PRJ-006 Planned

LED Matrix Display

8x8 LED matrix controlled by shift registers with scrolling text animation.

The LED matrix on the EFury Labs home page is a web simulation — this project builds the real thing. A bare 8×8 LED matrix, two 74HC595 shift registers and a ULN2803 driver turn three Arduino pins into 64 individually controlled LEDs, scrolling text with no flicker. No display library: you write the multiplexing yourself, and see exactly how every LED display works.

What you'll build

  • Scrolls a message (EFURY LABS - THINK BUILD SOLVE by default) across an 8×8 matrix.
  • Uses only three Arduino pins (data, clock, latch) for all 64 LEDs.
  • Refreshes at about 83 Hz — fast enough that your eye sees a steady image.
  • A built-in 5×7 font for space, digits, punctuation and A–Z, in plain C you can edit.

How it works

Arduino SPI: data, clock + latch 74HC595 #1 selects one row ULN2803 sinks the row current 74HC595 #2 column pattern via 330 Ω 8x8 matrix rows scanned ~83 times/s
Two bytes per row, eight rows per frame, 83 frames per second.
  • Multiplexing: only one row is lit at any moment. The code selects row 0 and sets which of its 8 columns are on, waits 1.5 ms, then moves to row 1, and so on. Persistence of vision blends the eight rows into one picture.
  • Shift registers: a 74HC595 converts 8 serial bits into 8 parallel outputs. Chaining two (Q7' → DS) means the Arduino sends 16 bits, then pulses the latch so both chips update at the same instant.
  • Why the ULN2803? When a whole row is lit, up to 8 LEDs' current flows out through one row (cathode) pin. That's more than a 74HC595 pin should sink, so the ULN2803 Darlington array does it instead.
  • Hardware SPI clocks the bits out at 4 MHz, so each row update takes only a few microseconds.

Parts list

QtyPartNotes
1Arduino Uno or Nano5 V board with hardware SPI on D11/D13.
18×8 LED matrix, common-cathode rows (1088AS type)3 mm or 5 mm, 16 pins.
274HC595 shift register (DIP-16)Plus a 100 nF capacitor for each.
1ULN2803A Darlington array (DIP-18)Sinks the row current.
8330 Ω resistorsOne per column.
—Large breadboard or perfboard, jumper wiresAbout 40 connections — take your time.
Find your matrix's pins first. Matrix pinouts are scrambled, and they differ between manufacturers. Set a multimeter to diode test, touch the red probe to one pin and the black to another until a single LED glows: the red probe is on a column (anode) and the black on a row (cathode). Map all 16 pins on paper before you wire anything.

Wiring

8 8 8 8 Arduino Uno D11 D13 D10 5V GND 74HC595 #1 rows DS 14 SHCP 11 STCP 12 VCC 16 GND 8 Q7' 9 Q0–Q7 74HC595 #2 cols DS 14 SHCP 11 STCP 12 Q0–Q7 ULN2803 IN 1–8 OUT 1–8 8 × 330 Ω in out 8x8 matrix ROWS COLS Both 595s: MR (10) → 5V, OE (13) → GND, VCC/GND to 5V/GND, 100 nF across VCC–GND next to each chip. ULN2803: GND (9) → GND, COM (10) unconnected. "8" = eight parallel wires.
Data enters 595 #1 (rows) and continues through Q7' into 595 #2 (columns). Both chips share the clock and latch.
ConnectionWiring
Arduino D11 (MOSI)595 #1 DS (pin 14)
Arduino D13 (SCK)SHCP (pin 11) on both 595s
Arduino D10STCP / latch (pin 12) on both 595s
595 #1 Q7' (pin 9)595 #2 DS (pin 14)
Both 595sVCC (16) → 5 V, GND (8) → GND, MR (10) → 5 V, OE (13) → GND, 100 nF from VCC to GND
595 #1 Q0…Q7ULN2803 IN1…IN8 (pins 1–8)
ULN2803 OUT1…OUT8 (pins 18–11)Matrix rows 1…8 (cathodes), top row first
ULN2803GND (pin 9) → GND; COM (pin 10) not connected
595 #2 Q7…Q0330 Ω → matrix columns 1…8 (anodes): Q7 is the left column, Q0 the right

74HC595 pin map: Q1–Q7 are pins 1–7, Q0 is pin 15. The notch marks the pin 1 end.

Current budget

With a typical red LED (≈ 2 V forward voltage) and a 74HC595 output dropping a little under load:

ILED ≈ (5 V − 2 V − ~0.9 V) / 330 Ω ≈ 6.4 mA

A fully lit row draws 8 × 6.4 ≈ 51 mA, shared across the eight column outputs of 595 #2 — within the chip's 70 mA total supply-current rating. The ULN2803 handles the 51 mA row current easily. Because each row is lit only ⅛ of the time, the matrix looks dimmer than a single LED at 6.4 mA. That's normal for multiplexing. Check other resistor values with the Ohm's law tool, and don't go below about 220 Ω.

The code

No libraries needed: SPI is built into the Arduino core. Change MESSAGE to your own text (A–Z, digits and basic punctuation).

/*  EFury Labs — PRJ-006 LED Matrix Display
    Arduino Uno/Nano + 8x8 LED matrix + 2x 74HC595 + ULN2803, scrolling text.
    Chain: D11 (MOSI) → 595 #1 (rows → ULN2803) → Q7' → 595 #2 (columns → 330 Ω → anodes)
    No extra libraries.                                                                   */

#include <SPI.h>

const uint8_t LATCH_PIN = 10;              // 74HC595 RCLK (both chips)
const char MESSAGE[] = "EFURY LABS - THINK BUILD SOLVE   ";
const uint16_t SCROLL_MS = 90;             // lower = faster
const uint16_t ROW_US    = 1500;           // 8 rows x 1.5 ms ≈ 83 Hz refresh (no flicker)

// 5x7 font, ASCII 32 (' ') to 90 ('Z'). One byte per column, bit 0 = top row.
const uint8_t FONT[][5] PROGMEM = {
  {0x00, 0x00, 0x00, 0x00, 0x00},  // ' '
  {0x00, 0x00, 0x5F, 0x00, 0x00},  // '!'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '"'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '#'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '$'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '%'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '&'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // "'"
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '('
  {0x00, 0x00, 0x00, 0x00, 0x00},  // ')'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '*'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '+'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // ','
  {0x08, 0x08, 0x08, 0x08, 0x08},  // '-'
  {0x00, 0x60, 0x60, 0x00, 0x00},  // '.'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '/'
  {0x3E, 0x51, 0x49, 0x45, 0x3E},  // '0'
  {0x00, 0x42, 0x7F, 0x40, 0x00},  // '1'
  {0x42, 0x61, 0x51, 0x49, 0x46},  // '2'
  {0x21, 0x41, 0x45, 0x4B, 0x31},  // '3'
  {0x18, 0x14, 0x12, 0x7F, 0x10},  // '4'
  {0x27, 0x45, 0x45, 0x45, 0x39},  // '5'
  {0x3C, 0x4A, 0x49, 0x49, 0x30},  // '6'
  {0x01, 0x71, 0x09, 0x05, 0x03},  // '7'
  {0x36, 0x49, 0x49, 0x49, 0x36},  // '8'
  {0x06, 0x49, 0x49, 0x29, 0x1E},  // '9'
  {0x00, 0x36, 0x36, 0x00, 0x00},  // ':'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // ';'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '<'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '='
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '>'
  {0x02, 0x01, 0x51, 0x09, 0x06},  // '?'
  {0x00, 0x00, 0x00, 0x00, 0x00},  // '@'
  {0x7E, 0x09, 0x09, 0x09, 0x7E},  // 'A'
  {0x7F, 0x49, 0x49, 0x49, 0x36},  // 'B'
  {0x3E, 0x41, 0x41, 0x41, 0x22},  // 'C'
  {0x7F, 0x41, 0x41, 0x41, 0x3E},  // 'D'
  {0x7F, 0x49, 0x49, 0x49, 0x41},  // 'E'
  {0x7F, 0x09, 0x09, 0x09, 0x01},  // 'F'
  {0x3E, 0x41, 0x49, 0x49, 0x7A},  // 'G'
  {0x7F, 0x08, 0x08, 0x08, 0x7F},  // 'H'
  {0x00, 0x41, 0x7F, 0x41, 0x00},  // 'I'
  {0x20, 0x40, 0x41, 0x3F, 0x01},  // 'J'
  {0x7F, 0x08, 0x14, 0x22, 0x41},  // 'K'
  {0x7F, 0x40, 0x40, 0x40, 0x40},  // 'L'
  {0x7F, 0x02, 0x0C, 0x02, 0x7F},  // 'M'
  {0x7F, 0x04, 0x08, 0x10, 0x7F},  // 'N'
  {0x3E, 0x41, 0x41, 0x41, 0x3E},  // 'O'
  {0x7F, 0x09, 0x09, 0x09, 0x06},  // 'P'
  {0x3E, 0x41, 0x51, 0x21, 0x5E},  // 'Q'
  {0x7F, 0x09, 0x19, 0x29, 0x46},  // 'R'
  {0x46, 0x49, 0x49, 0x49, 0x31},  // 'S'
  {0x01, 0x01, 0x7F, 0x01, 0x01},  // 'T'
  {0x3F, 0x40, 0x40, 0x40, 0x3F},  // 'U'
  {0x1F, 0x20, 0x40, 0x20, 0x1F},  // 'V'
  {0x3F, 0x40, 0x38, 0x40, 0x3F},  // 'W'
  {0x63, 0x14, 0x08, 0x14, 0x63},  // 'X'
  {0x03, 0x04, 0x78, 0x04, 0x03},  // 'Y'
  {0x61, 0x51, 0x49, 0x45, 0x43},  // 'Z'

};

uint8_t frame[8];                          // frame[row]: bit 7 = left column
uint16_t offset = 0, totalCols = 0;

// Column c of the whole message (5 columns per letter + 1 blank)
uint8_t messageColumn(uint16_t c) {
  uint16_t ch = c / 6, x = c % 6;
  if (x == 5) return 0;
  char k = toupper(MESSAGE[ch]);
  if (k < 32 || k > 90) k = ' ';
  return pgm_read_byte(&FONT[k - 32][x]);
}

void buildFrame() {
  for (uint8_t r = 0; r < 8; r++) frame[r] = 0;
  for (uint8_t x = 0; x < 8; x++) {
    uint8_t col = messageColumn((offset + x) % totalCols);
    for (uint8_t r = 0; r < 7; r++)
      if (col & (1 << r)) frame[r] |= (0x80 >> x);
  }
}

void writeRow(uint8_t row, uint8_t columns) {
  digitalWrite(LATCH_PIN, LOW);
  SPI.transfer(columns);                   // first byte travels on to 595 #2 (columns)
  SPI.transfer(1 << row);                  // second byte stays in 595 #1 (row select)
  digitalWrite(LATCH_PIN, HIGH);           // rising edge copies both to the outputs
}

void setup() {
  pinMode(LATCH_PIN, OUTPUT);
  SPI.begin();
  SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
  totalCols = (sizeof(MESSAGE) - 1) * 6;
  buildFrame();
}

void loop() {
  static uint8_t row = 0;
  static unsigned long lastRow = 0, lastScroll = 0;
  unsigned long now = micros();

  if (now - lastRow >= ROW_US) {           // multiplex: one row at a time
    lastRow = now;
    writeRow(row, 0);                      // blank first → no ghosting between rows
    writeRow(row, frame[row]);
    row = (row + 1) & 7;
  }
  if (millis() - lastScroll >= SCROLL_MS) {
    lastScroll = millis();
    offset = (offset + 1) % totalCols;
    buildFrame();
  }
}
Text mirrored or upside down? Matrices and wiring vary, so fix the orientation in code rather than rewiring. Mirrored (scrolling the wrong way): in buildFrame(), change 0x80 >> x to 0x01 << x. Upside down: in writeRow(), change 1 << row to 0x80 >> row. Don't switch SPI to LSBFIRST: that reverses both bytes and rotates the image 180°.

Build steps

  1. Map the matrix pins with a multimeter and label them.
  2. Wire one 595 with 8 LEDs (any LEDs + resistors) and test shifting a single byte — the classic first shift-register sketch.
  3. Add 595 #2 and the ULN2803, then the matrix. Wire the power pins and decoupling capacitors first, then the signals.
  4. Upload and check the orientation. Use the tip above if the text is mirrored.
  5. Move to perfboard once it works, with the matrix on female headers so it can be swapped.

Testing checklist

  • Temporarily set every frame[] byte to 0xFF: all 64 LEDs should light evenly. A dark row or column points to a wiring fault.
  • No ghosting: unlit LEDs next to lit ones stay fully dark.
  • No visible flicker, including in your peripheral vision. If it flickers, lower ROW_US.
  • Smooth scroll speed. Adjust SCROLL_MS to taste.
  • The 74HC595s stay cool; current at the USB port is roughly 60–100 mA in total.

Results

Build log

Status: planned. Photos, a slow-motion clip of the multiplexing, the measured current draw and the video will be added after the build.

Ideas for version 2

  • Chain more matrices by adding a column 595 per matrix — the rows stay shared.
  • Brightness control by PWM on the 595s' OE pin.
  • Serial input: type a new message in the Serial Monitor.
  • Replace the 595s with a MAX7219 — and compare how much code disappears.
  • WS2812B RGB matrix like the one simulated on the home page.
← All projects