ESP32 WS2812B 8×8 LED Matrix Display with Web Server – Scrolling Text, Clock, Colors & Effects

ESP32 WS2812B 8×8 LED Matrix Display with Web Server – Scrolling Text, Clock, Colors & Effects ESP32 WS2812B 8×8 LED Matrix Display with Web Server – Scrolling Text, Clock, Colors & Effects

Addressable RGB matrices look fantastic, but most tutorials stop at “upload this code and the text scrolls.” The moment you want to change the message, swap colors, or add a second panel, you’re back in the Arduino IDE re-flashing the board. That’s not how a display on your desk, shop window, or workbench should work.

In this project we’re building a fully web-controlled ESP32 LED matrix sign using chainable WS2812B 8×8 addressable RGB matrix modules. Everything — the scrolling message, text color, color gradients, animation effects, brightness, scroll speed, even the number of matrix panels connected — is configured from a webpage hosted directly on the ESP32. There’s also a built-in live clock and date display that syncs over Wi-Fi and adjusts to your timezone, and WiFiManager so you never have to hardcode your Wi-Fi credentials into the sketch.

By the end of this guide you’ll have a sign that:

  • Connects to Wi-Fi through a captive-portal setup page (no hardcoded SSID/password)
  • Displays any custom scrolling text you type into a web dashboard
  • Scrolls live time and date, synced over NTP, in the timezone you select from a dropdown
  • Supports solid colors and two-color gradients across the scrolling text
  • Includes four built-in animated effects (Rainbow Wave, Twinkle, Sweep, Breathing)
  • Scales from 1 to 5 chained 8×8 matrix modules without touching the code
  • Remembers every setting after a power cut, using the ESP32’s onboard flash storage
  • Resets its saved Wi-Fi credentials with nothing but a 5-second press of the ESP32’s onboard BOOT button — no extra wiring, no re-flashing

Let’s build it.


Requirements:

Hardware Requirements:

  • ESP32 Development Board (any variant — DevKit V1, WROOM-32, etc.)
  • WS2812B 8×8 Addressable RGB LED Matrix module — 1 to 5 units, daisy-chainable
  • 5V external power supply (rated per the power notes below — do not power the matrix from the ESP32’s USB port)
  • 470Ω–560Ω resistor (data line protection)
  • 1000µF, 6.3V or higher electrolytic capacitor (across the 5V/GND power rails)
  • Jumper wires / breadboard or perfboard for permanent wiring

No extra button is needed for the Wi-Fi reset feature — every ESP32 dev board already has a BOOT button (also silkscreened as IO0/FLASH on some boards) wired to GPIO0, and this project reuses it in software.

Software Requirements:

  • Arduino IDE with the ESP32 board package installed
  • Adafruit NeoPixel library
  • Adafruit GFX Library
  • WiFiManager library (by tzapu)

WS2812B 8×8 matrix module:

Each module packs 64 individually addressable RGB LEDs wired internally in a serpentine (boustrophedon) pattern — the data signal snakes across row 0 left-to-right, then row 1 right-to-left, and so on. This matters because it means each LED has a fixed address (0–63) that depends on the physical wiring of your specific board, not just its (x, y) position. We’ll handle this with an addressing function later in the code, and I’ll show you exactly how to fix it if your first test comes out mirrored or scrambled.

Multiple modules are chained by connecting the DOUT pin of one panel to the DIN pin of the next. Only the first module connects to the ESP32; data flows through the chain automatically. This is what lets us scale the display from a single 8×8 badge up to a 40×8 pixel scrolling sign using five modules, all from one GPIO pin.

Technical specifications:

  • Operating voltage: 5V DC
  • Each pixel: WS2812B RGB LED with built-in driver IC
  • Single-wire data protocol (no SPI/I2C needed)
  • Current draw: up to ~60mA per LED at full white, full brightness (64 LEDs × up to 3.84A per module at worst case)
  • Chainable via DIN → DOUT, up to hundreds of modules in theory
  • Refresh handled entirely by the microcontroller (no onboard memory)

ESP32 with 8X8 Matrix WS2812B LED module Circuit Diagram

Connect the LED matrix and required components to esp32 as shown in the below circuit diagram

ESP32 with 8X8 Matrix WS2812B LED module Circuit Diagram

Wiring:

WS2812B Matrix Pin Connects To
DIN (first module only) ESP32 GPIO 5 (through a 470Ω resistor)
5V External 5V power supply (+)
GND External power supply (–) and ESP32 GND (common ground)
DOUT (module 1) DIN (module 2), and so on down the chain

Place the 1000µF capacitor directly across the 5V and GND terminals of the first matrix module. This absorbs the initial power-on current spike and prevents the first pixel from glitching at boot — a very common issue with WS2812B strips and matrices that people mistake for a wiring fault.

Power supply and safety notes:

This is the part most tutorials skip, and it’s the part that actually causes “my matrix doesn’t work” support questions.

  • A single WS2812B LED can draw up to ~60mA at full brightness, full white. Five 8×8 modules = 320 LEDs, which means a theoretical worst case of over 19A at 100% brightness.
  • In practice, you’ll rarely show pure white text at full brightness, but you should still size your power supply generously. For up to 3 modules at moderate brightness (which is what most text/clock displays actually use), a 5V 5A supply is a safe, comfortable choice. For 4–5 modules, consider a 5V 8–10A supply, or inject power at both ends of the chain.
  • Never power more than one or two modules from the ESP32’s 5V pin or a phone charger — you will get brownouts, random resets, or dim/flickering colors.
  • The ESP32 and the 5V power supply must share a common ground, even though they’re powered separately.
  • The default brightness in this project is intentionally capped at a safe level (see cfgBrightness in the code) and is adjustable from the web dashboard — turning it down is the easiest way to reduce current draw if you don’t have a beefy power supply.

How the Web Dashboard Works:

On first boot, the ESP32 has no saved Wi-Fi credentials, so WiFiManager automatically starts an access point named LED-Matrix-Setup. Connect to it from your phone or laptop, and a captive portal pops up automatically (or open 192.168.4.1 in a browser) where you pick your home Wi-Fi network and enter the password. The ESP32 saves this, reboots, and connects normally from then on — no credentials are ever hardcoded in the sketch.

Once connected, the ESP32 hosts a control panel at its local IP address with:

  • A text box for your scrolling message
  • A mode switch: Text / Clock / Effects
  • Two color pickers (solid color, or a start → end gradient)
  • A gradient on/off toggle
  • An effects dropdown (Rainbow Wave, Twinkle, Sweep, Breathing)
  • Sliders for brightness and scroll speed
  • A dropdown to set how many 8×8 modules are physically connected (1–5)
  • A timezone dropdown for the live clock
  • A “Forget Wi-Fi” button to reset network settings remotely
  • A physical fallback: holding the ESP32’s BOOT button for 5 seconds wipes saved Wi-Fi credentials directly from the hardware, even if the board isn’t reachable on the network anymore

Every change is applied instantly and saved to the ESP32’s internal flash (via the Preferences library), so your settings survive power loss.

Similar Article: DIY Smart LED strip with Sound reactive effects using ESP8266 ESP32 WiFi


Required Libraries:

Install these from Arduino IDE → Tools → Manage Libraries:

  • Adafruit NeoPixel — by Adafruit
  • Adafruit GFX Library — by Adafruit
  • WiFiManager — by tzapu

WiFi.h, WebServer.h, Preferences.h, and time.h are all bundled with the ESP32 board package, so there’s nothing else to install. There’s also no SPIFFS/LittleFS filesystem used in this project — the entire web dashboard lives inside the sketch as a single string, so you don’t need to worry about upload filesystem tools or partition schemes. The default “Default 4MB with spiffs” partition scheme works fine.

Code to upload using Arduino IDE:

Set Board → your ESP32 board, pick the correct Port, and make sure DATA_PIN below matches whichever GPIO you wired to DIN. Upload the full sketch below as-is.

#include <Adafruit_GFX.h>
#include <Adafruit_NeoMatrix.h>
#include <Adafruit_NeoPixel.h>
#include <WiFi.h>
#include <WiFiManager.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>
#include <ESPmDNS.h>

// ---------------- Hardware configuration ----------------
#define DATA_PIN        5      // GPIO connected to DIN of the first matrix module
#define MDNS_HOSTNAME   "matrixdisplay"  // reachable at http://matrixdisplay.local
#define MODULE_COLS     8
#define MODULE_ROWS     8
#define MAX_MODULES     5

// Per-panel wiring (position/order of pixel 0 WITHIN each 8x8 panel)
// and tile wiring (how the panels are chained together) are two
// separate settings. The values below match a commonly used cheap
// WS2812B 8x8 board tested on real hardware. If your text comes out
// mirrored, rotated, or scrambled once you add a second module,
// see "Fixing a Mirrored or Scrambled Display" further down this
// article for how to change these.
#define PANEL_ORIENTATION (NEO_MATRIX_TOP + NEO_MATRIX_RIGHT + NEO_MATRIX_COLUMNS + NEO_MATRIX_PROGRESSIVE)
#define TILE_ORIENTATION  (NEO_TILE_TOP + NEO_TILE_LEFT + NEO_TILE_ROWS + NEO_TILE_PROGRESSIVE)

// Onboard BOOT button, used for a hardware Wi-Fi credentials reset.
// Wired active-LOW with an internal pull-up on every ESP32 dev board.
#define BOOT_BUTTON_PIN     0
#define WIFI_RESET_HOLD_MS  5000
unsigned long buttonPressStart = 0;
bool buttonHeld = false;

// Forward declaration — PAGE_HTML is defined further down (after the
// handlers that use it), so the compiler needs to know it exists first.
extern const char PAGE_HTML[] PROGMEM;

