Your cart is currently empty!

ESP32 + SPI Capacitive Touchscreen Tutorial
•
This site contains affiliate links (including Amazon and eBay). If you buy through these links, I may earn a commission at no extra cost to you. As an Amazon Associate I earn from qualifying purchases.
It wasn’t long ago that capacitive touchscreens were completely out of reach for hobbyists. Cheap resistive touchscreens have been available for a while, but can be a bit finicky to use. Finally, inexpensive capacitive touchscreens have arrived, and they are awesome.
Let’s take a look at the 2.8-inch 240×320 IPS SPI Capacitive Touchscreen(Amazon) that uses the ILI9341 LCD driver and FT6336 touch driver. We will also try out the 3.5-inch variant(Amazon) that uses the ST7796 LCD driver and the same FT6336 touch driver. We will drive the screen using a third-party variant(Amazon) of the official Espressif ESP32-DevKitC-32E(Amazon).
Here is the whole family of these screen modules and links to their specs/documentation:
| Size | Resolution | Screen Type | LCD Driver | Touch Driver | Amazon Link | Docs |
|---|---|---|---|---|---|---|
| 2.8 inch | 240×320 | IPS | ILI9341V | FT6336G | Buy(Amazon) | LCD Wiki |
| 3.2 inch | 240×320 | IPS | ILI9341V | FT6336U | Buy(Amazon) | LCD Wiki |
| 3.5 inch | 320×480 | IPS | ST7796U | FT6336U | Buy(Amazon) | LCD Wiki |
| 4 inch | 320×480 | TN | ST7796S | FT6336U | Buy(Amazon) | LCD Wiki |
Video
Check out my to-the-point, no-nonsense video tutorial for using these screen modules. I’m publishing this post to share the code shown in the video.
Wiring
All of the screen modules listed above conveniently have the same pinout and are interchangeable, requiring only minor code changes.
These screen modules can be used with 3.3V and 5V microcontrollers such as the ESP32 (3.3V) and Arduino (typically 5V). Regardless of your microcontroller, it’s recommended to power the VCC pin with 5V. Supplying 3.3V to VCC will work, but the screen won’t be as bright.
Screen Wiring
Connect VIN (5V) on the ESP32 to the VCC pin on the screen to achieve full screen brightness. The VIN (5V) pin on the ESP32 carries 5V from the USB connection. The other screen pins connected to ESP32 GPIO will operate at 3.3V logic levels.

Touch Wiring

