ESP32 LED Matrix Display

LEDBeginnerIntermediateAdvanced

Chain MAX7219 8x8 LED matrix modules with the ESP32 to create a scrolling text display. Control the message and brightness from a web interface, animate custom patterns, and display live MQTT data as a real-time ticker board.

Overview

In this beginner project you will connect a single MAX7219 8x8 LED matrix module to the ESP32 via SPI and use the MD_MAX72XX library to scroll a text message across the display. The message, scroll speed, and brightness are set in the sketch. This teaches SPI communication, the MAX7219 daisy-chain architecture, and font rendering on an 8x8 dot matrix.

Components
  • 1× ESP32 DevKit V1
  • 1× MAX7219 8x8 LED matrix module — Pre-assembled module with MAX7219 and 64 LEDs
  • 1× Breadboard and jumper wires
Wiring
Component PinESP32 PinNotes
MAX7219 DIN (MOSI)GPIO 23SPI MOSI
MAX7219 CS (CS)GPIO 5SPI chip select
MAX7219 CLKGPIO 18SPI clock
MAX7219 VCC5 V (Vin)Matrix LEDs need 5 V for full brightness
MAX7219 GNDGND
Arduino Code
esp32-led-matrix-display_beginner.ino
// ESP32 LED Matrix Display - Beginner
// Single MAX7219 8x8; scrolling text via MD_MAX72XX library
// Install: MD_MAX72XX by MajicDesigns in Library Manager
// Install: MD_Parola by MajicDesigns in Library Manager

#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <SPI.h>

#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
#define MAX_DEVICES   1
#define CS_PIN        5

MD_Parola display = MD_Parola(HARDWARE_TYPE, CS_PIN, MAX_DEVICES);

const char* message = "Hello ESP32! ";
const uint8_t SCROLL_SPEED = 60; // milliseconds per step (lower = faster)
const uint8_t BRIGHTNESS   = 5;  // 0 (dim) to 15 (full)

void setup() {
  Serial.begin(115200);
  display.begin();
  display.setIntensity(BRIGHTNESS);
  display.displayClear();
  display.displayScroll(message, PA_LEFT, PA_SCROLL_LEFT, SCROLL_SPEED);
  Serial.println("LED matrix display ready.");
}

void loop() {
  if (display.displayAnimate()) {
    // Animation complete — restart scroll
    display.displayReset();
  }
}
How It Works
01

MAX7219 Serial Interface: The MAX7219 uses a 3-wire SPI interface (DIN, CLK, CS) to receive 16-bit words: 4-bit register address and 8-bit data. It decodes the address to control individual rows of 8 LEDs. The ESP32 Arduino SPI driver sends bytes at up to 10 MHz; the MAX7219 accepts up to 25 MHz.

02

Daisy-Chain Architecture: Multiple MAX7219 modules chain together by connecting DOUT of one to DIN of the next. The CS pin is shared. To write to module N in a chain of M modules, the SPI data is shifted through all M modules and each module latches its 16-bit word when CS rises. This is why the number of devices (MAX_DEVICES) must match the chain length.

03

MD_Parola Scroll Library: MD_Parola abstracts scrolling, fading, and display animations over MD_MAX72XX. displayScroll() configures a non-blocking scroll that advances one pixel per SCROLL_SPEED milliseconds. displayAnimate() must be called in loop() to advance the animation; it returns true when the current animation cycle completes.

04

Brightness Control: The MAX7219 has a 4-bit intensity register controlling PWM duty cycle from 1/32 (dim, level 0) to 31/32 (bright, level 15). setIntensity(5) sets a comfortable indoor brightness. Reduce to 1-2 for dark rooms; increase to 12-15 for bright retail environments.

Applications
  • Reception desk name ticker display
  • Desk clock with scrolling date and time
  • Live score display for sports events
  • Workshop safety message board
Troubleshooting

Display shows random lit pixels

The hardware type (FC16_HW, DR0CR0RR0_HW, etc.) must match your module. Try each hardware type constant in the MD_MAX72XX enumeration until the display shows correct output. FC16_HW is the most common for cheap Chinese modules.

Text appears mirrored or upside down

