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 SOLVEby 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
- 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
| Qty | Part | Notes |
|---|---|---|
| 1 | Arduino Uno or Nano | 5 V board with hardware SPI on D11/D13. |
| 1 | 8×8 LED matrix, common-cathode rows (1088AS type) | 3 mm or 5 mm, 16 pins. |
| 2 | 74HC595 shift register (DIP-16) | Plus a 100 nF capacitor for each. |
| 1 | ULN2803A Darlington array (DIP-18) | Sinks the row current. |
| 8 | 330 Ω resistors | One per column. |
| — | Large breadboard or perfboard, jumper wires | About 40 connections — take your time. |
Wiring
| Connection | Wiring |
|---|---|
| Arduino D11 (MOSI) | 595 #1 DS (pin 14) |
| Arduino D13 (SCK) | SHCP (pin 11) on both 595s |
| Arduino D10 | STCP / latch (pin 12) on both 595s |
| 595 #1 Q7' (pin 9) | 595 #2 DS (pin 14) |
| Both 595s | VCC (16) → 5 V, GND (8) → GND, MR (10) → 5 V, OE (13) → GND, 100 nF from VCC to GND |
| 595 #1 Q0…Q7 | ULN2803 IN1…IN8 (pins 1–8) |
| ULN2803 OUT1…OUT8 (pins 18–11) | Matrix rows 1…8 (cathodes), top row first |
| ULN2803 | GND (pin 9) → GND; COM (pin 10) not connected |
| 595 #2 Q7…Q0 | 330 Ω → 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:
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();
}
}
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
- Map the matrix pins with a multimeter and label them.
- Wire one 595 with 8 LEDs (any LEDs + resistors) and test shifting a single byte — the classic first shift-register sketch.
- Add 595 #2 and the ULN2803, then the matrix. Wire the power pins and decoupling capacitors first, then the signals.
- Upload and check the orientation. Use the tip above if the text is mirrored.
- Move to perfboard once it works, with the matrix on female headers so it can be swapped.
Testing checklist
- Temporarily set every
frame[]byte to0xFF: 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_MSto taste. - The 74HC595s stay cool; current at the USB port is roughly 60–100 mA in total.
Results
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.