ESP32 + ILI9341 TFT: Display Color Images from PROGMEM
The ILI9341 320x240 TFT panel is the bread-and-butter color display for ESP32 hobby projects — 76,800 pixels of full color for under $5. This guide gets you from PNG to pushed-pixels with TFT_eSPI, image2cpp, and a handful of optimization tricks the docs gloss over.
Why TFT_eSPI over Adafruit_ILI9341
Both libraries draw to the same chip. TFT_eSPI is 5-10x faster on ESP32 because it uses ESP32's dedicated SPI hardware, DMA, and a tuned driver. Adafruit's library is portable but generic. For ESP32 + ILI9341, TFT_eSPI is the right pick.
Wiring: SPI to ESP32
| ILI9341 Pin | ESP32 GPIO |
|---|---|
| VCC | 3V3 |
| GND | GND |
| CS | 15 |
| RESET | 4 |
| DC | 2 |
| SDI (MOSI) | 23 |
| SCK | 18 |
| LED | 3V3 (or PWM pin for backlight control) |
| SDO (MISO) | 19 (optional, only if reading) |
Configure TFT_eSPI
TFT_eSPI is configured at compile time via User_Setup.h in the library folder. The simplest path is to copy User_Setup_Select.h and uncomment a profile, or edit User_Setup.h directly:
#define ILI9341_DRIVER
#define TFT_WIDTH 240
#define TFT_HEIGHT 320
#define TFT_MISO 19
#define TFT_MOSI 23
#define TFT_SCLK 18
#define TFT_CS 15
#define TFT_DC 2
#define TFT_RST 4
#define LOAD_GLCD
#define LOAD_FONT2
#define LOAD_GFXFF
#define SMOOTH_FONT
#define SPI_FREQUENCY 40000000 // 40MHz works on most boards
RGB565: the color format you'll convert to
ILI9341 natively accepts 16-bit RGB565: 5 bits red, 6 bits green (because the eye is most sensitive to green), 5 bits blue. A 320x240 image at RGB565 is 153,600 bytes — way too big for any AVR Arduino, comfortable on ESP32 (which has 4MB+ of flash).
Convert your image with image2cpp
- Open pixel.hjlabs.in/converter
- Drop in your PNG
- Set canvas to your target size (e.g. 320x240 or smaller)
- Output format: "Arduino code, 16-bit RGB565"
- Endianness: Little-endian (default) or Big-endian if your library expects it. TFT_eSPI's
pushImageassumes little-endian unless you settft.setSwapBytes(true).
Display the image
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();
// Paste image2cpp RGB565 output here:
const uint16_t myImage [] PROGMEM = {
0x0000, 0x0841, 0x10A2, /* ... 76,800 entries for 320x240 ... */
};
void setup() {
tft.init();
tft.setRotation(1); // landscape
tft.fillScreen(TFT_BLACK);
tft.setSwapBytes(true); // critical for image2cpp output
tft.pushImage(0, 0, 320, 240, myImage);
}
void loop() {}
The setSwapBytes gotcha
This single call trips up everyone. ESP32 SPI sends bytes high-byte first, but image2cpp generates the C array with low-byte first (matching how AVR / x86 view the uint16_t in memory). The result: red becomes blue, blue becomes red, green is roughly correct. Call tft.setSwapBytes(true) before pushImage and colors are correct.
Alternative: in image2cpp, choose the "Big-endian RGB565" output and skip setSwapBytes.
Drawing only a region
For a 64x64 sprite at position (100, 80):
tft.setSwapBytes(true);
tft.pushImage(100, 80, 64, 64, sprite);
Note: the array length must be exactly w * h uint16_t — not bytes. A 64x64 sprite is 4096 entries (8192 bytes).
PROGMEM vs flash on ESP32
ESP32 has no separate PROGMEM segment — flash is memory-mapped and accessed directly. The PROGMEM attribute is a no-op on ESP32 but kept for portability. Do not use pgm_read_word in ESP32 code — the macro on ESP32 is also a no-op but it's confusing. Just dereference the pointer normally.
Animation: rolling sprites
For a smooth scrolling sprite or animation, use TFT_eSPI's sprite class instead of full pushImage redraws:
TFT_eSprite sprite = TFT_eSprite(&tft);
void setup() {
tft.init();
tft.setRotation(1);
sprite.createSprite(64, 64);
sprite.pushImage(0, 0, 64, 64, mySprite);
}
void loop() {
static int x = 0;
sprite.pushSprite(x, 100);
x = (x + 1) % 320;
delay(16); // ~60fps
}
The sprite is rendered to off-screen RAM, then pushed in one DMA transaction. Tearing-free and fast.
Memory: when the image is too big for flash
A full-screen 320x240 RGB565 background is 150KB. Multiple full-screen images can blow past your flash partition. Two solutions:
- SPIFFS / LittleFS: store images as raw
.binfiles in the filesystem partition, load on demand. Slower but unlimited. - SD card: for galleries or wallpaper apps with hundreds of images.
For more on memory tradeoffs, see monochrome vs grayscale vs color bitmap memory.
Common problems
- White screen on boot. Wrong driver in User_Setup.h. ILI9341, ILI9341_2 and ILI9488 are different chips with similar pinouts.
- Image is rotated 90°. Set
tft.setRotation(0..3)to match. - Reds and blues swapped. setSwapBytes mismatch (covered above).
- Image is dim / washed out. Backlight not connected, or SPI signal integrity issues at 40MHz. Drop to 20MHz.
- Random crashes after pushImage. Array length wrong — you're reading past the end of flash. Recount: w*h uint16_t.
JPEG decoding alternative
For storage efficiency, use TJpg_Decoder to render JPEG files directly — a 320x240 JPEG is ~15KB instead of 150KB. The tradeoff is decode time (~50ms) and slightly fuzzy edges. Good for static backgrounds, bad for animations.
Bottom line
ESP32 + ILI9341 + TFT_eSPI + image2cpp's RGB565 output = full-color images in 30 lines of code. Remember setSwapBytes(true). Generate your RGB565 array now, or read our Arduino tutorial for fundamentals first.
Building a kiosk or photo frame? can quote a custom acrylic enclosure, and og.hjlabs.in handles your project README graphics.
Try image2cpp now — free, in browser
Drop in a PNG, JPG or BMP. Get a paste-ready C++ array for Arduino, ESP32 or RP2040. 100% client-side. Learn more about image2cpp or jump straight to the tool.
Open the converter →