ILI9341 Arduino Touchscreen Tutorial: XPT2046 Wiring, Calibration and Touch UI
The ILI9341 is a 2.4-inch or 2.8-inch color TFT display with a resolution of 240 by 320 pixels and 65,536 colors, and most modules include an XPT2046 resistive touch controller. Together they turn an Arduino into a small touchscreen device that can show menus, buttons and status screens and react to finger input. Both chips use the SPI bus, so the display and the touch panel share three signal wires and need only one extra chip-select pin each.
This tutorial shows how to wire the ILI9341 and the XPT2046 to an Arduino Uno, including the 5 V to 3.3 V level shifting that the module needs. You will install the Adafruit and XPT2046 libraries, draw text and shapes, show a bitmap icon, read raw touch coordinates and calibrate the panel so that touches match screen pixels. The last sections build touch buttons with visual feedback, an LED control panel and a three-page menu with Home, Settings and Info screens.
This display belongs to the Arduino display tutorials. If you want a smaller color screen without touch, read the ST7735 TFT tutorial, and for monochrome screens, see the SH1106 OLED tutorial, the SSD1306 OLED tutorial and the ST7920 graphic LCD tutorial. The LCD1602 I2C tutorial covers a simple character display, and the Arduino MP3 player tutorial uses the same ILI9341 screen in a larger project.
ILI9341 TFT Display Overview
The ILI9341 is the driver chip of a color TFT display that is available in sizes from 2.4 to 2.8 inches, and the common modules have a resolution of 240 by 320 pixels. Each pixel uses 16 bits of color, which gives 65,536 colors and is enough for text, charts, icons and small images. The driver communicates over SPI, so the Arduino sends commands and pixel data over a few wires, and the library takes care of the details.

