esp32 advanced 60 min

ESP32: build a UI with LVGL on a touch display

Drive a 320x240 ILI9341 touch display with LVGL 9 on an ESP32. Real widgets, buttons that work, and the two-task structure that keeps drawing and touch alive.

Code available for: ESP32 ArduinoArduino C
Published Sep 22, 2026

An SSD1306 OLED gets you a 128x64 grid of dots and a display.print(). That is fine for a temperature. It is not fine for a thermostat. When you want labels, buttons, a bar chart, and touch input (e.g. a thermostat faceplate on the wall), you want LVGL: a full graphics library with widgets, styles, and animations that runs on an ESP32.

The trap I hit: I followed a video, everything compiled, and the screen stayed black. My buffer was fine, my pins were fine. The problem was that LVGL is not a fire-and-forget render loop. It needs its lv_timer_handler() called every few milliseconds forever, and the tutorial’s delay(1000) in loop() was starving it. Structure the sketch as two FreeRTOS tasks from the start (one draws, one reads touch and runs your logic) and LVGL just works.

What you need

Needed

  • ESP32 dev board, a plain WROOM-32 (about $8). No PSRAM needed for 320x240 at 16-bit color with one buffer.
  • 2.4 inch ILI9341 resistive touch display module, 320x240 SPI, with the XPT2046 touch controller on the back (about $10). Get the module with the touch chip included, the display-only ones will waste an afternoon.
  • Jumper wires (a lot of them, this module uses every pin)
  • Breadboard, or better, female-to-female jumpers straight to the module header

Nice to have

  • Helping hands or a third hand tool for holding the module while you probe pins
  • Magnifying goggles for reading the silkscreen labels (they are tiny and inconsistent between sellers)
  • Multimeter for verifying 3.3 V at the module before first power-up
  • Soldering iron + solder if your module ships with an unsoldered header strip
  • Soldering mat and iron stand
  • Anti-static wristband

Wiring

This is SPI for the display plus a second SPI for the touch controller (they share MISO/MOSI/SCK, separate chip selects).

Wire key: VCC3.3VGNDCSGPIORSTMOSISCKMISO
Display pinConnects to
VCCESP32 3.3V
GNDESP32 GND
CSESP32 GPIO 5
RESETESP32 GPIO 4
DCESP32 GPIO 2
SDI (MOSI)ESP32 GPIO 23
SCKESP32 GPIO 18
LEDESP32 3.3V
SDO (MISO)ESP32 GPIO 19
Wire key: GPIOMOSIMISO
Touch pinConnects to
T_CLKESP32 GPIO 18 (shared)
T_CSESP32 GPIO 15
T_DI (MOSI)ESP32 GPIO 23 (shared)
T_DO (MISO)ESP32 GPIO 19 (shared)
T_IRQESP32 GPIO 25
T_CTRLleave unconnected on most modules

This module is 3.3 V only. There is no 5 V tolerance on the ILI9341 or the XPT2046. Do not wire it to 5 V “to make it brighter”, that is what the LED pin is for.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries:

  • Search “lvgl”, install LVGL by kisvp (version 9.x). The code below is written for 9.
  • Search “XPT2046”, install XPT2046_Touchscreen by Paul Stoffregen.

Then Arduino IDE >> Tools >> manage the board settings: Flash Size 4MB (or larger), Partition scheme “Huge APP”. LVGL 9 with one widget screen will not fit in the default 1.2 MB app partition. This is the other black-screen cause nobody warns you about (the upload fails or the board boot-loops).

The code

#include <lvgl.h>
#include <TFT_eSPI.h>       // install Bodmer's TFT_eSPI, configure User_Setup.h per its docs
#include <XPT2046_Touchscreen.h>
#include <SPI.h>

// --- Display buffer ---
#define SCREEN_W 320
#define SCREEN_H 240
static lv_color_t buf1[SCREEN_W * 100];   // 100 lines, ~64KB, fits in RAM

lv_display_t* disp;

// --- Pins (match the wiring table) ---
#define TFT_CS    5
#define TFT_RST   4
#define TFT_DC    2
#define TOUCH_CS  15
#define TOUCH_IRQ 25

XPT2046_Touchscreen touch(TOUCH_CS, TOUCH_IRQ);

// --- LVGL display flush: push pixels to the ILI9341 ---
void my_flush(lv_display_t* d, const lv_area_t* area, uint8_t* px_map) {
  uint32_t w = (area->x2 - area->x1 + 1);
  uint32_t h = (area->y2 - area->y1 + 1);
  TFT_eSPI& tft = *(TFT_eSPI*)lv_display_get_user_data(d);
  tft.startWrite();
  tft.setAddrWindow(area->x1, area->y1, w, h);
  tft.pushPixels(px_map, w * h);
  tft.endWrite();
  lv_display_flush_ready(d);
}

// --- LVGL touch read ---
void my_touch_read(lv_indev_drv_t* drv, lv_indev_data_t* data) {
  if (touch.tirqTouched() && touch.touched()) {
    TS_Point p = touch.getPoint();
    // XPT2046 raw range ~200..3900, map to screen
    data->point.x = map(p.x, 200, 3900, 0, SCREEN_W - 1);
    data->point.y = map(p.y, 200, 3900, 0, SCREEN_H - 1);
    data->state = LV_INDEV_STATE_PRESSED;
  } else {
    data->state = LV_INDEV_STATE_RELEASED;
  }
}

