Skip to content

TFT_eSPI ESP32 Color Display Guide

This guide takes a color TFT project from an empty Lopaka canvas to a working ESP32 display, step by step.

The division of work matters more here than on any other platform: Lopaka draws the picture, you configure the library. TFT_eSPI is configured at compile time through a settings file, and Lopaka never touches that file. Get the library talking to your panel first, then let Lopaka generate the drawing code.

What is TFT_eSPI?

TFT_eSPI is a fast SPI display library for ESP32, ESP8266, RP2040, and STM32. It supports the controllers found on most cheap color modules — ILI9341, ST7735, ST7789, GC9A01, ILI9488, and many more — and gives you one drawing API for all of them.

Lopaka's TFT_eSPI, M5, Lovyan platform also covers two API-compatible libraries:

LibraryTypical hardwareDisplay object in your sketch
TFT_eSPIGeneric ESP32/ESP8266/RP2040 + panelTFT_eSPI tft = TFT_eSPI();
M5GFX (M5Unified)M5Stack Core, StickC, Dial, CardputerM5.Display
LovyanGFXAny of the above, ESP32-S3 parallelYour own LGFX subclass

All three expose the same drawing calls (fillRect, drawString, pushImage, …), so the same generated code works on all three once the display object is named tft.

What TFT_eSPI is not

It is not a UI toolkit. There are no buttons, no layout engine, and no automatic redraw. You draw pixels when you decide to draw them. If you want widgets and event handling, look at LVGL instead.

Before you start

You need:

  • An ESP32 (or ESP8266 / RP2040 / STM32) board and a color TFT module.
  • The controller name printed in your module's listing or datasheet — for example ILI9341, ST7789, GC9A01.
  • The resolution (240×320, 240×240, 128×160, …) and the pins you wired the display to.
  • Arduino IDE or PlatformIO, able to compile and upload to your board.

WARNING

Lopaka does not detect your controller, generate wiring, or write TFT_eSPI's configuration. Two modules with identical resolution can need completely different drivers, pin assignments, color order, and offsets. That part is always yours.

Step 0: Install and configure TFT_eSPI

This is the step beginners skip and then spend an evening on a white screen. Do it first, and do not open Lopaka until a stock example draws on your panel.

Install the library

Arduino IDE: open Tools → Manage Libraries, search for TFT_eSPI by Bodmer, install.

PlatformIO: add it to platformio.ini:

ini
lib_deps = bodmer/TFT_eSPI

Tell it about your display

TFT_eSPI reads its configuration from User_Setup.h at compile time, not from the constructor. There are two ways to set it, and you only pick one:

  1. Edit User_Setup.h in the library folder (Arduino/libraries/TFT_eSPI/User_Setup.h). Uncomment the #define for your driver, then set the pins and the resolution.
  2. Use a bundled setup file. Open User_Setup_Select.h, comment out the #include <User_Setup.h> line, and uncomment one of the User_Setups/Setup*.h files that matches your board. This is the easier path for known boards such as the ESP32 TTGO T-Display.

A minimal User_Setup.h for an ILI9341 panel on an ESP32 looks roughly like this:

cpp
#define ILI9341_DRIVER          // one driver, uncommented

#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               // built-in Adafruit 5x7 font
#define LOAD_GFXFF              // Free Fonts — required for Lopaka font layers

#define SPI_FREQUENCY 40000000

Keep LOAD_GLCD and LOAD_GFXFF enabled

LOAD_GLCD provides the built-in 5×7 font. LOAD_GFXFF enables setFreeFont(), which every non-default Lopaka text layer uses. If LOAD_GFXFF is commented out, your text silently disappears or fails to compile.

PlatformIO users can pass the same defines as build flags instead of editing library files, which survives library updates:

ini
build_flags =
    -DUSER_SETUP_LOADED=1
    -DILI9341_DRIVER=1
    -DTFT_WIDTH=240
    -DTFT_HEIGHT=320
    -DTFT_MOSI=23
    -DTFT_SCLK=18
    -DTFT_CS=15
    -DTFT_DC=2
    -DTFT_RST=4
    -DLOAD_GLCD=1
    -DLOAD_GFXFF=1
    -DSPI_FREQUENCY=40000000