Some MAX7219 modules have the connector on a different side. Add display.setZoneEffect(0, true, PA_FLIP_LR) or PA_FLIP_UD to correct orientation. Also try changing HARDWARE_TYPE to MD_MAX72XX::GENERIC_HW.

Scrolling is very dim even at intensity 15

The MAX7219 module VCC must be 5 V. At 3.3 V the LED current is limited and brightness is reduced. Use the ESP32 Vin pin which provides 5 V from the USB supply. Also check the module ISET resistor; a 10 kohm resistor sets full-scale current to approximately 40 mA per LED.

Upgrades
  • Chain 4 MAX7219 modules for a 32x8 display with longer message capacity
  • Add a button to cycle through several preset messages
  • Add an RTC module and display the current time and date
  • Add Wi-Fi and set the message via a web interface
FAQ

You need an ESP32 DevKit, TODO: sensor, MAX7219 DIN (MOSI), a breadboard, jumper wires, and a USB cable for power and programming.

Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.

Start with Beginner if you are new to LED Projects. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.

Overview

The intermediate build chains four MAX7219 modules for a 32x8 display and adds a web interface where users type a custom message and set scroll speed and brightness from a browser. The current message and settings are stored in NVS so they survive power cuts. An NTP clock mode displays the current time scrolling continuously when no custom message is set.

Components
  • 1× ESP32 DevKit V1
  • 4× MAX7219 8x8 LED matrix module — Chained for 32x8 display
  • 1× Wi-Fi router — Web control + NTP
Wiring
Component PinESP32 PinNotes
MAX7219 chain DIN/CS/CLKGPIO 23 / 5 / 18Same as beginner; chain modules DOUT to DIN
Arduino Code
esp32-led-matrix-display_intermediate.ino
// ESP32 LED Matrix - Intermediate (4x MAX7219 + web control + NTP clock)
#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <SPI.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>

#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
#define MAX_DEVICES 4
#define CS_PIN 5

MD_Parola display(HARDWARE_TYPE, CS_PIN, MAX_DEVICES);
WebServer server(80);
Preferences prefs;
const char* SSID="YourSSID", *PASS="YourPass";

String message="Hello!";
int speed=60, brightness=5;
bool clockMode=false;

void saveSettings(){
  prefs.begin("matrix",false);
  prefs.putString("msg",message);
  prefs.putInt("spd",speed);
  prefs.putInt("bri",brightness);
  prefs.end();
}

void loadSettings(){
  prefs.begin("matrix",true);
  message=prefs.getString("msg","Hello!");
  speed=prefs.getInt("spd",60);
  brightness=prefs.getInt("bri",5);
  prefs.end();
}

void applyDisplay(){
  display.setIntensity(brightness);
  display.displayClear();
  display.displayScroll(message.c_str(),PA_LEFT,PA_SCROLL_LEFT,speed);
}

void servePanel(){
  String html="<html><body><h2>LED Matrix Control</h2>"
    "<form method=POST action=/set>"
    "<label>Message: <input name=msg value=""+message+""></label><br><br>"
    "<label>Speed (ms): <input name=spd type=number value="+String(speed)+"></label><br><br>"
    "<label>Brightness 0-15: <input name=bri type=number min=0 max=15 value="+String(brightness)+"></label><br><br>"
    "<input type=checkbox name=clk"+(clockMode?" checked":"")+"> Clock mode<br><br>"
    "<button type=submit>Update</button></form></body></html>";
  server.send(200,"text/html",html);
}

void handleSet(){
  if(server.hasArg("msg"))  message=server.arg("msg");
  if(server.hasArg("spd"))  speed=server.arg("spd").toInt();
  if(server.hasArg("bri"))  brightness=constrain(server.arg("bri").toInt(),0,15);
  clockMode=server.hasArg("clk");
  saveSettings();
  applyDisplay();
  server.sendHeader("Location","/"); server.send(302,"","");
}

void setup(){
  Serial.begin(115200);
  display.begin(); display.setIntensity(5);
  loadSettings();
  WiFi.begin(SSID,PASS);
  while(WiFi.status()!=WL_CONNECTED) delay(500);
  configTime(0,0,"pool.ntp.org");
  server.on("/",servePanel);
  server.on("/set",HTTP_POST,handleSet);
  server.begin();
  applyDisplay();
  Serial.printf("Matrix: http://%s/n",WiFi.localIP().toString().c_str());
}