// Adafruit_NeoMatrix draws text/shapes using Adafruit_GFX's standard
// color format, so a plain setTextColor() only ever gives you one
// flat color. To support the two-color gradient feature, this small
// subclass intercepts every pixel Adafruit_GFX is about to draw and,
// if gradient mode is on, swaps in a freshly computed color based on
// that pixel's X position before handing it to the real NeoMatrix
// drawPixel(). Background pixels (color == 0, from fillScreen(0))
// are left untouched, so only the lit parts of each character are
// recolored - the text itself is unaffected.
class GradientMatrix : public Adafruit_NeoMatrix {
  public:
    using Adafruit_NeoMatrix::Adafruit_NeoMatrix;
    bool gradientOn = false;
    uint32_t gStart = 0, gEnd = 0;
    int gWidth = MODULE_COLS;

    void drawPixel(int16_t x, int16_t y, uint16_t color) {
      if (gradientOn && color != 0) {
        uint8_t r1 = (gStart >> 16) & 0xFF, g1 = (gStart >> 8) & 0xFF, b1 = gStart & 0xFF;
        uint8_t r2 = (gEnd   >> 16) & 0xFF, g2 = (gEnd   >> 8) & 0xFF, b2 = gEnd & 0xFF;
        float t = (gWidth > 1) ? (float)x / (float)(gWidth - 1) : 0.0f;
        uint8_t r = r1 + (int)((int)r2 - (int)r1) * t;
        uint8_t g = g1 + (int)((int)g2 - (int)g1) * t;
        uint8_t b = b1 + (int)((int)b2 - (int)b1) * t;
        Adafruit_NeoMatrix::drawPixel(x, y, Color(r, g, b));
      } else {
        Adafruit_NeoMatrix::drawPixel(x, y, color);
      }
    }
};

GradientMatrix *matrix;
WebServer server(80);
Preferences prefs;

// ---------------- Runtime settings (persisted in flash) ----------------
String   cfgText        = "CIRCUIT SCHOOLS";
String   cfgMode        = "text";        // text | clock | effect
uint8_t  cfgEffect      = 0;             // 0 Rainbow, 1 Twinkle, 2 Sweep, 3 Breathing
uint32_t cfgColor1      = 0x00FF66;
uint32_t cfgColor2      = 0x0066FF;
bool     cfgGradient    = false;
uint8_t  cfgBrightness  = 60;            // 0-255, kept modest by default for safety
uint16_t cfgScrollDelay = 60;            // ms per column shift (lower = faster)
uint8_t  cfgModules     = 3;
String   cfgTZ          = "IST-5:30";
String   cfgTZLabel     = "India Standard Time (Kolkata)";

int activeWidth  = cfgModules * MODULE_COLS;  // visible width in pixels (clips unused modules)
int scrollX = 0;
unsigned long lastScroll = 0;
unsigned long lastEffect = 0;
unsigned long lastClockUpdate = 0;
String clockString = "";

// ---------------- Settings persistence ----------------
void loadSettings() {
  cfgText        = prefs.getString("text", cfgText);
  cfgMode        = prefs.getString("mode", cfgMode);
  cfgEffect      = prefs.getUChar("effect", cfgEffect);
  cfgColor1      = prefs.getUInt("color1", cfgColor1);
  cfgColor2      = prefs.getUInt("color2", cfgColor2);
  cfgGradient    = prefs.getBool("gradient", cfgGradient);
  cfgBrightness  = prefs.getUChar("bright", cfgBrightness);
  cfgScrollDelay = prefs.getUShort("speed", cfgScrollDelay);
  cfgModules     = prefs.getUChar("modules", cfgModules);
  cfgTZ          = prefs.getString("tz", cfgTZ);
  cfgTZLabel     = prefs.getString("tzlabel", cfgTZLabel);
}

// ---------------- Rendering ----------------
void renderTextFrame(const String &text) {
  matrix->fillScreen(0);
  matrix->setCursor(activeWidth - scrollX, 0);

  uint8_t r1 = (cfgColor1 >> 16) & 0xFF, g1 = (cfgColor1 >> 8) & 0xFF, b1 = cfgColor1 & 0xFF;
  matrix->setTextColor(matrix->Color(r1, g1, b1));

  matrix->gradientOn = cfgGradient;
  matrix->gStart = cfgColor1;
  matrix->gEnd   = cfgColor2;
  matrix->gWidth = activeWidth;

  matrix->print(text);
  matrix->show();
}

uint32_t wheel(uint8_t pos) {
  pos = 255 - pos;
  if (pos < 85)  return matrix->Color(255 - pos * 3, 0, pos * 3);
  if (pos < 170) { pos -= 85; return matrix->Color(0, pos * 3, 255 - pos * 3); }
  pos -= 170; return matrix->Color(pos * 3, 255 - pos * 3, 0);
}