Prove it works

Open File → Examples → TFT_eSPI → Test and Diagnostics → Colour_Test (or Generic → Free_Font_Demo) and upload it. You should see colors and text. If you do not, fix that before continuing — every later problem in this guide becomes unsolvable while the library configuration is wrong.

Keep that working example as your base sketch. From here on you only replace its drawing calls with Lopaka output.

Step 1: Create the Lopaka project

  1. Open lopaka.app and create a project.
  2. Select the TFT_eSPI, M5, Lovyan platform.
  3. Set Screen Size to the resolution your sketch actually uses.
  4. Pick the screen background color — it becomes the fillScreen() argument in the generated code.

Coordinates start at (0, 0) in the top-left corner, X grows to the right, Y grows down. Lopaka uses the same system, so a layer at (10, 20) in the editor is at (10, 20) on the panel.

Rotation

If your sketch calls tft.setRotation(1) on a 240×320 panel, the logical screen becomes 320×240 — so the Lopaka canvas should be 320×240. Lopaka never emits setRotation(); match the canvas to the orientation you have chosen in code.

Step 2: Design a screen

Place layers with the toolbar, reorder them in the Layers panel, and edit values in the Inspector. Layers are drawn in list order, so a later layer paints over an earlier one — exactly as the generated calls will run.

Rename layers before you export. Names become code comments, bitmap array names, polygon helper function names, animation headers, and variable names.

What each layer generates

Lopaka layerGenerated TFT_eSPI call
Dottft.drawPixel(x, y, color)
Linetft.drawLine(x1, y1, x2, y2, color)
Rectangletft.drawRect(...) / tft.fillRect(...)
Rounded rectangletft.drawRoundRect(...) / tft.fillRoundRect(...)
Ellipsetft.drawEllipse(x, y, rx, ry, color) / tft.fillEllipse(...)
Triangletft.drawTriangle(...) / tft.fillTriangle(...)
Arctft.drawSmoothArc(...) or tft.drawArc(...)
PolygonA generated void drawMyShape(void) helper built from drawLine() calls
TextsetTextColor(), setTextSize(), setFreeFont(), drawString()
Paint / icon / imagetft.drawBitmap(...) (1-bit) or tft.pushImage(...) (RGB565)
AnimationFrame arrays plus updateAnimations() / drawAnimations() helpers

There is no Circle tool on this platform — use the Ellipse tool with equal radii; TFT_eSPI's ellipse primitives cover it.

Colors

Lopaka lets you pick any RGB color, then packs it into the 16-bit RGB565 value TFT_eSPI expects, so #FF0000 becomes 0xF800. That is 5 bits of red, 6 of green, 5 of blue — 65,536 colors, not 16 million. Subtle gradients will band. There is no alpha channel: transparency in a source image is composited against the screen background at export time.

Text

Text layers produce drawString(text, x, y), where x, y is the top-left corner of the text — TFT_eSPI's default TL_DATUM. Lopaka adjusts the Y coordinate for the selected font's metrics, so you do not do baseline math by hand.

Three properties map to library state, and Lopaka emits each call only when it changes between layers:

  • ColorsetTextColor(fg), or setTextColor(fg, bg) when you set a text background color. A background makes redrawn text overwrite its old pixels instead of leaving trails.
  • SizesetTextSize(n), an integer pixel multiplier. 2 doubles every pixel, so it looks chunky rather than smooth.
  • FontsetFreeFont(&FreeSans9pt7b) for a GFX font, or setFreeFont(NULL) for the built-in 5×7 face.

WARNING

If your own code changes the text datum (tft.setTextDatum(MC_DATUM)), generated text lands in the wrong place. Reset it with tft.setTextDatum(TL_DATUM) before calling the Lopaka function.

Arcs

The Arc tool is unique to this platform. Angles are degrees measured clockwise from 12 o'clock, so a gauge from 30 to 330 opens at the bottom. The Inspector's smooth toggle chooses between the two library calls:

cpp
// Smooth: antialiased edges, rounded ends
tft.drawSmoothArc(120, 120, 100, 92, 30, 330, 0x07E0, 0x0000, true);
// Not smooth: hard pixel edges
tft.drawArc(120, 120, 100, 92, 30, 330, 0x07E0, 0x0000, false);

Both take a background color, because antialiased pixels are blended against it. Set the arc's background to whatever it actually sits on, or its edges will be haloed. drawSmoothArc() requires TFT_eSPI 2.4.x or newer — update the library if the compiler says it is undefined.

Step 3: Configure generated code

Open Code Settings below the code panel. This platform has one C++ template with these toggles:

SettingWhat it does
Wrapper functionWraps the screen in void draw{ScreenName}(void)
Include fontsEmits #include "FontName.h" for every non-default font used
Declare imagesEmits the bitmap arrays that image layers reference
Animation helpersEmits animation header includes plus timing and draw helpers
Declare variablesEmits declarations for layer properties you marked as variables
Comments, layer titlesAdds a // Layer name comment above each layer's calls
Clear/Fill displayStarts the block with tft.fillScreen(backgroundColor)

For your first export, leave everything on. The Copy button copies exactly what the panel shows.

The object must be called tft

Every generated line starts with tft.. The simplest fix is to name your display object tft in the sketch. On M5Stack, add one alias line: M5GFX& tft = M5.Display;

Step 4: Fonts, images, and animations

Fonts

The built-in Adafruit 5×7 font needs no header file. Every other font does.

For each non-default font on the screen:

  1. Open Code Settings and find the Fonts section.
  2. Click the download button next to the font to get its .h file.
  3. Put that file in the same folder as your .ino or .cpp.
  4. Keep Include fonts enabled so the #include "FreeSans9pt7b.h" line stays in the output.

Lopaka ships the Adafruit Free Fonts (FreeSans, FreeMono, FreeSerif in 9/12/18/24pt, with bold and oblique variants) and can convert uploaded TrueType (.ttf, .otf, .woff) and BDF fonts into GFX format. See the fonts guide for the import paths.

Font data lives in flash. A 24pt font with a full character set costs tens of kilobytes, so use large faces sparingly.

Images

Use the Image tool to import and crop artwork at its final pixel size. What gets generated depends on the layer's color mode:

  • Monochrome → a 1-bit PROGMEM array drawn with tft.drawBitmap(x, y, bits, w, h, color). One color, unset pixels transparent, 1 bit per pixel. Best for icons.
  • RGB → a uint16_t PROGMEM RGB565 array drawn with tft.pushImage(x, y, w, h, pixels). Full color, 2 bytes per pixel.

Watch the flash budget

A full-screen 240×320 RGB image is 153,600 bytes — roughly 12% of a standard ESP32 sketch partition, for one picture. Crop tightly, downscale, and use monochrome for anything that is a single color.

If image colors come out wrong while shapes look correct, see byte order in Troubleshooting.

The image import guide covers resizing, cropping, dithering, and size estimates.

Animations

Animation layers export as 1-bit frames — a two-color flipbook, not video. Each frame is drawn with setBitmapColor(foreground, background) followed by pushImage().

Turning Animation helpers on changes the shape of the output significantly:

cpp
#include "spinner.h"

void updateAnimations() {
    animation_spinner_frame = millis() / 100 % 12;
}

void drawAnimation_spinner(void) {
    tft.setBitmapColor(0xFFFF, 0x0);
    tft.pushImage(80, 60, 32, 32, animation_spinner_frames[animation_spinner_frame], false, nullptr);
}

void drawAnimations() {
    updateAnimations();
    drawAnimation_spinner();
}

void drawMain(void) { /* static layers */ }

void loop(){
    drawAnimations();
};

void setup() {
    tft.begin();
    drawMain();
}

Merge, do not paste

That generated setup() and loop() is a starter skeleton. Your sketch already has both. Keep one setup() and one loop(): take the includes, the frame arrays, updateAnimations(), and the draw helpers, then call drawAnimations() from your existing loop.