LovyanGFX (LGFX) Configuration
We’re using the LovyanGFX (LGFX) library to interface with the screen module. To configure it for your display, create an lgfx_config.h file and include it after LovyanGFX.h in your main sketch.
This file is configured for the pinout shown above, which is compatible with all of the listed screens. The config below targets the 2.8″ screen used for most of the tutorial, and it also includes commented-out lines for the 3.5″ screen we briefly try in the video.
/*******************************************************************************
* MostlyBuilds — ESP32 SPI Capacitive Touchscreen Tutorial
* Author: MostlyBuilds
* Guide: https://mostlybuilds.com/esp32-spi-capacitive-touch-screen-tutorial
*
* LovyangGFX (LGFX) Configuration
*******************************************************************************/
#pragma once
#include <LovyanGFX.hpp>
// I2C Touch Pins
static constexpr int TOUCH_INT = 34; // CTP_INT
static constexpr int I2C_SDA = 32; // CTP_SDA
static constexpr int TOUCH_RST = 13; // CTP_RST
static constexpr int I2C_SCL = 33; // CTP_SCL
// Screen SPI pins
static constexpr int PIN_MISO = 19; // SDO(MISO)
static constexpr int PIN_BL = 14; // LED
static constexpr int PIN_SCLK = 18; // SCK
static constexpr int PIN_MOSI = 23; // SDI(MOSI)
static constexpr int PIN_DC = 21; // LCD_RS
static constexpr int PIN_RST = 4; // LCD_RST
static constexpr int PIN_LCD_CS = 27; // LCD_CS (SS)
// Change the screen dimensions to match your screen.
// Set this to the native resolution and use "offset_rotation"
// configurations to change the screen orientation
// rather than swapping these values to handle portrait
// versus landscape.
// Dimensions for the 2.8" screen used in this tutorial
static constexpr uint16_t SCREEN_WIDTH = 240;
static constexpr uint16_t SCREEN_HEIGHT = 320;
// Dimensions for the 3.5" screen used in this tutorial
//static constexpr uint16_t SCREEN_WIDTH = 320;
//static constexpr uint16_t SCREEN_HEIGHT = 480;
// Change this for your specific touchscreen controller
// 0x38 is a very common value, used by FocalTech FT5x06
// family (FT5206/FT5306/FT5406/FT6236/FT6336, etc.)
static constexpr int TOUCH_I2C_ADDRESS = 0x38;
struct LGFX : public lgfx::LGFX_Device {
// Panel instance for the 2.8" screen in this tutorial
lgfx::Panel_ILI9341 _panel_instance;
// Panel instnace for the 3.5" screen in this tutorial
//lgfx::Panel_ST7796 _panel_instance;
// Change to the correct touch instance for your touch controller
// This works for both screens used in this tutorial
lgfx::Touch_FT5x06 _touch_instance;
lgfx::Bus_SPI _bus_instance;
lgfx::Light_PWM _light_instance;
LGFX(void) {
// Panel configuration
{
auto cfg = _panel_instance.config();
cfg.pin_cs = PIN_LCD_CS;
cfg.pin_rst = PIN_RST;
cfg.pin_busy = -1;
cfg.memory_width = SCREEN_WIDTH;
cfg.memory_height = SCREEN_HEIGHT;
cfg.panel_width = SCREEN_WIDTH;
cfg.panel_height = SCREEN_HEIGHT;
cfg.offset_x = 0;
cfg.offset_y = 0;
cfg.offset_rotation = 3; // 2.8" screen used in this tutorial
//cfg.offset_rotation = 1; // 3.5" screen used in this tutorial
// Rotation for landscape with header pins
// on the right side of the screen
cfg.invert = true; // Fixes the incorrect color issue
cfg.dummy_read_pixel = 8;
cfg.dummy_read_bits = 1;
cfg.readable = true;
cfg.rgb_order = false;
cfg.bus_shared = true;
cfg.dlen_16bit = false;
_panel_instance.config(cfg);
}
// SPI bus configuration
{
auto cfg = _bus_instance.config();
cfg.pin_sclk = PIN_SCLK;
cfg.pin_mosi = PIN_MOSI;
cfg.pin_miso = PIN_MISO;
cfg.pin_dc = PIN_DC;
cfg.spi_host = VSPI_HOST;
cfg.freq_write = 40000000;
cfg.freq_read = 10000000;
cfg.dma_channel = 1;
_bus_instance.config(cfg);
_panel_instance.setBus(&_bus_instance);
}
// Backlight configuration
{
auto cfg = _light_instance.config();
cfg.pin_bl = PIN_BL; // LED backlight pin
cfg.invert = false;
cfg.freq = 1000; // PWM frequency (1kHz is standard for LED backlight)
cfg.pwm_channel = 0; // Use channel 0 instead of 7 to avoid conflicts
_light_instance.config(cfg);
_panel_instance.setLight(&_light_instance);
}
// Touch config
{
auto cfg = _touch_instance.config();
cfg.x_min = 0;
cfg.x_max = SCREEN_WIDTH - 1;
cfg.y_min = 0;
cfg.y_max = SCREEN_HEIGHT - 1;
cfg.offset_rotation = 2; // 2.8" screen used in this tutorial
//cfg.offset_rotation = 0; // 3.5" screen used in this tutorial
// Rotation for landscape with header pins
// on the right side of the screen
cfg.pin_int = TOUCH_INT;
cfg.pin_rst = TOUCH_RST;
cfg.pin_sda = I2C_SDA;
cfg.pin_scl = I2C_SCL;
cfg.i2c_port = 0;
cfg.i2c_addr = TOUCH_I2C_ADDRESS;
cfg.freq = 400000;
_touch_instance.config(cfg);
_panel_instance.setTouch(&_touch_instance);
}
setPanel(&_panel_instance);
}
};
Hello World