// --- UI objects (globals so the task can update them) ---
lv_obj_t* temp_label;
lv_obj_t* bar;

// --- App logic task: update widgets every second ---
void logic_task(void* arg) {
  for (;;) {
    float fake_temp = 20.0 + (random(0, 60) / 10.0);
    lv_label_set_text_fmt(temp_label, "%.1f C", fake_temp);
    lv_bar_set_value(bar, (int32_t)(fake_temp * 2), LV_ANIM_ON);
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(TFT_RST, OUTPUT);
  digitalWrite(TFT_RST, HIGH);
  delay(50);
  digitalWrite(TFT_RST, LOW);
  delay(50);
  digitalWrite(TFT_RST, HIGH);

  static TFT_eSPI tft;
  tft.begin();
  tft.setRotation(1);           // landscape
  tft.fillScreen(TFT_BLACK);

  touch.begin();
  touch.setRotation(1);         // match the display rotation

  lv_init();
  disp = lv_display_create(SCREEN_W, SCREEN_H);
  lv_display_set_user_data(disp, &tft);
  lv_display_set_flush_cb(disp, my_flush);
  lv_display_set_buffers(disp, buf1, NULL, sizeof(buf1), LV_DISPLAY_RENDER_MODE_PARTIAL);

  lv_indev_t* indev = lv_indev_create();
  lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
  lv_indev_set_read_cb(indev, my_touch_read);
  lv_indev_set_display(indev, disp);

  // A tiny UI: title, big temperature, bar
  lv_obj_t* scr = lv_screen_active();
  lv_obj_set_style_bg_color(scr, lv_color_hex(0x101418), 0);

  lv_obj_t* title = lv_label_create(scr);
  lv_label_set_text(title, "Workshop bench");
  lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8);

  temp_label = lv_label_create(scr);
  lv_obj_set_style_text_font(temp_label, &lv_font_montserrat_48, 0);
  lv_label_set_text(temp_label, "-- C");
  lv_obj_align(temp_label, LV_ALIGN_CENTER, 0, -10);

  bar = lv_bar_create(scr);
  lv_obj_set_size(bar, 260, 14);
  lv_obj_align(bar, LV_ALIGN_CENTER, 0, 40);
  lv_bar_set_range(bar, 0, 100);

  // Two-task structure. This is the part that makes LVGL stable:
  xTaskCreatePinnedToCore(lvgl_task, "lvgl", 8192, NULL, 2, NULL, 1);  // core 1
  xTaskCreatePinnedToCore(logic_task, "logic", 4096, NULL, 1, NULL, 0); // core 0
}

// --- LVGL task: never touch UI objects from anywhere else ---
void lvgl_task(void* arg) {
  for (;;) {
    lv_timer_handler();       // runs render + input
    vTaskDelay(pdMS_TO_TICKS(5));
  }
}

void loop() {
  // empty on purpose: everything lives in the two tasks
}

// UI updates must go through the LVGL task. If logic_task ever needs
// to change a widget from another core, use lv_async_call() instead
// of touching the object directly (it schedules the change inside the
// LVGL task, which is the only safe place).

The code above uses TFT_eSPI for the ILI9341 transport. TFT_eSPI is configured by editing User_Setup.h in the library (its comment block lists every ILI9341 board preset), not from your sketch. That is the one library in this stack you configure by editing a file, and it is why the pins above are repeated there.

What you learned

  • LVGL is a retained-mode widget library, not a pixel-pushing one. You build the UI once, LVGL redraws only what changed.
  • LVGL needs lv_timer_handler() every few ms. A delay() in loop() freezes the UI, so the working structure is two FreeRTOS tasks on two cores.
  • Touch input is an input device you register, not a loop you poll yourself. LVGL calls your read function.
  • Only the LVGL task may touch UI objects. From another task, use lv_async_call().

When something breaks

  • Black screen, code compiles fine. Two usual causes: the app partition is too small (Arduino IDE >> Tools >> Partition scheme

    Huge APP), or lv_timer_handler() is starved by a delay() in loop(). Fix the partition first, then check the task setup.

  • White screen with noise. SPI pins in User_Setup.h do not match the wiring table. The module silkscreen labels vary between sellers (e.g. some print SDA for MOSI), so trust your wiring, not the label.
  • Display works, touch is dead. TOUCH_CS is floating. The touch controller shares the SPI bus, and without its chip select it never answers. GPIO 15 in the table above is required, not optional.
  • Touch works but is mirrored or offset. The XPT2046 raw values need calibration. Change the map() endpoints in my_touch_read until the stylus lands where it points, and swap x/y if the rotation is wrong (set both display and touch rotations to the same value first).
  • Random crashes after minutes. You called a UI function from logic_task. Move it behind lv_async_call().

What to build next

Put a real sensor behind the fake temperature: the BME280 tutorial’s reading feeds lv_label_set_text_fmt() exactly as written above, and the MQTT tutorial turns the same UI into a remote control (subscribe, then update the label from the callback through lv_async_call()).

For a wall-mounted build, this display plus the SSD1306 tutorial’s lessons about brightness is how you get to a thermostat faceplate. And when the UI needs live data from a browser at the same time, the WebSocket server tutorial feeds both from one chip.