Download every .h file listed under Code Settings → Animations and keep it beside the sketch. See Animations for the editor workflow.

Step 5: Copy and integrate

Name your screen Main, add a text layer and a rectangle, and click Copy. You get something like:

cpp
#include "FreeSans9pt7b.h"

void drawMain(void) {
    tft.fillScreen(0x0);
    // Title
    tft.setTextColor(0xFFFF);
    tft.setTextSize(1);
    tft.setFreeFont(&FreeSans9pt7b);
    tft.drawString("Hello!", 8, 12);
    // Border
    tft.drawRect(0, 0, 240, 240, 0xFFFF);
}

Dropped into a complete sketch:

cpp
#include <SPI.h>
#include <TFT_eSPI.h>

TFT_eSPI tft = TFT_eSPI();

// ---- BEGIN LOPAKA GENERATED CODE ----
#include "FreeSans9pt7b.h"

void drawMain(void) {
    tft.fillScreen(0x0);
    // Title
    tft.setTextColor(0xFFFF);
    tft.setTextSize(1);
    tft.setFreeFont(&FreeSans9pt7b);
    tft.drawString("Hello!", 8, 12);
    // Border
    tft.drawRect(0, 0, 240, 240, 0xFFFF);
}
// ---- END LOPAKA GENERATED CODE ----

void setup() {
    tft.init();
    tft.setRotation(0);
    drawMain();
}

void loop() {}

TFT_eSPI writes straight to the panel, so there is no buffer to flush — nothing like the display.display() call monochrome libraries need.

M5Stack

M5GFX is the same API behind a different object. One alias line is all it takes:

cpp
#include <M5Unified.h>

M5GFX& tft = M5.Display;   // now every generated tft.* call works

// Paste the Lopaka block here.

void setup() {
    auto cfg = M5.config();
    M5.begin(cfg);
    drawMain();
}

void loop() { M5.update(); }

M5Unified configures the panel for the specific M5 board automatically, so you skip User_Setup.h entirely. Follow your board's official example for M5.begin().

LovyanGFX

LovyanGFX needs an LGFX class describing your panel (its repository has ready-made configs for common boards). Name the instance tft and the generated code drops in unchanged.

Step 6: Runtime data and more screens

Making values dynamic

Static text is fine for a mockup, useless for a thermometer. Select a layer, enable the variable toggle for a supported property in the Inspector, and keep Declare variables enabled. Lopaka replaces the literal with a declaration named after the layer:

cpp
const char* temperature = "21.5 C";

void drawMain(void) {
    // ...
    tft.drawString(temperature, 8, 40);
}

Update the variable in your code, call drawMain() again, and the new value appears.

Avoid full-screen flicker

Calling drawMain() in a tight loop repaints everything and flickers. For a value that changes often, give its text layer a background color and redraw only that layer's call — or draw into a TFT_eSprite and push the sprite. Sprites are a TFT_eSPI feature you add yourself; Lopaka does not generate them.

Multiple screens

Each Lopaka screen exports independently, and its title becomes the function name. Copy each one in, merge duplicate includes and bitmap arrays, then let your code choose:

cpp
enum Screen { MAIN, SETTINGS };
Screen currentScreen = MAIN;

void drawCurrentScreen(void) {
    if (currentScreen == MAIN) {
        drawMain();
    } else {
        drawSettings();
    }
}

Importing existing code

The code panel has an Import code button that reads a .ino, .c, .cpp, or .txt file and turns recognized tft.* calls into editable layers. It is experimental and works best on code Lopaka generated itself — hand-written sketches with loops, variables, and helper functions will not fully round-trip. Imported layers are appended to the current screen.

Lopaka handles vs. you handle

Lopaka handlesYou handle
Pixel layout, layer order, and the drawing callsUser_Setup.h, driver selection, pins, and SPI frequency
RGB → RGB565 packing for shapes, text, and imagesPanel color order, inversion, and offsets
Bitmap arrays, GFX font headers, animation framesFlash budget and keeping downloaded .h files with the sketch
Text baseline math and font state callsText datum if your code changes it
A named draw function per screenCalling it, redraw timing, input, and navigation
Optional variable declarationsUpdating those variables with real data