ILI9341 Specifications
| Specification | Detail |
|---|---|
| Display size | 2.4 inch or 2.8 inch, depending on the module |
| Resolution | 240 × 320 pixels |
| Driver IC | ILI9341 |
| Touch controller | XPT2046, resistive |
| Interface | SPI |
| Color depth | 16-bit, 65,536 colors |
| Logic level | 3.3 V |
| Extras | Many modules include a microSD card slot |
Why Use the ILI9341 with Arduino
- The module is inexpensive, easy to find and supported by well-known libraries from Adafruit.
- The resolution and the color depth suit menus, dashboards, weather stations and simple games.
- The resistive touch panel lets you add buttons and menus without extra hardware, and it works with a finger or a stylus.
- SPI wiring works on the Uno, the Nano and the Mega, and the display and the touch controller share the same bus.
The drawing functions are the same as those of the ST7735 and the SSD1306 libraries, so code that you have written for one display is easy to move to another.
ILI9341 XPT2046 Wiring
The display and the touch controller both connect to the hardware SPI pins of the Arduino. On the Uno and the Nano, MOSI is on pin 11, MISO is on pin 12 and SCK is on pin 13, and these pins cannot be changed. On the Mega, they are on pins 51, 50 and 52. The Arduino UNO pinout guide and the Arduino Nano pinout guide show these pins in detail. The remaining control pins can use any free digital pin.
Display Connections
| ILI9341 pin | Arduino Uno pin | Function |
|---|---|---|
| VCC | 5V | Power for the module and its onboard regulator |
| GND | GND | Ground |
| CS | D10 | Chip select of the display |
| DC (RS) | D9 | Data or command selection |
| RESET | D8 | Reset, optional |
| MOSI (SDI) | D11 | SPI data from the Arduino to the display |
| SCK | D13 | SPI clock |
| LED | 3.3V or 5V, see the module notes | Backlight |
| MISO (SDO) | Leave unconnected | Only needed to read data back from the display |
The reset pin can be connected to the 3.3 V supply to save a pin, but a dedicated pin gives a more reliable start-up. The backlight pin depends on the module, so check its description before you choose between 3.3 V and 5 V.
Touch Controller Connections
The XPT2046 is already on the module, and it shares the power pins with the display. Only its signal pins need wires.
| XPT2046 pin | Arduino Uno pin | Function |
|---|---|---|
| T_CS | D6 | Chip select of the touch controller |
| T_CLK | D13 | SPI clock, shared with the display |
| T_DIN | D11 | SPI data in, shared with the display |
| T_DO | D12 | SPI data out, used as MISO |
| T_IRQ | Not used | Optional interrupt output that goes LOW on a touch |
The touch controller uses T_DO as its MISO line, so the display MISO pin can stay unconnected. On some modules, the output of the display can disturb the touch readings when both outputs share the same wire, and leaving the display MISO free avoids that problem. Each device keeps its own chip-select pin, which is D10 for the display and D6 for the touch controller.
Power Tips
- Keep the wires short to reduce flicker and noise.
- The display and the backlight need roughly 80 mA to 120 mA, so use an external 5 V supply if a weak USB port causes resets.
- Connect the grounds of the Arduino, the display and any external supply together.
ILI9341 Level Shifting (5V to 3.3V)
The Arduino Uno outputs 5 V signals, but the ILI9341 and the XPT2046 work with 3.3 V logic. Some modules include level shifting, and many do not, so the safest approach is to check the description or the schematic of your module. If you cannot confirm that the module is 5 V tolerant, reduce the 5 V signals that go into the module.
The lines that need level shifting are the ones that the Arduino drives.
- MOSI (pin 11)
- SCK (pin 13)
- CS of the display (pin 10)
- DC (pin 9)
- RESET (pin 8)
- T_CS of the touch controller (pin 6)
The lines that the module drives, which are MISO or T_DO, and the optional T_IRQ, are safe to connect directly, because the 3.3 V output is high enough for the 5 V input of the Arduino.
Resistor Divider Method
A divider of two resistors reduces 5 V to about 3.3 V. Place a 1 kΩ resistor in series with the signal and a 2 kΩ resistor from the module side of that resistor to ground. The same divider is used on each line that needs shifting. The divider works well at the SPI speed of an Uno, and it costs very little.
Logic Level Shifter Method
A small bidirectional or four-channel level shifter module gives cleaner edges than a divider, and it takes less wiring effort for many lines. Connect the high side to 5 V and the low side to 3.3 V, and pass the signals through it. This option is better when you use long wires or higher SPI speeds.
ILI9341 Library Setup
Three libraries are used in this tutorial. Adafruit GFX provides the drawing functions, Adafruit ILI9341 controls the display and XPT2046_Touchscreen reads the touch controller.
Install Adafruit GFX and Adafruit ILI9341
- Open the Arduino IDE and go to Sketch, then Include Library, then Manage Libraries.
- Search for “Adafruit GFX” and install Adafruit GFX Library.
- Search for “Adafruit ILI9341” and install Adafruit ILI9341.
The two libraries give you the usual drawing functions, such as fillScreen(), drawPixel(), setCursor(), setTextColor(), drawRect() and drawCircle().
Install XPT2046_Touchscreen
Search for “XPT2046_Touchscreen” in the Library Manager and install the library by Paul Stoffregen.
The library provides touched() to detect a touch and getPoint() to read the position and the pressure of the touch. It shares the SPI bus with the display, and each device uses its own chip-select pin. If you pass the T_IRQ pin to the constructor, the library checks that pin first, which makes the touch detection faster.
Optional Libraries
The Adafruit libraries are fast enough for an Uno or a Nano. Larger projects on faster boards can use libraries such as ILI9341_due or TFT_eSPI. TFT_eSPI is aimed mainly at the ESP32 and the ESP8266 and does not suit the Uno.
ILI9341 Text and Shapes
A first test sketch confirms that the wiring and the libraries work. It prints a line of text and draws a line, two rectangles and two circles. The function setRotation(1) selects the landscape orientation, which gives a drawing area of 320 by 240 pixels.
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#define TFT_CS 10
#define TFT_DC 9
#define TFT_RST 8
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
void setup() {
tft.begin();
tft.setRotation(1); // Landscape, 320 x 240
tft.fillScreen(ILI9341_BLACK);
// Text
tft.setCursor(20, 20);
tft.setTextSize(2);
tft.setTextColor(ILI9341_YELLOW);
tft.println("ILI9341 Test");
// Shapes
tft.drawLine(10, 60, 230, 60, ILI9341_RED);
tft.drawRect(10, 80, 100, 50, ILI9341_GREEN);
tft.fillRect(120, 80, 100, 50, ILI9341_BLUE);
tft.drawCircle(60, 180, 30, ILI9341_CYAN);
tft.fillCircle(180, 180, 30, ILI9341_MAGENTA);
}
void loop() {
}The functions in the sketch work as follows.
tft.begin()starts the SPI communication and initializes the display.tft.fillScreen()clears the screen with a background color.setCursor(),setTextSize()andsetTextColor()prepare the next text output, andprintln()prints it.drawLine(),drawRect(),fillRect(),drawCircle()andfillCircle()draw the shapes, and the last argument of each is the color.
In landscape mode with text size 2, the screen fits 26 characters per line and 15 lines. In portrait mode, which setRotation(0) selects, it fits 20 characters per line and 20 lines. The rotation values 0 to 3 turn the drawing area in steps of 90 degrees.
If the screen stays white, check the wiring and the level shifting first, and see the troubleshooting section for more steps.
ILI9341 Bitmaps
Bitmaps show logos, icons and other fixed graphics. A monochrome bitmap stores one bit per pixel, so a 16 by 16 icon needs only 32 bytes. The PROGMEM keyword keeps the data in flash memory and saves the limited SRAM of the Uno. The drawBitmap() function draws the 1-bit image in a single color that you choose.
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#define TFT_CS 10
#define TFT_DC 9
#define TFT_RST 8
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
// 16 x 16 heart icon, 1 bit per pixel
const unsigned char heart[] PROGMEM = {
0x00, 0x00, 0x3C, 0x3C, 0x7E, 0x7E, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0xFE, 0x3F, 0xFC,
0x1F, 0xF8, 0x0F, 0xF0, 0x07, 0xE0, 0x03, 0xC0,
0x01, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
void setup() {
tft.begin();
tft.setRotation(1);
tft.fillScreen(ILI9341_BLACK);
tft.setCursor(10, 10);
tft.setTextSize(2);
tft.setTextColor(ILI9341_YELLOW);
tft.println("Bitmap Demo");
// x, y, array, width, height, color
tft.drawBitmap(40, 60, heart, 16, 16, ILI9341_RED);
}
void loop() {
}The arguments of drawBitmap() are the position of the top-left corner, the bitmap array, the width, the height and the color. The width and the height must match the data in the array. Bigger images use the same function with a larger array. The SSD1306 bitmap section shows how to convert a picture to an array with the Image2CPP tool.
Color Images and Memory Limits
A color image must be in the RGB565 format and is drawn with drawRGBBitmap(). Each pixel needs two bytes, so a full-screen image of 240 by 320 pixels needs 153,600 bytes. The Uno has only 32 KB of flash memory, so a full-screen color image does not fit in the program. Small icons fit easily, and larger pictures should be read from an SD card. The SD card section of the ST7735 tutorial and the SD card SPI tutorial show how to load images from the card.
XPT2046 Touch Coordinates
The XPT2046 reads the position of a finger on the resistive panel and returns raw values from its 12-bit converter. The values range from 0 to 4095, but a real panel usually reports a range of about 200 to 3800. The sketch below prints the raw values to the Serial Monitor and draws a dot at the touched position with a rough conversion to pixels.
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#define TFT_CS 10
#define TFT_DC 9
#define TFT_RST 8
#define TOUCH_CS 6
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
XPT2046_Touchscreen touch(TOUCH_CS);
void setup() {
Serial.begin(9600);
tft.begin();
tft.setRotation(1);
tft.fillScreen(ILI9341_BLACK);
touch.begin();
touch.setRotation(3); // Try 1 if the touch is mirrored
tft.setTextSize(2);
tft.setTextColor(ILI9341_GREEN);
tft.setCursor(10, 10);
tft.println("Touch Test");
}
void loop() {
if (touch.touched()) {
TS_Point p = touch.getPoint(); // Raw touch point
Serial.print("X = ");
Serial.print(p.x);
Serial.print(" Y = ");
Serial.println(p.y);
// Rough, uncalibrated conversion to pixels
int x = map(p.x, 200, 3800, 0, tft.width());
int y = map(p.y, 200, 3800, 0, tft.height());
tft.fillCircle(x, y, 3, ILI9341_YELLOW);
}
delay(5);
}The touch.touched() function returns true while the screen is pressed, and touch.getPoint() returns a point with the raw x, y and pressure z values. The touch.setRotation() call turns the touch axes. The display and the touch panel have separate rotation settings, and the touch rotation must match the display rotation. For tft.setRotation(1), most modules need touch.setRotation(3), and some need touch.setRotation(1). If the dot moves in the wrong direction, change this value first.
The small delay at the end of the loop keeps the sketch from polling the touch controller at the highest possible rate. Both devices share the SPI bus, and the libraries use SPI transactions, so they can work side by side without extra code.
The dots do not land exactly under the finger yet, because the conversion uses guessed limits. The next section replaces them with measured values.
XPT2046 Touch Calibration
Raw touch values depend on the panel, and the real minimum and maximum values differ from one module to the next. Calibration measures them and converts raw readings to pixel positions accurately. The method below uses two reference points. The sketch draws a cross near the top-left corner and another near the bottom-right corner, and you touch the center of each cross. A line between the two raw readings is enough to map every other position. The crosses are placed 20 pixels inside the corners, because it is difficult to press the exact edge of a resistive panel.
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#define TFT_CS 10
#define TFT_DC 9
#define TFT_RST 8
#define TOUCH_CS 6
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
XPT2046_Touchscreen touch(TOUCH_CS);
const int MARGIN = 20; // Distance of the crosses from the screen edges
void drawCross(int x, int y) {
tft.drawLine(x - 10, y, x + 10, y, ILI9341_WHITE);
tft.drawLine(x, y - 10, x, y + 10, ILI9341_WHITE);
}
// Waits for a touch, returns the raw point and waits until the finger lifts
TS_Point waitForTouch() {
while (!touch.touched()) delay(10);
delay(100); // Let the reading settle
TS_Point p = touch.getPoint();
while (touch.touched()) delay(10); // Wait for release
delay(200);
return p;
}
void setup() {
Serial.begin(9600);
tft.begin();
tft.setRotation(1);
touch.begin();
touch.setRotation(3);
// Point 1: near the top-left corner
tft.fillScreen(ILI9341_BLACK);
tft.setTextSize(2);
tft.setTextColor(ILI9341_WHITE);
tft.setCursor(60, 110);
tft.print("Touch the cross");
drawCross(MARGIN, MARGIN);
TS_Point p1 = waitForTouch();
// Point 2: near the bottom-right corner
tft.fillScreen(ILI9341_BLACK);
tft.setCursor(60, 110);
tft.print("Touch the cross");
drawCross(tft.width() - MARGIN, tft.height() - MARGIN);
TS_Point p2 = waitForTouch();
tft.fillScreen(ILI9341_BLACK);
tft.setCursor(60, 110);
tft.print("Calibration done");
Serial.println("Copy these values into your sketch:");
Serial.print("const int RAW_X1 = "); Serial.print(p1.x); Serial.println(";");
Serial.print("const int RAW_Y1 = "); Serial.print(p1.y); Serial.println(";");
Serial.print("const int RAW_X2 = "); Serial.print(p2.x); Serial.println(";");
Serial.print("const int RAW_Y2 = "); Serial.print(p2.y); Serial.println(";");
}
void loop() {
}Open the Serial Monitor at 9600 baud after the two touches. The sketch prints four constants, which you copy into the sketches that follow. The conversion uses the map() function and the same margin as the crosses.
int sx = map(p.x, RAW_X1, RAW_X2, MARGIN, tft.width() - MARGIN);
int sy = map(p.y, RAW_Y1, RAW_Y2, MARGIN, tft.height() - MARGIN);The map() function also works when the first raw value is larger than the second, so a mirrored axis is corrected automatically. It cannot fix swapped axes, which is why the rotation of the touch panel has to be right before you calibrate. Run the calibration again whenever you change the rotation of the display.
ILI9341 Touch Buttons
With a calibrated panel, touch buttons are simple to build. A button is a rectangle with a label, and a touch counts as a press when the converted position falls inside that rectangle. The sketch below defines a small Button structure, draws two buttons and changes the color of a pressed button until the finger lifts. The values for RAW_X1 to RAW_Y2 must come from your own calibration run.
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#define TFT_CS 10
#define TFT_DC 9
#define TFT_RST 8
#define TOUCH_CS 6
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
XPT2046_Touchscreen touch(TOUCH_CS);
// Values printed by the calibration sketch (replace them with your own)
const int RAW_X1 = 350, RAW_Y1 = 400, RAW_X2 = 3700, RAW_Y2 = 3700;
const int MARGIN = 20;
struct Button {
int x, y, w, h;
uint16_t color;
const char *label;
};
Button btn1 = {20, 70, 130, 60, ILI9341_RED, "RED"};
Button btn2 = {170, 70, 130, 60, ILI9341_BLUE, "BLUE"};
// Reads the touch panel and returns the position in screen pixels
bool readTouch(int &sx, int &sy) {
if (!touch.touched()) return false;
TS_Point p = touch.getPoint();
sx = constrain(map(p.x, RAW_X1, RAW_X2, MARGIN, tft.width() - MARGIN), 0, tft.width() - 1);
sy = constrain(map(p.y, RAW_Y1, RAW_Y2, MARGIN, tft.height() - MARGIN), 0, tft.height() - 1);
return true;
}
void waitForRelease() {
while (touch.touched()) delay(10);
delay(50);
}
void drawButton(const Button &b, bool pressed) {
tft.fillRoundRect(b.x, b.y, b.w, b.h, 8, pressed ? ILI9341_GREEN : b.color);
tft.setTextSize(2);
tft.setTextColor(ILI9341_WHITE);
int textWidth = strlen(b.label) * 12; // 12 pixels per character at size 2
tft.setCursor(b.x + (b.w - textWidth) / 2, b.y + b.h / 2 - 8);
tft.print(b.label);
}
bool inside(const Button &b, int sx, int sy) {
return (sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h);
}
void showStatus(const char *text, uint16_t color) {
tft.fillRect(0, 190, tft.width(), 30, ILI9341_BLACK);
tft.setTextSize(2);
tft.setTextColor(color);
tft.setCursor(10, 195);
tft.print(text);
}
void setup() {
tft.begin();
tft.setRotation(1);
touch.begin();
touch.setRotation(3);
tft.fillScreen(ILI9341_BLACK);
tft.setTextSize(2);
tft.setTextColor(ILI9341_YELLOW);
tft.setCursor(10, 10);
tft.print("Touch Buttons");
drawButton(btn1, false);
drawButton(btn2, false);
}
void loop() {
int sx, sy;
if (!readTouch(sx, sy)) {
delay(10);
return;
}
Button *hit = NULL;
if (inside(btn1, sx, sy)) hit = &btn1;
else if (inside(btn2, sx, sy)) hit = &btn2;
if (hit) {
drawButton(*hit, true); // Pressed look
char message[24];
snprintf(message, sizeof(message), "Pressed: %s", hit->label);
showStatus(message, ILI9341_GREEN);
waitForRelease();
drawButton(*hit, false); // Back to the normal look
}
}The sketch is organized into small parts that the next two examples reuse.
readTouch()reads the raw point and converts it to screen pixels with the calibration values.drawButton()draws a rounded rectangle with a centered label and switches to green while the button is pressed.inside()checks whether a point is inside a button, which is the only test that a button needs.waitForRelease()waits until the finger lifts, so that one touch produces exactly one press.
Because the buttons are stored in a structure, adding a third or a fourth button needs one more line for the definition and one more check in the loop.
ILI9341 LED Control Panel
The next example turns a touch interface into a real controller. Two buttons switch an LED on pin 5, and a status line shows the current state. Connect the LED anode to pin 5 through a resistor of 220 Ω and the cathode to ground. The helper functions are the same as in the previous sketch, so only the new parts are described here.
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#define TFT_CS 10
#define TFT_DC 9
#define TFT_RST 8
#define TOUCH_CS 6
#define LED_PIN 5
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
XPT2046_Touchscreen touch(TOUCH_CS);
const int RAW_X1 = 350, RAW_Y1 = 400, RAW_X2 = 3700, RAW_Y2 = 3700;
const int MARGIN = 20;
struct Button {
int x, y, w, h;
uint16_t color;
const char *label;
};
Button btnOn = {80, 60, 160, 50, ILI9341_BLUE, "LED ON"};
Button btnOff = {80, 130, 160, 50, ILI9341_RED, "LED OFF"};
bool readTouch(int &sx, int &sy) {
if (!touch.touched()) return false;
TS_Point p = touch.getPoint();
sx = constrain(map(p.x, RAW_X1, RAW_X2, MARGIN, tft.width() - MARGIN), 0, tft.width() - 1);
sy = constrain(map(p.y, RAW_Y1, RAW_Y2, MARGIN, tft.height() - MARGIN), 0, tft.height() - 1);
return true;
}
void drawButton(const Button &b) {
tft.fillRoundRect(b.x, b.y, b.w, b.h, 8, b.color);
tft.setTextSize(2);
tft.setTextColor(ILI9341_WHITE);
int textWidth = strlen(b.label) * 12;
tft.setCursor(b.x + (b.w - textWidth) / 2, b.y + b.h / 2 - 8);
tft.print(b.label);
}
bool inside(const Button &b, int sx, int sy) {
return (sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h);
}
void showStatus(const char *text, uint16_t color) {
tft.fillRect(0, 200, tft.width(), 30, ILI9341_BLACK);
tft.setTextSize(2);
tft.setTextColor(color);
tft.setCursor(10, 205);
tft.print(text);
}
void setup() {
pinMode(LED_PIN, OUTPUT);
tft.begin();
tft.setRotation(1);
touch.begin();
touch.setRotation(3);
tft.fillScreen(ILI9341_BLACK);
tft.setTextSize(2);
tft.setTextColor(ILI9341_YELLOW);
tft.setCursor(10, 10);
tft.print("Control Panel");
drawButton(btnOn);
drawButton(btnOff);
showStatus("Status: LED is OFF", ILI9341_RED);
}
void loop() {
int sx, sy;
if (!readTouch(sx, sy)) {
delay(10);
return;
}
if (inside(btnOn, sx, sy)) {
digitalWrite(LED_PIN, HIGH);
showStatus("Status: LED is ON", ILI9341_GREEN);
} else if (inside(btnOff, sx, sy)) {
digitalWrite(LED_PIN, LOW);
showStatus("Status: LED is OFF", ILI9341_RED);
}
delay(100);
}The status line is cleared and redrawn only when a button is touched, so the rest of the screen does not flicker. The same pattern drives relays, motors through a driver or any other output, and the digitalWrite() tutorial explains how to control the output pins safely.
ILI9341 Multi-Page UI
Most real projects need more than one screen. A simple state variable tracks the active page, and each page has its own drawing function and its own buttons. The sketch below has a Home page with two buttons, a Settings page and an Info page, and each of those has a Back button.
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#define TFT_CS 10
#define TFT_DC 9
#define TFT_RST 8
#define TOUCH_CS 6
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
XPT2046_Touchscreen touch(TOUCH_CS);
const int RAW_X1 = 350, RAW_Y1 = 400, RAW_X2 = 3700, RAW_Y2 = 3700;
const int MARGIN = 20;
struct Button {
int x, y, w, h;
uint16_t color;
const char *label;
};
Button btnSettings = {70, 70, 180, 50, ILI9341_BLUE, "Settings"};
Button btnInfo = {70, 140, 180, 50, ILI9341_GREEN, "Info"};
Button btnBack = {10, 190, 120, 40, ILI9341_RED, "Back"};
enum Page { HOME, SETTINGS, INFO };
Page currentPage = HOME;
bool readTouch(int &sx, int &sy) {
if (!touch.touched()) return false;
TS_Point p = touch.getPoint();
sx = constrain(map(p.x, RAW_X1, RAW_X2, MARGIN, tft.width() - MARGIN), 0, tft.width() - 1);
sy = constrain(map(p.y, RAW_Y1, RAW_Y2, MARGIN, tft.height() - MARGIN), 0, tft.height() - 1);
return true;
}
void waitForRelease() {
while (touch.touched()) delay(10);
delay(50);
}
void drawButton(const Button &b) {
tft.fillRoundRect(b.x, b.y, b.w, b.h, 8, b.color);
tft.setTextSize(2);
tft.setTextColor(ILI9341_WHITE);
int textWidth = strlen(b.label) * 12;
tft.setCursor(b.x + (b.w - textWidth) / 2, b.y + b.h / 2 - 8);
tft.print(b.label);
}
bool inside(const Button &b, int sx, int sy) {
return (sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h);
}
void drawTitle(const char *title, uint16_t color) {
tft.setTextSize(2);
tft.setTextColor(color);
tft.setCursor(10, 10);
tft.print(title);
}
void drawHomePage() {
tft.fillScreen(ILI9341_BLACK);
drawTitle("Home Page", ILI9341_YELLOW);
drawButton(btnSettings);
drawButton(btnInfo);
}
void drawSettingsPage() {
tft.fillScreen(ILI9341_DARKCYAN);
drawTitle("Settings", ILI9341_WHITE);
drawButton(btnBack);
}
void drawInfoPage() {
tft.fillScreen(ILI9341_NAVY);
drawTitle("Info Page", ILI9341_YELLOW);
tft.setTextColor(ILI9341_WHITE);
tft.setCursor(10, 60);
tft.print("Version: 1.0");
drawButton(btnBack);
}
void goTo(Page page) {
currentPage = page;
if (page == HOME) drawHomePage();
else if (page == SETTINGS) drawSettingsPage();
else drawInfoPage();
waitForRelease(); // Ignore the finger that is still down
}
void setup() {
tft.begin();
tft.setRotation(1);
touch.begin();
touch.setRotation(3);
drawHomePage();
}
void loop() {
int sx, sy;
if (!readTouch(sx, sy)) {
delay(10);
return;
}
if (currentPage == HOME) {
if (inside(btnSettings, sx, sy)) goTo(SETTINGS);
else if (inside(btnInfo, sx, sy)) goTo(INFO);
} else {
if (inside(btnBack, sx, sy)) goTo(HOME);
}
}The design rests on three ideas.
- The
Pageenumeration stores which screen is active, and the loop reacts only to the buttons of that screen. - Each page has its own function, such as
drawHomePage(), which keeps the code easy to read and to extend. - The function
goTo()changes the state, draws the new page and waits for the finger to lift, so that one touch cannot trigger two actions.
The same structure scales to five or ten pages. Add a value to the enumeration, write one drawing function and add the buttons that lead to it.
ILI9341 Troubleshooting
Most problems with the ILI9341 and the XPT2046 come from wiring, voltage levels, rotation or missing calibration.
The Screen Stays White or Blank
A white screen usually means that the display has power but receives no valid data.
- Check that CS, DC and RESET match the pin numbers in the sketch.
- Check that MOSI and SCK use the hardware SPI pins, which are 11 and 13 on the Uno and the Nano.
- Confirm that the signals are reduced to 3.3 V if your module has no level shifting.
- Try a lower SPI speed with
tft.begin(4000000);when the wires are long. - Make sure that
tft.begin()runs insetup()and that no other device uses the same CS pin.
Touch Does Not Work or the Position Is Wrong
- Check that T_CS is on its own pin and that it is not the same pin as the display CS.
- Call
touch.begin()and select the rotation that matches the display. Trytouch.setRotation(1)if the touch is mirrored. - Run the calibration sketch again after any change of rotation, and copy the new values into your sketch.
- If the dot moves along the wrong axis when you drag your finger, the touch rotation is wrong, and calibration cannot fix this.
- Check that the display MISO pin is unconnected if the readings are erratic, because on some modules it disturbs the T_DO line.
- A resistive panel needs a firm press, so use a fingertip or a stylus and not a light touch.
Random Lines or Freezing
- Use short wires and a common ground for the Arduino, the display and any external supply.
- Make sure that the supply gives enough current, since the display and the backlight need about 80 mA to 120 mA.
- Avoid other devices on the SPI bus while the display is updating, or give each device its own chip select pin.
Conclusion
This tutorial showed how to wire the ILI9341 display and the XPT2046 touch controller to an Arduino, shift the signals to 3.3 V and install the libraries. It covered text, shapes, bitmaps, raw touch readings and a two-point calibration, and it built touch buttons, an LED control panel and a multi-page menu. The important points are the 3.3 V logic level, the matching rotation of the display and the touch panel, and a fresh calibration after every rotation change. With these basics you can build dashboards, control panels and small touch applications.
Browse More Arduino Display Tutorials
Arduino SH1106 OLED Tutorial: I2C Wiring, Text, Bitmaps & Animations
How to Interface MAX7219 7 Segment Display with Arduino | Display Text, Scrolling Message, and Time
Arduino ST7920 Tutorial (128×64 LCD): Wiring, Setup, U8g2, and Text Display Guide
Arduino ST7920 Display Graphics Guide: Shapes, Icons, Bitmaps, and UI Design
Arduino ST7920 Graphics Guide: How to Create Scrolling Text, Animations and Page Transitions (U8g2)
Arun is an embedded systems engineer with 10+ years of experience in STM32, ESP32, and AVR microcontrollers. He created ControllersTech to share practical tutorials on embedded software, HAL drivers, RTOS, and hardware design — grounded in real industrial automation experience.
Recommended Tools
Essential dev tools
Categories
Browse by platform






In my opinion the 2-resistor voltage divider on this site is not correct. Arduino has 5V, the display needs 3.3V.
It is correct. With this arrangement, the 5V signals from Arduino are brought down to 3V.