char clockBuf[32];
void loop(){
  server.handleClient();
  if(clockMode){
    struct tm ti; getLocalTime(&ti);
    sprintf(clockBuf,"%02d:%02d:%02d",ti.tm_hour,ti.tm_min,ti.tm_sec);
    if(String(clockBuf)!=message){ message=String(clockBuf); applyDisplay(); }
  }
  if(display.displayAnimate()) display.displayReset();
}
How It Works
01

Four-Module Chain: Setting MAX_DEVICES=4 tells MD_Parola the chain length. SPI data is shifted through all four MAX7219 chips on each write. The display presents a continuous 32-pixel-wide canvas. The scroll message moves across all four modules seamlessly without gaps.

02

Web Settings Form: The HTML form POST sends message, speed, brightness, and a clock-mode checkbox to /set. Server-side, each arg() call extracts the posted value. After updating and saving to NVS, applyDisplay() reconfigures the scroll with the new parameters immediately.

03

NVS Persistence: Message, speed, and brightness are saved to NVS after each web form submission. On the next power-on, loadSettings() retrieves the last configured values so the display boots with the same message without requiring reconfiguration.

04

NTP Clock Mode: When clockMode is true, the loop builds a HH:MM:SS time string every iteration. When it differs from the current message, applyDisplay() restarts the scroll with the new time. The scroll runs continuously across the four modules showing the current time.

Applications
  • Reception message board updated from any browser
  • Retail promotion display remotely updated by staff
  • Clock and message display for a meeting room entrance
  • Sports scoreboard controllable from a tablet
Troubleshooting

Chain of 4 shows partial text on wrong modules

The module wiring order may be reversed relative to the visual left-to-right display order. Add display.setZoneEffect(0, true, PA_FLIP_LR) or reverse the physical chain order until text scrolls left-to-right as expected.

Web form does not POST values correctly

Verify server.hasArg() matches the HTML input name attributes exactly (case-sensitive). Use Serial.println(server.arg("msg")) to debug what the server receives after a form submission.

Clock mode shows the wrong time

Add the UTC offset in seconds to configTime(). For UTC+5: configTime(18000, 0, "pool.ntp.org"). For daylight-saving regions, use a POSIX timezone string as the third parameter.

Upgrades
  • Add a temperature sensor and rotate between time, temperature, and custom message
  • Add MQTT subscription so messages can be sent from Node-RED or Home Assistant
  • Add OTA firmware update so the sketch can be updated without physical access
  • Add multiple message slots rotatable via buttons on the web page
FAQ

You need an ESP32 DevKit, TODO: sensor, MAX7219 DIN (MOSI), a breadboard, jumper wires, and a USB cable for power and programming.

Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.

Start with Beginner if you are new to LED Projects. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.

Overview

The advanced build subscribes to multiple MQTT topics and renders a live ticker board: top row shows the current time, bottom row alternates between MQTT data feeds (stock ticker, weather, sensor readings). A Node-RED dashboard feeds live data to the MQTT topics. Custom animated transitions (slide, fade, wipe) between data items use MD_Parola zone effects.

Components
  • 1× ESP32 DevKit V1
  • 4× MAX7219 8x8 modules
  • 1× MQTT broker
  • 1× Node-RED — Data feed publisher
Wiring
Component PinESP32 PinNotes
Same as intermediateGPIO 23/5/18
Arduino Code
esp32-led-matrix-display_advanced.ino
// ESP32 LED Matrix - Advanced (MQTT multi-feed ticker)
// Subscribes to matrix/line0 and matrix/line1 for dual-row ticker content
#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <SPI.h>
#include <WiFi.h>
#include <PubSubClient.h>

#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
#define MAX_DEVICES 4
#define CS_PIN 5

MD_Parola display(HARDWARE_TYPE, CS_PIN, MAX_DEVICES);
WiFiClient wc; PubSubClient mqtt(wc);