void renderEffectFrame() {
  matrix->gradientOn = false; // effects compute their own colors directly
  matrix->fillScreen(0);
  unsigned long t = millis();

  switch (cfgEffect) {
    case 0: { // Rainbow wave
      for (int x = 0; x < activeWidth; x++) {
        for (int y = 0; y < MODULE_ROWS; y++) {
          uint8_t hue = (uint8_t)((x * 8 + y * 8 + t / 8) & 0xFF);
          matrix->drawPixel(x, y, wheel(hue));
        }
      }
      break;
    }
    case 1: { // Twinkle
      uint8_t r1 = (cfgColor1 >> 16) & 0xFF, g1 = (cfgColor1 >> 8) & 0xFF, b1 = cfgColor1 & 0xFF;
      int sparkles = (activeWidth * MODULE_ROWS) / 6;
      for (int i = 0; i < sparkles; i++) {
        int x = random(activeWidth), y = random(MODULE_ROWS);
        matrix->drawPixel(x, y, matrix->Color(r1, g1, b1));
      }
      break;
    }
    case 2: { // Sweep - fading vertical bar
      uint8_t r1 = (cfgColor1 >> 16) & 0xFF, g1 = (cfgColor1 >> 8) & 0xFF, b1 = cfgColor1 & 0xFF;
      int pos = (t / 40) % (activeWidth + 8);
      for (int trail = 0; trail < 8; trail++) {
        int x = pos - trail;
        if (x >= 0 && x < activeWidth) {
          uint8_t fade = 255 - trail * 32;
          uint32_t c = matrix->Color(r1 * fade / 255, g1 * fade / 255, b1 * fade / 255);
          for (int y = 0; y < MODULE_ROWS; y++) matrix->drawPixel(x, y, c);
        }
      }
      break;
    }
    case 3: { // Breathing
      float phase = (sin(t / 500.0) + 1.0) / 2.0;
      uint8_t r1 = (cfgColor1 >> 16) & 0xFF, g1 = (cfgColor1 >> 8) & 0xFF, b1 = cfgColor1 & 0xFF;
      uint32_t c = matrix->Color((uint8_t)(r1 * phase), (uint8_t)(g1 * phase), (uint8_t)(b1 * phase));
      for (int x = 0; x < activeWidth; x++)
        for (int y = 0; y < MODULE_ROWS; y++)
          matrix->drawPixel(x, y, c);
      break;
    }
  }
  matrix->show();
}

void updateClockString() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) { clockString = "TIME SYNC..."; return; }
  char buf[32];
  strftime(buf, sizeof(buf), "%d-%m-%Y  %H:%M:%S", &timeinfo);
  clockString = String(buf);
}

void handleDisplay() {
  if (cfgMode == "effect") {
    if (millis() - lastEffect >= 30) { lastEffect = millis(); renderEffectFrame(); }
    return;
  }

  if (cfgMode == "clock" && millis() - lastClockUpdate >= 1000) {
    lastClockUpdate = millis();
    updateClockString();
  }

  if (millis() - lastScroll >= cfgScrollDelay) {
    lastScroll = millis();
    String activeText = (cfgMode == "clock") ? clockString : cfgText;

    int16_t x1, y1; uint16_t tw, th;
    matrix->getTextBounds(activeText, 0, 0, &x1, &y1, &tw, &th);

    scrollX++;
    if (scrollX > (int)tw + activeWidth) scrollX = 0;

    renderTextFrame(activeText);
  }
}

// ---------------- Web server ----------------
String escapeJson(String s) {
  s.replace("\\", "\\\\");
  s.replace("\"", "\\\"");
  return s;
}

void handleStatus() {
  char colorBuf[8];
  String json = "{";
  json += "\"text\":\"" + escapeJson(cfgText) + "\",";
  json += "\"mode\":\"" + cfgMode + "\",";
  json += "\"effect\":" + String(cfgEffect) + ",";
  sprintf(colorBuf, "%06X", cfgColor1); json += "\"color1\":\"" + String(colorBuf) + "\",";
  sprintf(colorBuf, "%06X", cfgColor2); json += "\"color2\":\"" + String(colorBuf) + "\",";
  json += "\"gradient\":" + String(cfgGradient ? "true" : "false") + ",";
  json += "\"brightness\":" + String(cfgBrightness) + ",";
  json += "\"speed\":" + String(cfgScrollDelay) + ",";
  json += "\"modules\":" + String(cfgModules) + ",";
  json += "\"tz\":\"" + cfgTZ + "\",";
  json += "\"ip\":\"" + WiFi.localIP().toString() + "\"";
  json += "}";
  server.send(200, "application/json", json);
}