Here we have a simple test sketch to demonstrate that the screen works. It configures the screen, draws some static “hello world” text, and renders text that updates dynamically in the loop() function.
The included photo has the colors inverted because it was taken before I set the invert flag in lgfx_config.h.
Not too shabby, right?
/*******************************************************************************
* MostlyBuilds — ESP32 SPI Capacitive Touchscreen Tutorial
* Author: MostlyBuilds
* Guide: https://mostlybuilds.com/esp32-spi-capacitive-touch-screen-tutorial
*
* Hello world - Static & dynamic text
*******************************************************************************/
#include <LovyanGFX.h>
#include "lgfx_config.h" // Configuration for our screen
// Create an instance of LovyanGFX (LGFX)
LGFX tft;
void setup() {
Serial.begin(115200);
delay(50);
// Init the screen with the lgfx_config.h
tft.init();
// Set the screen brightness to the max
tft.setBrightness(255);
// Clear screen
tft.fillScreen(TFT_BLACK);
// Basic text setup
tft.setTextColor(TFT_WHITE, TFT_BLACK); // (foreground, background)
tft.setTextSize(2);
tft.setCursor(20, 20);
// Draw some text
tft.println("Hello, world!");
tft.println("LovyanGFX on ESP32");
}
void loop() {
static uint32_t last = 0;
static int x = 20;
if (millis() - last > 250) {
last = millis();
tft.fillRect(20, 80, 240, 24, TFT_BLACK);
tft.setCursor(20, 80);
tft.print("Tick ");
tft.print(millis() / 1000);
tft.print("s");
}
}Capacitive Touch

Taking things a step further, let’s update the sketch from above to include a button that we can detect taps on. This code includes some basic debug logging that prints to Serial, as well as rendering a red debug dot on the screen wherever you tap. This is useful for verifying that the configured coordinate system matches the touch panel hardware.
What we’ve created here is a very basic example of building a simple UI and handling tap input. For a lot of projects, this is often all you need. For more complex UIs, the LVGL graphics library is a significant upgrade and works well alongside LGFX. We won’t be covering LVGL in this tutorial, but maybe in the future! Subscribe to @MostlyBuilds on YouTube if this stuff interests you.
/*******************************************************************************
* MostlyBuilds — ESP32 SPI Capacitive Touchscreen Tutorial
* Author: MostlyBuilds
* Guide: https://mostlybuilds.com/esp32-spi-capacitive-touch-screen-tutorial
*
* Hello world with a button to demonstrate tap handling
*******************************************************************************/
#include <LovyanGFX.h>
#include "lgfx_config.h" // Configuration for our screen
// Create an instance of LovyanGFX (LGFX)
LGFX tft;
// UI state
static uint32_t pressCount = 0;
static bool wasTouching = false;
// Button rectangle
static int btnX = 40;
static int btnY = 140;
static int btnW = 240;
static int btnH = 80;
// Function for determining if a point exists within a rectangle
static bool pointInRect(int x, int y, int rx, int ry, int rw, int rh) {
return (x >= rx) && (x < rx + rw) && (y >= ry) && (y < ry + rh);
}
// Render the count from pressing the button
static void drawCount() {
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
// Clear just the count line area
tft.fillRect(20, 70, 300, 24, TFT_BLACK);
tft.setCursor(20, 70);
tft.print("Count: ");
tft.print(pressCount);
}
// Render the button that has different colors
// depending on the pressed state
static void drawButton(bool pressed) {
const uint16_t bg = TFT_BLACK;
const uint16_t border = TFT_WHITE;
const uint16_t fillUp = TFT_DARKGREY;
const uint16_t fillDn = 0x39E7; // darker gray
const uint16_t textCol = TFT_BLACK;
// Button fill + border
tft.fillRoundRect(btnX, btnY, btnW, btnH, 10, pressed ? fillDn : fillUp);
tft.drawRoundRect(btnX, btnY, btnW, btnH, 10, border);
// Label
const char* label = "PRESS ME";
tft.setTextSize(2);
tft.setTextColor(textCol, pressed ? fillDn : fillUp);
// Center text using LovyanGFX helpers
int tw = tft.textWidth(label);
int th = tft.fontHeight();
int tx = btnX + (btnW - tw) / 2;
int ty = btnY + (btnH - th) / 2;
tft.setCursor(tx, ty);
tft.print(label);
// Restore default text colors
tft.setTextColor(TFT_WHITE, bg);
}
void setup() {
Serial.begin(115200);
delay(50);
// Init the screen with the lgfx_config.h
tft.init();
// Set the screen brightness to the max
tft.setBrightness(255);
// Clear screen
tft.fillScreen(TFT_BLACK);
// Basic text setup
tft.setTextColor(TFT_WHITE, TFT_BLACK); // (foreground, background)
tft.setTextSize(2);
tft.setCursor(20, 20);
tft.println("Hello, world!");
tft.setCursor(20, 45);
tft.println("Tap the button:");
drawCount();
drawButton(false);
}
void loop() {
static uint32_t last = 0;
if (millis() - last > 250) {
last = millis();
tft.fillRect(20, 100, 300, 24, TFT_BLACK);
tft.setCursor(20, 100);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(2);
tft.print("Tick ");
tft.print(millis() / 1000);
tft.print("s");
}
uint16_t rx, ry;
bool touching = tft.getTouch(&rx, &ry);
// Debug dot + log on new press (helps confirm mapping)
if (touching && !wasTouching) {
// Draw a red dot where the touch was detected
tft.fillCircle(rx, ry, 4, TFT_RED);
// Log the touch coordinate data + screen dimensions
Serial.printf("raw(%u,%u) wh(%d,%d)\n", rx, ry, tft.width(), tft.height());
}
// Detect if the user is pressing within the button bounds
if (touching && !wasTouching) {
if (pointInRect(rx, ry, btnX, btnY, btnW, btnH)) {
pressCount++;
// Update the rendered count value
drawCount();
// Redraw the button in the pressed state
drawButton(true);
}
}
// On release, redraw the button in the normal non-pressed state
if (!touching && wasTouching) {
drawButton(false);
}
wasTouching = touching;
}
A Rotating Cube