const char* SSID="YourSSID", *PASS="YourPass";
const char* MQTT_HOST="192.168.1.100";

String line0="Loading...", line1="";
bool newData=false;

void mqttCallback(char* topic, byte* payload, unsigned int len){
  String msg((char*)payload,len);
  if(String(topic)=="matrix/line0") line0=msg;
  if(String(topic)=="matrix/line1") line1=msg;
  newData=true;
}

void updateDisplay(){
  String combined = line0;
  if(line1.length()) combined += "  |  " + line1;
  display.setIntensity(5);
  display.displayClear();
  display.displayScroll(combined.c_str(), PA_LEFT, PA_SCROLL_LEFT, 50);
  newData=false;
}

void setup(){
  Serial.begin(115200);
  display.begin(); display.setIntensity(5);
  display.displayScroll("Connecting...",PA_LEFT,PA_SCROLL_LEFT,60);
  WiFi.begin(SSID,PASS);
  while(WiFi.status()!=WL_CONNECTED){
    delay(500); if(display.displayAnimate()) display.displayReset();
  }
  mqtt.setServer(MQTT_HOST,1883);
  mqtt.setCallback(mqttCallback);
}

void loop(){
  if(!mqtt.connected()){
    mqtt.connect("MatrixDisplay");
    mqtt.subscribe("matrix/line0");
    mqtt.subscribe("matrix/line1");
  }
  mqtt.loop();
  if(newData) updateDisplay();
  if(display.displayAnimate()) display.displayReset();
}
How It Works
01

Dual-Topic MQTT Ticker: Two MQTT topics feed the display: matrix/line0 for the primary content (time, headline) and matrix/line1 for secondary content (temperature, score). The callback sets a newData flag. The next loop() iteration calls updateDisplay(), which combines both lines with a pipe separator and restarts the scroll.

02

Node-RED Data Publisher: A Node-RED flow fetches weather data from an HTTP endpoint every 5 minutes and publishes the temperature and conditions to matrix/line1. An inject node with a 1-second interval publishes the current time formatted as HH:MM to matrix/line0. Additional flows can publish news headlines or sensor readings.

03

Non-Blocking Display Animation: display.displayAnimate() returns true when the current scroll animation completes. displayReset() restarts the same scroll from the beginning. This non-blocking design allows mqtt.loop() to process incoming messages between each animation step without interrupting the display.

04

Connection Status During Boot: While connecting to Wi-Fi, the display shows "Connecting..." scrolling across the modules. The animation loop runs during the Wi-Fi connection attempt so the display appears active rather than frozen. Once connected, the first MQTT data arrives and replaces the placeholder text.

Applications
  • Office ticker showing real-time KPI metrics from a business dashboard
  • Cryptocurrency price ticker with live MQTT data feed
  • IoT sensor data display showing temperature, humidity, and air quality
  • Building lobby information board updated via MQTT from a CMS
Troubleshooting

MQTT messages arrive but display does not update

Verify newData is set to true in mqttCallback and checked in loop(). If updateDisplay() calls displayScroll() while a previous scroll is running, the new content may not start until displayAnimate() is called and returns true. Move updateDisplay() to inside the displayAnimate() completion block.

Combined message is too long and scroll is very slow

Limit line0 and line1 to 20 characters each. Longer combined messages increase the scroll duration linearly. Add truncation in the mqttCallback with line0=msg.substring(0,20).

Display freezes when Wi-Fi reconnects

The PubSubClient reconnect call in loop() is brief. Ensure mqtt.loop() is called every iteration and does not block. If reconnection takes more than a few hundred milliseconds, the displayAnimate() calls stop and the display freezes on the current frame.

Upgrades
  • Add a photoresistor to auto-adjust brightness based on ambient light level
  • Add animated zone transitions between line0 and line1 using MD_Parola zone effects
  • Add a web configurator to assign MQTT topics to each display zone at runtime
  • Add an alert mode: publish to matrix/alert for a red-flashing urgent message overlay
FAQ

You need an ESP32 DevKit, TODO: sensor, MAX7219 DIN (MOSI), a breadboard, jumper wires, and a USB cable for power and programming.

Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.

Start with Beginner if you are new to LED Projects. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.