void handleSet() {
  bool tzChanged = false, modulesChanged = false;

  if (server.hasArg("text"))       { cfgText = server.arg("text"); prefs.putString("text", cfgText); scrollX = 0; }
  if (server.hasArg("mode"))       { cfgMode = server.arg("mode"); prefs.putString("mode", cfgMode); scrollX = 0; }
  if (server.hasArg("effect"))     { cfgEffect = server.arg("effect").toInt(); prefs.putUChar("effect", cfgEffect); }
  if (server.hasArg("color1"))     { cfgColor1 = strtoul(server.arg("color1").c_str(), NULL, 16); prefs.putUInt("color1", cfgColor1); }
  if (server.hasArg("color2"))     { cfgColor2 = strtoul(server.arg("color2").c_str(), NULL, 16); prefs.putUInt("color2", cfgColor2); }
  if (server.hasArg("gradient"))   { cfgGradient = server.arg("gradient") == "1"; prefs.putBool("gradient", cfgGradient); }
  if (server.hasArg("brightness")) { cfgBrightness = server.arg("brightness").toInt(); prefs.putUChar("bright", cfgBrightness); matrix->setBrightness(cfgBrightness); }
  if (server.hasArg("speed"))      { cfgScrollDelay = server.arg("speed").toInt(); prefs.putUShort("speed", cfgScrollDelay); }

  if (server.hasArg("modules")) {
    uint8_t m = server.arg("modules").toInt();
    if (m >= 1 && m <= MAX_MODULES && m != cfgModules) {
      cfgModules = m; prefs.putUChar("modules", cfgModules);
      modulesChanged = true;
    }
  }

  if (server.hasArg("tz") && server.hasArg("tzlabel")) {
    cfgTZ = server.arg("tz");
    cfgTZLabel = server.arg("tzlabel");
    prefs.putString("tz", cfgTZ);
    prefs.putString("tzlabel", cfgTZLabel);
    tzChanged = true;
  }

  if (modulesChanged) {
    activeWidth = cfgModules * MODULE_COLS;
    scrollX = 0;
    matrix->fillScreen(0);
    matrix->show();
  }

  if (tzChanged) {
    configTzTime(cfgTZ.c_str(), "pool.ntp.org", "time.nist.gov");
  }

  server.send(200, "text/plain", "OK");
}

void handleResetWifi() {
  server.send(200, "text/plain", "Wi-Fi settings cleared. Restarting...");
  delay(500);
  WiFiManager wm;
  wm.resetSettings();
  ESP.restart();
}

// ---------------- BOOT button Wi-Fi reset ----------------
void checkBootButton() {
  if (digitalRead(BOOT_BUTTON_PIN) == LOW) {
    if (!buttonHeld) {
      buttonHeld = true;
      buttonPressStart = millis();
    } else if (millis() - buttonPressStart >= WIFI_RESET_HOLD_MS) {
      Serial.println("BOOT button held 5s - clearing Wi-Fi settings...");

      // Flash the matrix red as visual confirmation before restarting.
      matrix->fillScreen(matrix->Color(255, 0, 0));
      matrix->show();
      delay(400);
      matrix->fillScreen(0);
      matrix->show();

      WiFiManager wm;
      wm.resetSettings();
      delay(200);
      ESP.restart();
    }
  } else {
    buttonHeld = false;
  }
}

void handleRoot() {
  server.send(200, "text/html", PAGE_HTML);
}

