If you have been building ESP32 projects with the ubiquitous 0.96" OLED, you already know its limits: tiny screen, monochrome only, and no practical way to show images or color-coded dashboards. Color TFT displays change everything. For a few hundred rupees more, you get vibrant 65K-color screens that can render graphs, photos, animated UIs, and touch interfaces.
This guide walks you through the two most popular TFT controllers in the maker ecosystem — the ST7789 and the ILI9341 — from basic wiring to advanced techniques like sprite-based animation, real-time graphing, image rendering from SPIFFS, and touch input handling.
Why Move Beyond Monochrome?
A 128x64 SSD1306 OLED is perfect for printing a temperature reading or a short status line. But the moment your project needs any of the following, you need a color TFT:
- Sensor dashboards with color-coded zones (green/yellow/red)
- Real-time line graphs of temperature, humidity, or voltage over time
- Image display — logos, icons, photos from flash storage
- Touch-based GUIs with buttons, sliders, and menus
- Weather stations with multi-zone layouts
- Game development — even simple ones benefit enormously from color and resolution
Color TFTs also offer much larger screen real estate. A 1.3" 240x240 ST7789 has nearly 4x the pixels of a 128x64 OLED, and a 2.8" ILI9341 at 320x240 gives you over 6x the pixels.
ST7789 vs ILI9341: Choosing Your Controller
Both are SPI-based TFT controllers that work beautifully with ESP32. Here is how they compare:
| Feature | ST7789 | ILI9341 |
|---|---|---|
| Common resolutions | 240x240 (1.3"), 240x320 (2.0") | 240x320 (2.4", 2.8") |
| Color depth | 65K (RGB565) | 65K (RGB565) |
| Interface | SPI (up to 80 MHz) | SPI (up to 40 MHz typical) |
| Voltage | 3.3V native | 3.3V native |
| Touch support | Rarely included | Often bundled with XPT2046 |
| CS pin | Some modules omit CS (active low tied to GND) | Always present |
| Typical price (India) | 180-350 INR | 250-500 INR |
| Best for | Compact displays, wearables | Larger dashboards, touch UIs |
ST7789 modules are smaller, cheaper, and often ship without a CS pin — making them dead simple to wire. ILI9341 modules are the go-to choice when you need a larger screen and capacitive or resistive touch input.
Both controllers use the SPI interface, which is significantly faster than I2C. While an SSD1306 OLED over I2C tops out around 400 KHz, SPI TFT displays routinely run at 40-80 MHz — fast enough for smooth animations and full-screen redraws.
SPI Interface: Pin Requirements
SPI needs more GPIO pins than I2C, but the speed trade-off is well worth it. Here are the signals involved:
| Signal | Direction | Purpose |
|---|---|---|
| MOSI (SDA) | ESP32 to Display | Serial data out |
| SCK (SCL) | ESP32 to Display | Serial clock |
| CS | ESP32 to Display | Chip select (active low) |
| DC (RS) | ESP32 to Display | Data/Command select |
| RST | ESP32 to Display | Hardware reset |
| BL (LED) | ESP32 to Display | Backlight control |
The good news: both ST7789 and ILI9341 modules designed for makers run at 3.3V logic, which is the native level of ESP32 GPIO. No level shifters needed.
Wiring ST7789 to ESP32
Here is a reliable pin assignment for a 1.3" ST7789 240x240 module connected to an ESP32 DevKit:
| ST7789 Pin | ESP32 GPIO | Notes |
|---|---|---|
| VCC | 3.3V | Never connect to 5V |
| GND | GND | Common ground |
| SCL (SCK) | GPIO 18 | SPI clock |
| SDA (MOSI) | GPIO 23 | SPI data |
| CS | GPIO 5 | Chip select (or skip if module has no CS) |
| DC | GPIO 2 | Data/Command |
| RST | GPIO 4 | Reset |
| BL | GPIO 15 | Backlight (or connect to 3.3V for always-on) |
If your ST7789 module lacks a CS pin, you can set CS to -1 in the library configuration. The display is permanently selected in that case.
For ILI9341, use the same SPI pins (GPIO 18/23) but add the touch controller pins:
| ILI9341 Pin | ESP32 GPIO |
|---|---|
| T_CS | GPIO 21 |
| T_IRQ | GPIO 22 |
| T_DIN | GPIO 23 (shared MOSI) |
| T_DO | GPIO 19 (MISO) |
| T_CLK | GPIO 18 (shared SCK) |
The touch controller (XPT2046) shares the SPI bus with the display. The separate T_CS line ensures only one device communicates at a time.
Setting Up TFT_eSPI: The Essential Library
The TFT_eSPI library by Bodmer is the gold standard for driving TFT displays on ESP32. It is significantly faster than Adafruit_GFX because it uses hardware SPI with DMA and is optimized specifically for ESP32.
Install it via the Arduino Library Manager or PlatformIO:
pio lib install "TFT_eSPI"
Configuring User_Setup.h
TFT_eSPI uses a header file for configuration instead of runtime parameters. Open the file at libraries/TFT_eSPI/User_Setup.h and make these changes:
// 1. Select your driver (uncomment ONE)
#define ST7789_DRIVER // For ST7789
// #define ILI9341_DRIVER // For ILI9341
// 2. Set resolution
#define TFT_WIDTH 240
#define TFT_HEIGHT 240 // Use 320 for 240x320 displays
// 3. Pin assignments (must match your wiring)
#define TFT_MOSI 23
#define TFT_SCLK 18
#define TFT_CS 5 // Set to -1 if no CS pin
#define TFT_DC 2
#define TFT_RST 4
#define TFT_BL 15 // Backlight pin
// 4. SPI clock speed
#define SPI_FREQUENCY 40000000 // 40 MHz (safe default)
// #define SPI_FREQUENCY 80000000 // 80 MHz (faster, test first)
// 5. For ILI9341 with touch, also set:
// #define TOUCH_CS 21
Pro tip: Instead of modifying the library file directly, create a User_Setup_Select.h that points to your own setup file. This way, library updates will not overwrite your configuration.
Basic Setup: Hello TFT
Let us start with the simplest possible sketch — fill the screen and draw text:
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();
void setup() {
tft.init();
tft.setRotation(0); // 0-3 for different orientations
tft.fillScreen(TFT_BLACK); // Clear screen
// Draw a welcome message
tft.setTextColor(TFT_WHITE, TFT_BLACK); // Text color, background
tft.setTextSize(2);
tft.setCursor(20, 100);
tft.println("Hello, TFT!");
// Draw colored text
tft.setTextColor(TFT_GREEN);
tft.setTextSize(1);
tft.setCursor(20, 140);
tft.println("ST7789 240x240 Display");
}
void loop() {
// Nothing here yet
}
Understanding RGB565 Colors
TFT displays use 16-bit RGB565 encoding: 5 bits red, 6 bits green, 5 bits blue. TFT_eSPI provides predefined constants like TFT_RED, TFT_GREEN, TFT_BLUE, TFT_WHITE, TFT_BLACK, TFT_CYAN, TFT_YELLOW, and more.
To create custom colors, use the tft.color565() function:
uint16_t orange = tft.color565(255, 165, 0);
uint16_t darkGreen = tft.color565(0, 100, 0);
uint16_t customBlue = tft.color565(30, 58, 95); // Wavtron navy
Drawing Shapes and Lines
TFT_eSPI has a rich set of drawing primitives:
void drawShapesDemo() {
tft.fillScreen(TFT_BLACK);
// Lines
tft.drawLine(0, 0, 239, 239, TFT_WHITE); // Diagonal
tft.drawFastHLine(0, 120, 240, TFT_YELLOW); // Horizontal (fast)
tft.drawFastVLine(120, 0, 240, TFT_YELLOW); // Vertical (fast)
// Rectangles
tft.drawRect(10, 10, 100, 60, TFT_CYAN); // Outline
tft.fillRect(130, 10, 100, 60, TFT_RED); // Filled
// Rounded rectangles
tft.fillRoundRect(10, 170, 100, 50, 10, TFT_GREEN);
// Circles
tft.drawCircle(180, 140, 30, TFT_MAGENTA); // Outline
tft.fillCircle(60, 140, 25, TFT_BLUE); // Filled
// Triangles
tft.fillTriangle(130, 170, 170, 230, 220, 170, TFT_ORANGE);
}
Performance note: drawFastHLine and drawFastVLine are significantly faster than generic drawLine for axis-aligned lines because they use bulk SPI transfers.
Displaying Sensor Data: Temperature Dashboard
Here is a practical example — a formatted temperature and humidity dashboard using a DHT22 sensor:
#include <TFT_eSPI.h>
#include <DHT.h>
#define DHTPIN 16
#define DHTTYPE DHT22
TFT_eSPI tft = TFT_eSPI();
DHT dht(DHTPIN, DHTTYPE);
void setup() {
tft.init();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
dht.begin();
drawStaticUI();
}
void drawStaticUI() {
// Title bar
tft.fillRect(0, 0, 240, 40, tft.color565(30, 58, 95));
tft.setTextColor(TFT_WHITE);
tft.setTextSize(2);
tft.setCursor(30, 12);
tft.print("SENSOR STATION");
// Temperature section
tft.setTextColor(TFT_LIGHTGREY);
tft.setTextSize(1);
tft.setCursor(20, 55);
tft.print("TEMPERATURE");
// Humidity section
tft.setCursor(20, 145);
tft.print("HUMIDITY");
// Divider
tft.drawFastHLine(20, 130, 200, tft.color565(60, 60, 60));
}
void updateReadings() {
float temp = dht.readTemperature();
float humi = dht.readHumidity();
if (isnan(temp) || isnan(humi)) return;
// Clear previous values
tft.fillRect(20, 70, 200, 50, TFT_BLACK);
tft.fillRect(20, 160, 200, 50, TFT_BLACK);
// Temperature value
uint16_t tempColor = (temp > 35) ? TFT_RED :
(temp > 25) ? TFT_YELLOW : TFT_GREEN;
tft.setTextColor(tempColor, TFT_BLACK);
tft.setTextSize(4);
tft.setCursor(20, 75);
tft.printf("%.1f", temp);
tft.setTextSize(2);
tft.print(" C");
// Humidity value
uint16_t humiColor = (humi > 70) ? TFT_CYAN :
(humi > 40) ? TFT_GREEN : TFT_ORANGE;
tft.setTextColor(humiColor, TFT_BLACK);
tft.setTextSize(4);
tft.setCursor(20, 165);
tft.printf("%.0f", humi);
tft.setTextSize(2);
tft.print(" %");
}
void loop() {
updateReadings();
delay(2000);
}
Notice how we only redraw the value areas each cycle, not the entire screen. This eliminates flicker and dramatically improves perceived performance.
Drawing Real-Time Graphs
A scrolling line chart is one of the most impressive things you can show on a TFT. Here is a complete implementation that plots the last 200 temperature readings as a scrolling graph:
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();
#define GRAPH_X 20
#define GRAPH_Y 50
#define GRAPH_W 200
#define GRAPH_H 150
#define MAX_POINTS 200
float readings[MAX_POINTS];
int readIndex = 0;
bool bufferFull = false;
void setup() {
tft.init();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
memset(readings, 0, sizeof(readings));
drawGraphFrame();
}
void drawGraphFrame() {
// Title
tft.setTextColor(TFT_WHITE);
tft.setTextSize(2);
tft.setCursor(20, 15);
tft.print("TEMP GRAPH");
// Axis labels
tft.setTextSize(1);
tft.setTextColor(TFT_LIGHTGREY);
tft.setCursor(0, GRAPH_Y);
tft.print("40");
tft.setCursor(0, GRAPH_Y + GRAPH_H - 8);
tft.print("20");
// Frame
tft.drawRect(GRAPH_X - 1, GRAPH_Y - 1,
GRAPH_W + 2, GRAPH_H + 2,
tft.color565(60, 60, 60));
// Grid lines (horizontal)
for (int i = 1; i < 4; i++) {
int y = GRAPH_Y + (GRAPH_H * i / 4);
for (int x = GRAPH_X; x < GRAPH_X + GRAPH_W; x += 6) {
tft.drawPixel(x, y, tft.color565(40, 40, 40));
}
}
}
void addReading(float value) {
readings[readIndex] = value;
readIndex++;
if (readIndex >= MAX_POINTS) {
readIndex = 0;
bufferFull = true;
}
}
void drawGraph() {
// Clear graph area
tft.fillRect(GRAPH_X, GRAPH_Y, GRAPH_W, GRAPH_H, TFT_BLACK);
// Redraw grid
for (int i = 1; i < 4; i++) {
int y = GRAPH_Y + (GRAPH_H * i / 4);
for (int x = GRAPH_X; x < GRAPH_X + GRAPH_W; x += 6) {
tft.drawPixel(x, y, tft.color565(40, 40, 40));
}
}
int count = bufferFull ? MAX_POINTS : readIndex;
if (count < 2) return;
float minVal = 20.0, maxVal = 40.0; // Fixed range for temperature
for (int i = 1; i < count; i++) {
int idx0 = (bufferFull ? (readIndex + i - 1) : (i - 1)) % MAX_POINTS;
int idx1 = (bufferFull ? (readIndex + i) : i) % MAX_POINTS;
int x0 = GRAPH_X + map(i - 1, 0, MAX_POINTS - 1, 0, GRAPH_W - 1);
int x1 = GRAPH_X + map(i, 0, MAX_POINTS - 1, 0, GRAPH_W - 1);
int y0 = GRAPH_Y + GRAPH_H - (int)((readings[idx0] - minVal)
/ (maxVal - minVal) * GRAPH_H);
int y1 = GRAPH_Y + GRAPH_H - (int)((readings[idx1] - minVal)
/ (maxVal - minVal) * GRAPH_H);
y0 = constrain(y0, GRAPH_Y, GRAPH_Y + GRAPH_H);
y1 = constrain(y1, GRAPH_Y, GRAPH_Y + GRAPH_H);
tft.drawLine(x0, y0, x1, y1, TFT_GREEN);
}
}
void loop() {
// Simulate sensor reading (replace with actual sensor)
float temp = 28.0 + sin(millis() / 5000.0) * 5.0
+ random(-10, 10) / 10.0;
addReading(temp);
drawGraph();
delay(500);
}
This circular buffer approach means the graph scrolls continuously as new data arrives. In a real project, you would replace the simulated reading with dht.readTemperature() or an analogRead from a thermistor.
Displaying Images from SPIFFS
You can store JPEG images in the ESP32's SPIFFS filesystem and render them on the TFT. This is great for splash screens, icons, and photo slideshows.
First, upload your images to SPIFFS using the ESP32 Sketch Data Upload tool in Arduino IDE or via PlatformIO's Upload Filesystem Image command. Place image files in the data/ folder of your project.
#include <TFT_eSPI.h>
#include <SPIFFS.h>
#include <TJpg_Decoder.h> // Install via Library Manager
TFT_eSPI tft = TFT_eSPI();
// Callback: TJpg_Decoder sends decoded blocks here
bool tftOutput(int16_t x, int16_t y, uint16_t w, uint16_t h,
uint16_t* bitmap) {
if (y >= tft.height()) return false; // Skip if off-screen
tft.pushImage(x, y, w, h, bitmap);
return true; // Continue decoding
}
void setup() {
Serial.begin(115200);
tft.init();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
if (!SPIFFS.begin(true)) {
Serial.println("SPIFFS mount failed");
return;
}
// Configure decoder
TJpgDec.setJpgScale(1); // 1 = full size, 2 = half, 4 = quarter
TJpgDec.setCallback(tftOutput);
// Draw the image
TJpgDec.drawFsJpg(0, 0, "/splash.jpg", SPIFFS);
}
void loop() {}
Image preparation tips:
- Resize images to match your display resolution (240x240 or 320x240)
- Use JPEG, not PNG (TJpg_Decoder only supports JPEG)
- Keep file sizes under 50 KB for fast loading
- Use JPEG quality 70-80% for a good balance of size and quality
- SPIFFS partition is typically 1.5 MB — plan accordingly
For PNG support, look into the PNGdec library, which works similarly but decodes PNG files.
Touch Input with ILI9341 and XPT2046
Many ILI9341 modules come with a XPT2046 resistive touch controller built in. This opens the door to interactive GUIs. TFT_eSPI has built-in touch support.
First, make sure your User_Setup.h includes the touch CS pin:
#define TOUCH_CS 21
Then calibrate and read touch input:
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();
// Calibration values — run the calibration sketch first
uint16_t calData[5] = { 275, 3620, 264, 3532, 1 };
void setup() {
Serial.begin(115200);
tft.init();
tft.setRotation(1); // Landscape
tft.fillScreen(TFT_BLACK);
tft.setTouch(calData);
drawTouchDemo();
}
void drawTouchDemo() {
tft.setTextColor(TFT_WHITE);
tft.setTextSize(2);
tft.setCursor(40, 20);
tft.print("Touch the screen");
// Draw a target zone
tft.fillRoundRect(100, 80, 120, 60, 8, TFT_GREEN);
tft.setTextColor(TFT_BLACK);
tft.setTextSize(2);
tft.setCursor(115, 100);
tft.print("PRESS");
}
void loop() {
uint16_t touchX, touchY;
if (tft.getTouch(&touchX, &touchY)) {
Serial.printf("Touch at: %d, %d\n", touchX, touchY);
// Check if touch is inside the button
if (touchX > 100 && touchX < 220 && touchY > 80 && touchY < 140) {
tft.fillRoundRect(100, 80, 120, 60, 8, TFT_RED);
tft.setTextColor(TFT_WHITE);
tft.setTextSize(2);
tft.setCursor(110, 100);
tft.print("PRESSED");
delay(300);
// Redraw original button
tft.fillRoundRect(100, 80, 120, 60, 8, TFT_GREEN);
tft.setTextColor(TFT_BLACK);
tft.setCursor(115, 100);
tft.print("PRESS");
}
}
}
Calibration: TFT_eSPI includes a touch calibration example sketch (Examples > TFT_eSPI > Generic > Touch_calibrate). Run it once, note the five calibration values printed to Serial, and paste them into your code.
Simple GUI with Buttons
Building on touch input, here is a more structured approach to creating a multi-button interface:
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();
struct Button {
int16_t x, y, w, h;
const char* label;
uint16_t color;
bool pressed;
};
#define NUM_BUTTONS 3
Button buttons[NUM_BUTTONS] = {
{ 20, 80, 90, 50, "LED ON", TFT_GREEN, false },
{ 130, 80, 90, 50, "LED OFF", TFT_RED, false },
{ 75, 160, 90, 50, "STATUS", TFT_CYAN, false }
};
uint16_t calData[5] = { 275, 3620, 264, 3532, 1 };
void setup() {
tft.init();
tft.setRotation(1);
tft.fillScreen(TFT_BLACK);
tft.setTouch(calData);
// Header
tft.setTextColor(TFT_WHITE);
tft.setTextSize(2);
tft.setCursor(50, 20);
tft.print("CONTROL PANEL");
drawAllButtons();
}
void drawButton(Button &btn, bool highlight) {
uint16_t bgColor = highlight ? TFT_WHITE : btn.color;
uint16_t txtColor = highlight ? btn.color : TFT_BLACK;
tft.fillRoundRect(btn.x, btn.y, btn.w, btn.h, 8, bgColor);
tft.drawRoundRect(btn.x, btn.y, btn.w, btn.h, 8, btn.color);
// Center text in button
int16_t textWidth = strlen(btn.label) * 6; // Approximate
tft.setTextColor(txtColor);
tft.setTextSize(1);
tft.setCursor(btn.x + (btn.w - textWidth) / 2,
btn.y + (btn.h - 8) / 2);
tft.print(btn.label);
}
void drawAllButtons() {
for (int i = 0; i < NUM_BUTTONS; i++) {
drawButton(buttons[i], false);
}
}
void loop() {
uint16_t touchX, touchY;
if (tft.getTouch(&touchX, &touchY)) {
for (int i = 0; i < NUM_BUTTONS; i++) {
Button &btn = buttons[i];
if (touchX > btn.x && touchX < btn.x + btn.w &&
touchY > btn.y && touchY < btn.y + btn.h) {
drawButton(btn, true); // Highlight
handleButton(i);
delay(200);
drawButton(btn, false); // Reset
}
}
}
}
void handleButton(int index) {
switch (index) {
case 0:
digitalWrite(LED_BUILTIN, HIGH);
break;
case 1:
digitalWrite(LED_BUILTIN, LOW);
break;
case 2:
// Show status in footer area
tft.fillRect(0, 220, 320, 20, TFT_BLACK);
tft.setTextColor(TFT_YELLOW);
tft.setTextSize(1);
tft.setCursor(20, 224);
tft.printf("LED: %s | Uptime: %lus",
digitalRead(LED_BUILTIN) ? "ON" : "OFF",
millis() / 1000);
break;
}
}
Sprites: Flicker-Free Rendering
The biggest problem with direct-to-screen drawing is flicker — when you clear an area and redraw it, the user briefly sees the black background. Sprites solve this by rendering into an off-screen buffer, then pushing the entire buffer to the display in one SPI transaction.
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();
TFT_eSprite sprite = TFT_eSprite(&tft);
float angle = 0;
void setup() {
tft.init();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
// Create a sprite (off-screen buffer)
// 240x240 at 16-bit = 115,200 bytes — fits in ESP32 RAM
sprite.createSprite(240, 240);
sprite.setTextDatum(MC_DATUM); // Middle-center text alignment
}
void loop() {
sprite.fillSprite(TFT_BLACK);
// Draw animated gauge
int cx = 120, cy = 120, r = 80;
sprite.drawCircle(cx, cy, r, TFT_DARKGREY);
sprite.drawCircle(cx, cy, r - 1, TFT_DARKGREY);
// Gauge needle
float rad = angle * DEG_TO_RAD;
int nx = cx + cos(rad) * (r - 10);
int ny = cy + sin(rad) * (r - 10);
sprite.drawLine(cx, cy, nx, ny, TFT_GREEN);
// Value text
sprite.setTextColor(TFT_WHITE);
sprite.setTextSize(3);
sprite.drawString(String((int)angle) + " deg", cx, cy + r + 20);
// Push entire sprite to display — no flicker
sprite.pushSprite(0, 0);
angle += 2;
if (angle >= 360) angle = 0;
delay(20); // ~50 FPS
}
Memory warning: A full 240x240 16-bit sprite uses 115 KB of RAM. ESP32 has about 520 KB total, with roughly 300 KB available. For a 320x240 display, you may need to use partial sprites (e.g., 320x120 for the top half, then bottom half) or 8-bit color mode.
// 8-bit color sprite uses half the RAM
sprite.setColorDepth(8);
sprite.createSprite(320, 240); // 76,800 bytes instead of 153,600
Performance Optimization
SPI Clock Speed
The single biggest factor in display performance is SPI clock speed:
| Speed | Fill 240x240 | FPS (full redraw) | Stability |
|---|---|---|---|
| 20 MHz | ~30 ms | ~33 | Very stable |
| 40 MHz | ~15 ms | ~66 | Stable (recommended) |
| 80 MHz | ~8 ms | ~120+ | May need short wires (<10 cm) |
Start with 40 MHz. If your wiring is short and solid, try 80 MHz. Long jumper wires at 80 MHz can cause display glitches.
DMA Transfers
TFT_eSPI supports DMA (Direct Memory Access) on ESP32, which lets the SPI peripheral send data to the display while the CPU does other work:
// In User_Setup.h, enable DMA
#define USE_DMA_TO_TFT
// In code, use pushImageDMA for large transfers
sprite.pushSprite(0, 0); // Automatically uses DMA if enabled
DMA is most beneficial when you have CPU-intensive tasks (sensor reads, calculations) happening between screen updates.
Partial Updates
Instead of redrawing the entire screen, only update the areas that changed. This is the single most effective optimization:
// Bad: clear and redraw everything
tft.fillScreen(TFT_BLACK);
drawEverything();
// Good: only redraw the value that changed
tft.fillRect(VALUE_X, VALUE_Y, VALUE_W, VALUE_H, TFT_BLACK);
drawValue(newValue);
Backlight Control via PWM
Most TFT modules have a BL (backlight) pin. Instead of connecting it directly to 3.3V, connect it to a GPIO and use PWM for brightness control:
#define BL_PIN 15
void setup() {
// ESP32 LEDC PWM setup
ledcAttach(BL_PIN, 5000, 8); // 5 kHz, 8-bit resolution
setBacklight(128); // 50% brightness
}
void setBacklight(uint8_t brightness) {
ledcWrite(BL_PIN, brightness); // 0 = off, 255 = full
}
This is essential for battery-powered projects. Dimming the backlight from 100% to 50% can reduce display power consumption by 30-40%.
Power Consumption Comparison
| Display | Active (full brightness) | Dimmed (50%) | Sleep |
|---|---|---|---|
| 0.96" SSD1306 OLED | 8-15 mA | N/A (per-pixel) | <0.5 mA |
| 1.3" ST7789 TFT | 25-40 mA | 15-25 mA | 2-5 mA |
| 2.4" ILI9341 TFT | 60-90 mA | 35-55 mA | 5-10 mA |
| 2.8" ILI9341 TFT | 80-120 mA | 50-70 mA | 5-10 mA |
OLEDs have an advantage for mostly-dark UIs since unlit pixels draw zero current. TFTs always power the backlight regardless of content.
OLED vs TFT: Complete Comparison
| Feature | OLED (SSD1306) | TFT (ST7789/ILI9341) |
|---|---|---|
| Colors | Monochrome (white/blue) | 65,536 colors (RGB565) |
| Resolution | 128x64 typical | 240x240 to 320x240 |
| Contrast | Infinite (true black) | Good, but backlight bleeds |
| Viewing angle | Near 180 degrees | 160-170 degrees |
| Sunlight readability | Excellent | Moderate |
| Power (dark UI) | Very low | Higher (backlight always on) |
| Power (bright UI) | High | Moderate |
| Interface | I2C (slow) or SPI | SPI only (fast) |
| Burn-in risk | Yes (organic) | No |
| Touch option | No | Yes (ILI9341 + XPT2046) |
| Price range (India) | 100-250 INR | 180-500 INR |
| Best for | Simple status, low power | Dashboards, GUIs, images |
Bottom line: Use OLED when you need minimal power draw and simple text/icons. Use TFT when your project requires color, graphics, or touch interaction.
Display Selection Guide
Choosing the right display depends on your project requirements:
Use a 1.3" ST7789 (240x240) when you need:
- A compact color display for wearables or small enclosures
- Color-coded sensor readings without touch
- Minimal pin count (some modules need only 5 GPIOs)
- Budget-friendly color upgrade from OLED
Use a 2.0" ST7789 (240x320) when you need:
- More screen real estate than 1.3" but still compact
- Portrait-mode interfaces (menu lists, data tables)
- No touch requirement
Use a 2.4-2.8" ILI9341 (320x240) when you need:
- Touch input for interactive controls
- Detailed dashboards with multiple data zones
- Image display (photos, maps, diagrams)
- Desktop/bench instruments where size is not a constraint
Stick with SSD1306 OLED when you need:
- Battery life over everything else
- Sunlight-readable output
- Just one or two lines of text
- I2C simplicity (only 2 data pins)
Wiring Checklist Before You Power On
Before applying power, verify these points to avoid damaging your display:
- VCC to 3.3V — never 5V, even if the module has a regulator (some cheap ones do not)
- GND connected — floating ground causes unpredictable behavior
- SPI pins correct — swapping MOSI and SCK is the most common wiring mistake
- DC pin connected — without it, the display cannot distinguish commands from data
- RST pin connected — either to a GPIO or to 3.3V via a 10K resistor (for manual reset on power-up)
- Backlight connected — a TFT with no backlight power looks completely dead, even if SPI is working correctly
Conclusion
Color TFT displays transform ESP32 projects from functional prototypes into polished, user-friendly devices. The ST7789 and ILI9341 controllers are mature, well-supported, and affordable. Combined with the TFT_eSPI library, you get hardware-accelerated graphics rendering, DMA transfers, sprite-based animation, and touch input — all running on a microcontroller that costs a few hundred rupees.
Start with a simple "Hello TFT" sketch, then progressively add sensor data formatting, real-time graphs, and image rendering as your project demands. The performance headroom of SPI at 40-80 MHz means you will rarely hit a wall, even with full-screen animations.
Browse our collection of TFT displays, ESP32 development boards, and sensors at wavtron.in to get started with your next color display project.