Trying something a bit more complicated and flashy, let’s run some code generated with ChatGPT that renders a full-screen rotating cube. This will likely only run on the smaller 240×320 resolution screens because of the memory required to render it.
Note: If you’re using an ESP32 module with PSRAM, you can take advantage of the extra memory to render a full-screen cube on a larger 320×480 screen. The code below would likely need slight tweaks to make use of that additional memory.
/*******************************************************************************
* MostlyBuilds — ESP32 SPI Capacitive Touchscreen Tutorial
* Author: MostlyBuilds + ChatGPT
* Guide: https://mostlybuilds.com/esp32-spi-capacitive-touch-screen-tutorial
*
* Rotating cube sketch generated with ChatGPT
*******************************************************************************/
#include <LovyanGFX.h>
#include "lgfx_config.h"
#include <math.h>
LGFX tft;
lgfx::LGFX_Sprite canvas(&tft);
// -------- 3D math helpers --------
struct Vec3 { float x, y, z; };
struct Vec2 { int x, y; };
static inline Vec3 rotX(const Vec3& v, float a) {
float s = sinf(a), c = cosf(a);
return { v.x, v.y * c - v.z * s, v.y * s + v.z * c };
}
static inline Vec3 rotY(const Vec3& v, float a) {
float s = sinf(a), c = cosf(a);
return { v.x * c + v.z * s, v.y, -v.x * s + v.z * c };
}
static inline Vec3 rotZ(const Vec3& v, float a) {
float s = sinf(a), c = cosf(a);
return { v.x * c - v.y * s, v.x * s + v.y * c, v.z };
}
static inline Vec2 project(const Vec3& v, int cx, int cy, float fov, float zOffset) {
float z = v.z + zOffset;
float inv = (z != 0.0f) ? (fov / z) : 1.0f;
int sx = (int)(v.x * inv) + cx;
int sy = (int)(v.y * inv) + cy;
return { sx, sy };
}
// -------- Drawing helpers (thick lines) --------
static void drawThickLine(lgfx::LGFX_Sprite& g, int x0, int y0, int x1, int y1, int thickness, uint16_t color) {
if (thickness <= 1) {
g.drawLine(x0, y0, x1, y1, color);
return;
}
int dx = x1 - x0;
int dy = y1 - y0;
int adx = dx >= 0 ? dx : -dx;
int ady = dy >= 0 ? dy : -dy;
int half = thickness / 2;
if (adx >= ady) {
// mostly horizontal: offset in Y
for (int o = -half; o <= half; ++o) {
g.drawLine(x0, y0 + o, x1, y1 + o, color);
}
} else {
// mostly vertical: offset in X
for (int o = -half; o <= half; ++o) {
g.drawLine(x0 + o, y0, x1 + o, y1, color);
}
}
}
static void drawWireCubeThickColored(lgfx::LGFX_Sprite& g, const Vec2 pts[8], int thickness,
uint16_t frontCol, uint16_t backCol, uint16_t linkCol) {
static const uint8_t edges[12][2] = {
{0,1},{1,2},{2,3},{3,0}, // front
{4,5},{5,6},{6,7},{7,4}, // back
{0,4},{1,5},{2,6},{3,7} // links
};
for (int i = 0; i < 12; i++) {
uint8_t a = edges[i][0], b = edges[i][1];
uint16_t col = (i < 4) ? frontCol : (i < 8) ? backCol : linkCol;
drawThickLine(g, pts[a].x, pts[a].y, pts[b].x, pts[b].y, thickness, col);
}
}
void setup() {
Serial.begin(115200);
delay(50);
tft.init();
tft.setBrightness(255);
// Title/header drawn directly (static)
tft.fillScreen(0x0000);
tft.setTextColor(0xFFFF, 0x0000);
tft.setTextSize(2);
tft.setCursor(10, 10);
tft.println("MostlyBuilds - SUBSCRIBE!");
// Create sprite for drawing area (below header)
const int w = tft.width();
const int h = tft.height() - 40;
// Try 16-bit first (best quality). If it fails, fallback to 8-bit.
canvas.setColorDepth(16);
if (!canvas.createSprite(w, h)) {
Serial.println("16-bit sprite alloc failed; falling back to 8-bit");
canvas.setColorDepth(8);
if (!canvas.createSprite(w, h)) {
Serial.println("8-bit sprite alloc failed; reduce sprite size!");
// As a last resort, you could create a smaller sprite here.
}
}
canvas.fillScreen(0x0000);
canvas.pushSprite(0, 40);
}
void loop() {
static uint32_t last = 0;
const uint32_t now = millis();
if (now - last < 16) return; // ~60 FPS cap
last = now;
// Clear sprite (off-screen)
canvas.fillScreen(0x0000);
// Cube points (centered at origin)
const float s = 60.0f;
const Vec3 cube[8] = {
{-s, -s, -s}, { s, -s, -s}, { s, s, -s}, {-s, s, -s},
{-s, -s, s}, { s, -s, s}, { s, s, s}, {-s, s, s}
};
// Animate rotation
float t = now * 0.001f;
float ax = t * 0.9f;
float ay = t * 1.2f;
float az = t * 0.7f;
// Projection params (center inside sprite)
int cx = canvas.width() / 2;
int cy = canvas.height() / 2;
float fov = 200.0f;
float zOffset = 220.0f;
Vec2 pts[8];
for (int i = 0; i < 8; i++) {
Vec3 v = cube[i];
v = rotX(v, ax);
v = rotY(v, ay);
v = rotZ(v, az);
pts[i] = project(v, cx, cy, fov, zOffset);
}
// Colors (RGB565)
uint16_t frontCol = tft.color565( 40, 200, 255); // cyan
uint16_t backCol = tft.color565(255, 80, 140); // pink
uint16_t linkCol = tft.color565(255, 200, 40); // yellow
int thickness = 4;
drawWireCubeThickColored(canvas, pts, thickness, frontCol, backCol, linkCol);
// Optional: center crosshair in sprite
canvas.drawFastHLine(cx - 4, cy, 9, 0x7BEF);
canvas.drawFastVLine(cx, cy - 4, 9, 0x7BEF);
// Push completed frame to screen all at once -> no flashing
canvas.pushSprite(0, 40);
}Swapping For a 3.5″ 320×480 Screen
Out of curiosity, I swapped out the 2.8″ 240×320 screen that I used for most of this tutorial with its 3.5″ 320×480 big brother. This is super easy to do since the pinout is exactly the same. I just needed to change a few values in lgfx_config.h:
static constexpr uint16_t SCREEN_WIDTH = 320;
static constexpr uint16_t SCREEN_HEIGHT = 480;
...
lgfx::Panel_ST7796 _panel_instance;
...
// Panel configuration
cfg.offset_rotation = 1;
...
// Touch config
cfg.offset_rotation = 0;Note: The lgfx_config.h at the top of this tutorial defaults to the values for the 2.8″ screen, but it also contains commented-out values for the 3.5″ screen, which I’ve listed separately here.