// ---------------- HTML dashboard ----------------
const char PAGE_HTML[] PROGMEM = R"HTMLPAGE(
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32 LED Matrix Control Panel</title>
<style>
  body{background:#0f1117;color:#eee;font-family:Segoe UI,Arial,sans-serif;margin:0;padding:20px;}
  .card{background:#1a1d27;border-radius:12px;padding:20px;max-width:480px;margin:0 auto 16px;box-shadow:0 4px 12px rgba(0,0,0,.4);}
  h1{font-size:20px;text-align:center;margin-top:0;color:#5ee6a0;}
  label{display:block;margin:14px 0 6px;font-size:14px;color:#aab;}
  input[type=text],select{width:100%;padding:8px;border-radius:6px;border:1px solid #333;background:#0f1117;color:#eee;box-sizing:border-box;}
  input[type=color]{width:60px;height:36px;border:none;vertical-align:middle;}
  input[type=range]{width:100%;}
  .row{display:flex;gap:14px;align-items:center;}
  .modes label{display:inline-block;margin-right:14px;font-size:14px;color:#eee;}
  button{width:100%;padding:12px;margin-top:18px;border:none;border-radius:8px;background:#5ee6a0;color:#0f1117;font-weight:bold;font-size:15px;cursor:pointer;}
  button.secondary{background:#3a3f52;color:#eee;margin-top:8px;}
  #msg{text-align:center;color:#5ee6a0;font-size:13px;height:16px;}
  .ip{text-align:center;font-size:12px;color:#889;margin-top:-6px;}
</style>
</head>
<body>
<div class="card">
  <h1>ESP32 LED Matrix</h1>
  <div class="ip">Device IP: <span id="ipLabel">-</span></div>

  <label>Scrolling Text</label>
  <input type="text" id="text" maxlength="120" placeholder="Type your message">

  <label>Display Mode</label>
  <div class="modes">
    <label><input type="radio" name="mode" value="text"> Text</label>
    <label><input type="radio" name="mode" value="clock"> Clock</label>
    <label><input type="radio" name="mode" value="effect"> Effects</label>
  </div>

  <label>Effect (used in Effects mode)</label>
  <select id="effect">
    <option value="0">Rainbow Wave</option>
    <option value="1">Twinkle</option>
    <option value="2">Sweep</option>
    <option value="3">Breathing</option>
  </select>

  <label>Text / Effect Color &amp; Gradient</label>
  <div class="row">
    <input type="color" id="color1" value="#00ff66">
    <input type="color" id="color2" value="#0066ff">
    <label style="margin:0;"><input type="checkbox" id="gradient"> Enable gradient</label>
  </div>

  <label>Brightness</label>
  <input type="range" id="brightness" min="5" max="255">

  <label>Scroll Speed (lower = faster)</label>
  <input type="range" id="speed" min="15" max="200">

  <label>Number of 8x8 Modules Connected</label>
  <select id="modules">
    <option value="1">1 module (8x8)</option>
    <option value="2">2 modules (16x8)</option>
    <option value="3">3 modules (24x8)</option>
    <option value="4">4 modules (32x8)</option>
    <option value="5">5 modules (40x8)</option>
  </select>

  <label>Timezone (for Clock mode)</label>
  <select id="tz">
    <option value="UTC0">UTC</option>
    <option value="IST-5:30">India Standard Time (Kolkata)</option>
    <option value="NPT-5:45">Nepal Time (Kathmandu)</option>
    <option value="PKT-5">Pakistan Time (Karachi)</option>
    <option value="BDT-6">Bangladesh Time (Dhaka)</option>
    <option value="WIB-7">Indonesia Western (Jakarta)</option>
    <option value="&lt;+08&gt;-8">Singapore / Malaysia / China (UTC+8)</option>
    <option value="JST-9">Japan Standard Time (Tokyo)</option>
    <option value="KST-9">Korea Standard Time (Seoul)</option>
    <option value="AEST-10AEDT,M10.1.0,M4.1.0/3">Australia Eastern (Sydney)</option>
    <option value="NZST-12NZDT,M9.5.0,M4.1.0/3">New Zealand (Auckland)</option>
    <option value="&lt;+04&gt;-4">Gulf Standard Time (Dubai)</option>
    <option value="CET-1CEST,M3.5.0,M10.5.0/3">Central Europe (Berlin/Paris)</option>
    <option value="EET-2EEST,M3.5.0/3,M10.5.0/4">Eastern Europe (Athens/Cairo)</option>
    <option value="GMT0BST,M3.5.0/1,M10.5.0">UK / Ireland (London)</option>
    <option value="EST5EDT,M3.2.0,M11.1.0">US Eastern (New York)</option>
    <option value="CST6CDT,M3.2.0,M11.1.0">US Central (Chicago)</option>
    <option value="MST7MDT,M3.2.0,M11.1.0">US Mountain (Denver)</option>
    <option value="PST8PDT,M3.2.0,M11.1.0">US Pacific (Los Angeles)</option>
    <option value="&lt;-03&gt;3">Brazil (Sao Paulo)</option>
  </select>

  <button onclick="apply()">Apply Settings</button>
  <button class="secondary" onclick="if(confirm('Forget saved Wi-Fi and restart?'))fetch('/resetwifi')">Forget Wi-Fi</button>
  <div id="msg"></div>
</div>

<script>
function apply(){
  const tzSel = document.getElementById('tz');
  const p = new URLSearchParams({
    text: document.getElementById('text').value,
    mode: document.querySelector('input[name=mode]:checked').value,
    effect: document.getElementById('effect').value,
    color1: document.getElementById('color1').value.replace('#',''),
    color2: document.getElementById('color2').value.replace('#',''),
    gradient: document.getElementById('gradient').checked ? '1' : '0',
    brightness: document.getElementById('brightness').value,
    speed: document.getElementById('speed').value,
    modules: document.getElementById('modules').value,
    tz: tzSel.value,
    tzlabel: tzSel.options[tzSel.selectedIndex].text
  });
  fetch('/set?' + p.toString()).then(()=>{
    document.getElementById('msg').textContent = 'Settings applied';
    setTimeout(()=>document.getElementById('msg').textContent='', 2000);
  });
}

fetch('/status').then(r=>r.json()).then(d=>{
  document.getElementById('text').value = d.text;
  const modeInput = document.querySelector('input[name=mode][value="'+d.mode+'"]');
  if(modeInput) modeInput.checked = true;
  document.getElementById('effect').value = d.effect;
  document.getElementById('color1').value = '#'+d.color1;
  document.getElementById('color2').value = '#'+d.color2;
  document.getElementById('gradient').checked = d.gradient;
  document.getElementById('brightness').value = d.brightness;
  document.getElementById('speed').value = d.speed;
  document.getElementById('modules').value = d.modules;
  document.getElementById('tz').value = d.tz;
  document.getElementById('ipLabel').textContent = d.ip;
});
</script>
</body>
</html>
)HTMLPAGE";

// ---------------- Setup / Loop ----------------
void setup() {
  Serial.begin(115200);
  pinMode(BOOT_BUTTON_PIN, INPUT_PULLUP);

  matrix = new GradientMatrix(MODULE_COLS, MODULE_ROWS, MAX_MODULES, 1, DATA_PIN,
                               PANEL_ORIENTATION + TILE_ORIENTATION,
                               NEO_GRB + NEO_KHZ800);
  matrix->begin();
  matrix->setTextWrap(false);
  matrix->fillScreen(0);
  matrix->show();

  prefs.begin("ledcfg", false);
  loadSettings();
  matrix->setBrightness(cfgBrightness);

  WiFiManager wm;
  wm.setConfigPortalTimeout(180);
  bool connected = wm.autoConnect("LED-Matrix-Setup");
  if (!connected) {
    Serial.println("Wi-Fi setup timed out. Restarting...");
    ESP.restart();
  }
  Serial.print("Connected. IP address: ");
  Serial.println(WiFi.localIP());

  if (MDNS.begin(MDNS_HOSTNAME)) {
    MDNS.addService("http", "tcp", 80);
    Serial.print("mDNS responder started: http://");
    Serial.print(MDNS_HOSTNAME);
    Serial.println(".local");
  } else {
    Serial.println("mDNS setup failed - use the IP address above instead.");
  }

  configTzTime(cfgTZ.c_str(), "pool.ntp.org", "time.nist.gov");

  activeWidth = cfgModules * MODULE_COLS;

  server.on("/", HTTP_GET, handleRoot);
  server.on("/status", HTTP_GET, handleStatus);
  server.on("/set", HTTP_GET, handleSet);
  server.on("/resetwifi", HTTP_GET, handleResetWifi);
  server.begin();
}

void loop() {
  server.handleClient();
  checkBootButton();
  handleDisplay();
}

 

Uploading & Board Settings:

  • Board: select your exact ESP32 board (e.g. “ESP32 Dev Module”)
  • Partition Scheme: Default (no filesystem upload needed — the dashboard is embedded in the sketch itself)
  • Upload Speed: 921600 works fine on most boards; drop to 115200 if you get upload errors
  • Keep the matrix disconnected from external power while uploading over USB — power it up only after the upload finishes and you’re ready to test
  • The BOOT button doubles as the Wi-Fi reset button in this project’s normal running mode — it only enters flashing mode if held while the board is powered on or reset, so there’s no conflict during everyday use.

Working of the ESP32 LED Matrix Web Server:

ESP32 WS2812B 8×8 LED Matrix Display with Web Server output

First boot — Wi-Fi setup: Power the ESP32. Since no Wi-Fi credentials are saved yet, it starts a Wi-Fi access point called LED-Matrix-Setup. Connect to it from your phone, choose your home network from the list, enter your password, and save. The ESP32 reconnects to your router and prints its IP address to the Serial Monitor at 115200 baud.

Everyday use: Open that IP address in any browser on the same network — this loads the control panel shown above. Type a message and hit Apply Settings to see it scroll across the matrix immediately. Switch Display Mode to Clock to show the live time and date instead (pick your timezone from the dropdown first — the ESP32 fetches accurate time over NTP and applies the correct UTC offset and daylight-saving rules automatically). Switch to Effects to run one of the four built-in animations instead of text.

Turn on Enable gradient and pick two colors to have your scrolling text fade smoothly from the first color to the second across the width of the display — great for a two-tone shop sign or a “cool blue to warm orange” sunset look.

If you add more matrix panels later, just update the Number of 8×8 Modules Connected dropdown and hit Apply — no re-flashing required.


Fixing a Mirrored or Scrambled Display:

This is the single most common issue with chained WS2812B matrices, and it’s a wiring-convention problem, not a bug. Different manufacturers wire the internal serpentine pattern starting from different corners. To calibrate:

  1. Upload the code, set Mode → Effects → Rainbow Wave, and watch the panels.
  2. If the colors sweep smoothly and continuously from the leftmost panel to the rightmost panel, you’re done.
  3. If instead each panel shows its own repeating rainbow, or the pattern looks vertically flipped, edit the three MATRIX_* flags near the top of the sketch (MATRIX_SERPENTINE, MATRIX_START_BOTTOM, MATRIX_START_RIGHT), re-upload, and check again. There are only 8 possible combinations, so you’ll find the right one within a few tries.

Resetting Wi-Fi With the BOOT Button:

The dashboard’s “Forget Wi-Fi” button only works while the ESP32 is already online and you can reach its control panel. That’s not much help if you’ve moved the sign to a new house, given it away, or it simply can’t reach your router anymore. For exactly that situation, this project also wires the Wi-Fi reset into the ESP32’s onboard BOOT button (GPIO0) — no extra components required, since every ESP32 dev board already has this button for entering flashing mode.

Here’s how it works in the code:

  • The button is read every loop iteration with digitalRead(). Since BOOT is wired active-LOW with an onboard pull-up, pinMode(BOOT_BUTTON_PIN, INPUT_PULLUP) is all the setup it needs — no external resistor.
  • The instant the button goes LOW, a timestamp is stored. As long as you keep holding it, the code keeps comparing millis() against that timestamp.
  • At the 5-second mark, the matrix flashes red as visual confirmation, the saved Wi-Fi credentials are wiped with WiFiManager::resetSettings(), and the ESP32 restarts straight back into the LED-Matrix-Setup captive portal — exactly like a fresh, out-of-the-box boot.
  • Letting go before 5 seconds does nothing, so a quick accidental bump of the button is completely harmless.

To use it: press and hold the BOOT button on the ESP32 board itself (not a button on the matrix — matrices don’t have one) for a full 5 seconds. Watch for the matrix to flash red, then release. The board reboots and starts broadcasting the LED-Matrix-Setup access point again, ready to be connected to a new network.


Build a Stunning LED Matrix Display with PCBWay—Custom PCBs & 3D-Printed Enclosures for Your ESP32 & WS2812B Project

Create a mesmerizing 8×8 LED matrix display with scrolling text, real-time clock, dynamic colors, and animated effects—all controllable from a web server. PCBWay’s integrated manufacturing services help you transform your ESP32 and WS2812B prototype into a polished, professional display system capable of chaining up to 5 modules.

Custom PCB Manufacturing for High-Power LED Control:
PCBWay manufactures high-quality custom PCBs that consolidate your ESP32, level shifting circuitry, power distribution, and connector interfaces onto a single compact board. Their PCBs feature robust power traces to handle the current demands of up to 320 LEDs (5 modules × 64 LEDs), clean data lines with proper impedance control for stable signal transmission across chained modules, and dedicated capacitor placements for power smoothing. Choose professional SMT assembly to receive a fully populated, tested board ready for web server deployment.

Custom 3D-Printed Display Enclosures:
Design and order a professional display housing through PCBWay’s advanced 3D printing service. Create a sleek, bezel-style enclosure using premium PLA+ or ABS, featuring precisely aligned openings for each LED matrix panel, a diffuser layer for uniform light distribution, and mounting points for wall or desktop installation. Add cable management channels for daisy-chaining multiple modules and secure PCB mounting bosses—all printed with precision for a commercial-grade appearance.

Why PCBWay Delivers Professional Results:

  • Reliable Performance: Robust PCB design handles high-current LED demands without voltage drops
  • Visual Excellence: Custom enclosures with diffusers create professional display quality
  • Complete Solution: From prototype to a stunning, ready-to-install display

Build Your LED Matrix Display Today:
Upload your PCB design and enclosure model for instant quotes, DFM feedback, and professional manufacturing only at PCBWAY.COM


Frequently Asked Questions:

Can I use a WS2812B LED strip instead of matrix modules? Not directly with this code — the XY() mapping function assumes 8×8 panels chained horizontally. A strip would need a different, simpler mapping (no row/column logic needed), which is a good beginner modification if you want to try it.

Does this work on ESP8266 instead of ESP32? Mostly yes — WiFiManager, Adafruit NeoPixel, and Adafruit GFX all support ESP8266. You’d need to swap WebServer.h for ESP8266WebServer.h and adjust the include for WiFi.h. ESP32 is recommended here for its extra RAM and processing headroom, especially once you scale up to 5 panels.

How many matrix modules can I really chain? This project caps it at 5 (40×8 pixels) to keep memory and power requirements predictable, but the MAX_MODULES constant can be raised if your power supply and Wi-Fi-connected ESP32 can handle the extra current and refresh load.

Why does the display flicker when Wi-Fi is busy? WS2812B protocol timing is sensitive to interrupts. Heavy Wi-Fi traffic can occasionally cause a visible flicker on large chains. Keeping brightness moderate and using a short, well-shielded data wire with the recommended resistor and capacitor minimizes this.

What if I forget to note the IP address, or the board won’t join Wi-Fi anymore? Hold the BOOT button on the ESP32 for 5 seconds. This clears the saved Wi-Fi credentials at the hardware level regardless of the network state, and the board restarts into its LED-Matrix-Setup access point so you can reconnect it from scratch.

Can I control it without opening the webpage every time? Yes — since settings are applied through simple GET requests to /set, you can trigger changes from any automation tool (like a phone shortcut, a home automation hub, or a script) by calling the same URL with your desired parameters.

1 comments
  1. This is a great program. I have learned a lot from this. It is the only working example I have found that uses preferences for storage with JavaScript to serve the data. The color picker is also very cool. I am running on an ESP-01S with no problems at all, but I removed the effects module and also took out the WifiManager

Leave a Reply

Your email address will not be published. Required fields are marked *