Compile, upload, and iterate

  1. Compile before uploading, and fix missing headers or undefined symbols first.
  2. Upload and compare the panel against the Lopaka canvas.
  3. Adjust canvas size, rotation, or layer positions and re-export.
  4. When the design changes, replace the whole generated block. Do not hand-edit individual draw calls — the next export will overwrite them.

Marking the block makes that painless:

cpp
// ---- BEGIN LOPAKA GENERATED CODE ----
// Replace this entire block after exporting again.
// ---- END LOPAKA GENERATED CODE ----

Troubleshooting

The screen is white, black, or blank

Almost always the library configuration, not the generated code.

  • Re-run a stock TFT_eSPI example. If that is blank too, the problem is User_Setup.h, wiring, or power.
  • Confirm the correct driver #define is uncommented, and only one.
  • Check the backlight pin — many modules need TFT_BL driven high.
  • Confirm tft.init() runs before your draw function.
  • Lower SPI_FREQUENCY to 27000000; long jumper wires do not survive 40 MHz.

'tft' was not declared in this scope

The generated code calls tft.*. Name your display object tft, or add an alias such as M5GFX& tft = M5.Display;.

setFreeFont or drawSmoothArc is undefined

setFreeFont() needs #define LOAD_GFXFF in User_Setup.h. drawSmoothArc() needs TFT_eSPI 2.4.x or newer — update through the Library Manager.

A font header will not compile

Download the .h from Code Settings → Fonts, put it next to the sketch, and keep Include fonts enabled so the filename, the include, and the &FontName symbol all agree.

Colors are wrong everywhere

Red and blue swapped across the whole screen is a panel color-order issue: try TFT_RGB_ORDER TFT_BGR (or TFT_RGB) in User_Setup.h. An inverted-looking image is #define TFT_INVERSION_ON / TFT_INVERSION_OFF. Fix it in the setup file — never by changing every color in your design.

Image colors are wrong but shapes are fine

Lopaka writes RGB565 image data byte-swapped, the same convention most TFT_eSPI image converters use. Tell the library to swap it back before pushing images:

cpp
tft.setSwapBytes(true);

Call it once in setup(), after tft.init(). If images were already correct and this breaks them, set it to false.

Text is in the wrong place

Reset the datum with tft.setTextDatum(TL_DATUM) before the generated function — Lopaka assumes the default top-left datum. If everything is offset or clipped instead, the canvas size does not match the display after setRotation().

Text leaves smears when it updates

drawString() only paints glyph pixels; old ones stay. Give the text layer a background color in Lopaka so it emits setTextColor(fg, bg), or clear the area with fillRect() first.

The design is rotated, mirrored, or shifted by a few pixels

Match the Lopaka canvas to the logical size after setRotation(). A constant few-pixel offset usually means the wrong driver variant — some panels have controller RAM larger than the visible area and need a driver-specific offset define.

The sketch does not fit in flash

RGB565 images are the usual culprit at 2 bytes per pixel. Crop tighter, downscale, convert single-color art to monochrome, and drop unused large fonts.

Animation code conflicts with my sketch

Keep one setup() and one loop(). Take the includes, frame arrays, and helper functions from the generated output; keep your own initialization; call drawAnimations() from your existing loop.

FAQ

Does Lopaka configure TFT_eSPI for me?

No. User_Setup.h, driver selection, pins, and SPI frequency are entirely yours. Lopaka generates drawing calls and assets that assume a working, initialized display object named tft.

Does my display work with Lopaka?

If TFT_eSPI, M5GFX, or LovyanGFX drives it, yes — Lopaka targets the shared API, not a list of modules. Verify the module with a stock library example first.

Can I export full-color images?

Yes — RGB image layers become RGB565 arrays drawn with pushImage(). Animation frames are always 1-bit, whatever colors they use in the editor.

Does Lopaka generate sprites, DMA, or partial redraws?

No. It emits direct drawing calls. TFT_eSprite, DMA transfers, and dirty-rectangle redraw are optimizations you add around the generated function.

Where should I learn more?