The “Hello World” sketches we tried earlier worked out of the box on the bigger screen—except for the rotating cube example. That’s because rendering a full-screen rotating cube on the larger display requires more memory. To get around this, I went back to ChatGPT and asked it to scale down the cube so we could reduce the memory footprint, and boom, it worked.
As mentioned earlier, if you’re using an ESP32 module with PSRAM, you can take advantage of the extra memory to render a full-screen cube on a 320×480 screen.
Here is the code for the scaled-down rotating cube (thanks, ChatGPT!):
/*******************************************************************************
* MostlyBuilds — ESP32 SPI Capacitive Touchscreen Tutorial
* Author: MostlyBuilds + ChatGPT
* Guide: https://mostlybuilds.com/esp32-spi-capacitive-touch-screen-tutorial
*
* Scaled down rotating cube sketch for rendering on a larger screen
* when constrained by the memory of an ESP32 without PSRAM.
* Generated with ChatGPT.
*******************************************************************************/
#include <LovyanGFX.h>
#include "lgfx_config.h"
#include <math.h>
LGFX tft;
lgfx::LGFX_Sprite canvas(&tft);
// -------- 3D math helpers --------
struct Vec3 { float x, y, z; };
struct Vec2 { int x, y; };
static inline Vec3 rotX(const Vec3& v, float a) {
float s = sinf(a), c = cosf(a);
return { v.x, v.y * c - v.z * s, v.y * s + v.z * c };
}
static inline Vec3 rotY(const Vec3& v, float a) {
float s = sinf(a), c = cosf(a);
return { v.x * c + v.z * s, v.y, -v.x * s + v.z * c };
}
static inline Vec3 rotZ(const Vec3& v, float a) {
float s = sinf(a), c = cosf(a);
return { v.x * c - v.y * s, v.x * s + v.y * c, v.z };
}
static inline Vec2 project(const Vec3& v, int cx, int cy, float fov, float zOffset) {
float z = v.z + zOffset;
float inv = (z != 0.0f) ? (fov / z) : 1.0f;
int sx = (int)(v.x * inv) + cx;
int sy = (int)(v.y * inv) + cy;
return { sx, sy };
}
// -------- Drawing helpers (thick lines) --------
static void drawThickLine(lgfx::LGFX_Sprite& g, int x0, int y0, int x1, int y1, int thickness, uint16_t color) {
if (thickness <= 1) {
g.drawLine(x0, y0, x1, y1, color);
return;
}
int dx = x1 - x0;
int dy = y1 - y0;
int adx = dx >= 0 ? dx : -dx;
int ady = dy >= 0 ? dy : -dy;
int half = thickness / 2;
if (adx >= ady) {
// mostly horizontal: offset in Y
for (int o = -half; o <= half; ++o) {
g.drawLine(x0, y0 + o, x1, y1 + o, color);
}
} else {
// mostly vertical: offset in X
for (int o = -half; o <= half; ++o) {
g.drawLine(x0 + o, y0, x1 + o, y1, color);
}
}
}
static void drawWireCubeThickColored(lgfx::LGFX_Sprite& g, const Vec2 pts[8], int thickness,
uint16_t frontCol, uint16_t backCol, uint16_t linkCol) {
static const uint8_t edges[12][2] = {
{0,1},{1,2},{2,3},{3,0}, // front
{4,5},{5,6},{6,7},{7,4}, // back
{0,4},{1,5},{2,6},{3,7} // links
};
for (int i = 0; i < 12; i++) {
uint8_t a = edges[i][0], b = edges[i][1];
uint16_t col = (i < 4) ? frontCol : (i < 8) ? backCol : linkCol;
drawThickLine(g, pts[a].x, pts[a].y, pts[b].x, pts[b].y, thickness, col);
}
}
// -------- Sprite allocation with fallbacks --------
static bool allocCanvasSprite(int w, int h) {
canvas.deleteSprite();
// If your LovyanGFX build supports PSRAM sprites, you can try this:
// (Uncomment if available in your version)
// canvas.setPsram(true);
// Try 16-bit full size
canvas.setColorDepth(16);
if (canvas.createSprite(w, h)) {
Serial.printf("Sprite OK: %dx%d @16-bit (~%u bytes)\n", w, h, (unsigned)(w * h * 2));
return true;
}
// Try 8-bit full size
Serial.println("16-bit sprite alloc failed; trying 8-bit...");
canvas.setColorDepth(8);
if (canvas.createSprite(w, h)) {
Serial.printf("Sprite OK: %dx%d @8-bit (~%u bytes)\n", w, h, (unsigned)(w * h));
return true;
}
// Reduce height until it fits
Serial.println("8-bit sprite alloc failed; trying smaller heights...");
for (int hh = h; hh >= 120; hh -= 40) {
canvas.deleteSprite();
if (canvas.createSprite(w, hh)) {
Serial.printf("Sprite OK (reduced): %dx%d @8-bit (~%u bytes)\n", w, hh, (unsigned)(w * hh));
return true;
}
}
Serial.println("Sprite alloc failed completely.");
return false;
}
void setup() {
Serial.begin(115200);
delay(50);
tft.init();
tft.setBrightness(255);
// Header drawn directly on TFT
tft.fillScreen(0x0000);
tft.setTextColor(0xFFFF, 0x0000);
tft.setTextSize(2);
tft.setCursor(10, 10);
tft.println("MostlyBuilds - SUBSCRIBE!");
// Sprite for drawing area (below header)
const int w = tft.width();
const int h = tft.height() - 40;
if (!allocCanvasSprite(w, h)) {
// Hard stop so you don't "run" with a 0-sized sprite and see nothing.
while (true) {
Serial.println("FATAL: Could not allocate sprite. Add PSRAM, reduce size, or draw directly to TFT.");
delay(1000);
}
}
canvas.fillScreen(0x0000);
canvas.pushSprite(0, 40);
}
void loop() {
static uint32_t last = 0;
const uint32_t now = millis();
if (now - last < 16) return; // ~60 FPS cap
last = now;
// If sprite somehow got deleted or failed, bail safely
if (canvas.width() <= 0 || canvas.height() <= 0) return;
// Clear sprite (off-screen)
canvas.fillScreen(0x0000);
// Scale cube + projection to the actual sprite size (works for 240x320 and 320x480)
float minDim = (canvas.width() < canvas.height()) ? canvas.width() : canvas.height();
float s = minDim * 0.22f; // cube size scales with sprite
float fov = minDim * 0.90f; // projection scale
float zOffset = minDim * 1.10f; // camera distance
// Cube points (centered at origin)
const Vec3 cube[8] = {
{-s, -s, -s}, { s, -s, -s}, { s, s, -s}, {-s, s, -s},
{-s, -s, s}, { s, -s, s}, { s, s, s}, {-s, s, s}
};
// Animate rotation
float t = now * 0.001f;
float ax = t * 0.9f;
float ay = t * 1.2f;
float az = t * 0.7f;
// Projection params (center inside sprite)
int cx = canvas.width() / 2;
int cy = canvas.height() / 2;
Vec2 pts[8];
for (int i = 0; i < 8; i++) {
Vec3 v = cube[i];
v = rotX(v, ax);
v = rotY(v, ay);
v = rotZ(v, az);
pts[i] = project(v, cx, cy, fov, zOffset);
}
// Colors (RGB565)
uint16_t frontCol = tft.color565( 40, 200, 255); // cyan
uint16_t backCol = tft.color565(255, 80, 140); // pink
uint16_t linkCol = tft.color565(255, 200, 40); // yellow
int thickness = (int)(minDim * 0.012f);
if (thickness < 2) thickness = 2;
if (thickness > 6) thickness = 6;
drawWireCubeThickColored(canvas, pts, thickness, frontCol, backCol, linkCol);
// Center crosshair
canvas.drawFastHLine(cx - 4, cy, 9, 0x7BEF);
canvas.drawFastVLine(cx, cy - 4, 9, 0x7BEF);
// Push completed frame to screen all at once -> no flashing
canvas.pushSprite(0, 40);
}
Loading Data From a SDCard

All of these screen modules conveniently come with an SD card slot and an SD_CS pin that lets you read an SD card over the same SPI bus that drives the screen. Let’s toss an image on the card and read it back so we can display it on the screen.
Image format
Not all image files can be rendered by LGFX. I highly recommend using the JPEG format and running it through ImageMagick, which is a free, open-source tool for manipulating image files. You can download it from the official site (imagemagick.org), or if you’re on a Mac, you can install it via Homebrew:
brew install imagemagick
magick input_image.jpg -strip -interlace none -colorspace sRGB -sampling-factor 4:2:0 -quality 85 output_image.jpg
Rendering An Image

The CS (Chip Select) pins for both the screen and SD card let you choose which device is “listening” on the shared SPI bus at any given moment. LovyanGFX (LGFX) automatically asserts and releases the LCD’s CS line whenever you call its drawing APIs, so up until now you haven’t had to think about it. But once you add an SD card to the same SPI wires (SCLK/MOSI/MISO), you need to be a bit more intentional: before initializing either device, this sketch sets both CS pins HIGH (deselected) so the LCD and SD card don’t accidentally talk at the same time. Then we initialize the display, bring up SPI with the shared pin definitions, and call SD.begin() with the SD’s CS pin. With the SD mounted, tft.drawJpgFile(SD, IMAGE_PATH, 0, 0) reads the JPEG straight from the card and decodes it onto the screen.
/*******************************************************************************
* MostlyBuilds — ESP32 SPI Capacitive Touchscreen Tutorial
* Author: MostlyBuilds + ChatGPT
* Guide: https://mostlybuilds.com/esp32-spi-capacitive-touch-screen-tutorial
*
* Loading an image from a SDCard and displaying it on the screen
*******************************************************************************/
#include <SPI.h>
#include <SD.h> // Include SD.h BEFORE LovyanGFX.h to avoid conflicts
#include <LovyanGFX.h>
#include "lgfx_config.h"
static constexpr int PIN_SD_CS = 25;
static constexpr const char* IMAGE_PATH = "/my_image_ps_baseline.jpg";
LGFX tft;
void setup() {
Serial.begin(115200);
delay(100);
// Make sure both screen and SD are deselected by default.
pinMode(PIN_LCD_CS, OUTPUT);
digitalWrite(PIN_LCD_CS, HIGH);
pinMode(PIN_SD_CS, OUTPUT);
digitalWrite(PIN_SD_CS, HIGH);
tft.init();
tft.setBrightness(255);
tft.fillScreen(TFT_BLACK);
// SD shares the same SPI pins as the LCD bus.
SPI.begin(PIN_SCLK, PIN_MISO, PIN_MOSI);
// If your wiring is long / flaky, try 8 MHz.
if (!SD.begin(PIN_SD_CS, SPI, 8000000)) {
Serial.println("SD.begin failed");
while (true) delay(1000);
}
if (!tft.drawJpgFile(SD, IMAGE_PATH, 0, 0)) {
Serial.println("drawJpgFile failed (missing file or decode error)");
while (true) delay(1000);
}
}
void loop() {}
If you’re having trouble loading a file from the SD Card, here are two drop in functions that you can add to your sketch and call after SD.begin().
// List files/folders in the SD card root ("/").
// Useful to confirm the exact filenames the board sees (case-sensitive).
// Call this AFTER SD.begin(...) and Serial.begin(...).
static void listSDRoot() {
File root = SD.open("/");
if (!root) {
Serial.println("ERROR: SD.open(/) failed");
return;
}
Serial.println("SD root listing:");
while (true) {
File f = root.openNextFile();
if (!f) break;
Serial.print(" ");
Serial.print(f.name());
if (f.isDirectory()) {
Serial.println("/");
} else {
Serial.print(" (");
Serial.print((uint32_t)f.size());
Serial.println(" bytes)");
}
f.close();
}
root.close();
}
// Print info about a file on the SD card (exists + size).
// Call this AFTER SD.begin(...) and Serial.begin(...).
//
// Example:
// printFileInfo("/my_image.jpg");
static void printFileInfo(const char* path) {
if (!path || path[0] == '\0') {
Serial.println("ERROR: printFileInfo() got an empty path");
return;
}
// Common student mistake: missing leading slash
if (path[0] != '/') {
Serial.print("WARN: Path does not start with '/': ");
Serial.println(path);
Serial.println(" Try: \"/file.jpg\"");
}
File f = SD.open(path, FILE_READ);
if (!f) {
Serial.print("ERROR: File not found or can't be opened: ");
Serial.println(path);
return;
}
Serial.print("Found file: ");
Serial.print(path);
Serial.print(" (");
Serial.print((uint32_t)f.size());
Serial.println(" bytes)");
f.close();
}
Final Thoughts
These screens are dangerously fun, and surprisingly high quality for their price point. A touch UI can take your electronics projects to the next level because it lets you create a customizable, dynamic interface instead of relying on a fixed set of hardware buttons. As your project evolves, your UI can evolve with it: new screens, new controls, and better usability without redesigning the enclosure or the front panel.
Follow me on YouTube @MostlyBuilds and comment on the video if you’d like to see more stuff like this.