Hardware: Smallsign reference build
The smallsign is led-ticker’s reference build for short single-line
displays: a Raspberry Pi 4 driving five chained 32x16 RGB matrix panels
through Adafruit’s RGB matrix bonnet for a 160x16 canvas. It’s the
target the project was originally written against, and the dimensions
the smallsign-flavored examples in config/ assume. You don’t have to
match it exactly — any HUB75 panel chain that fits a similar shape
(Pi 4 with the adafruit-hat mapping, 16-tall canvas) will run the same
configs — but the parts list and tuning notes below are what we built
and tested against.
At a glance
Section titled “At a glance”- Raspberry Pi 4 Model B
- 5x chained 32x16 RGB matrix panels = 160x16 logical canvas
- ~20 fps (50ms per frame)
default_scale = 1— drawing logic and panel pixels are 1:1- Same Docker image as the bigsign reference build; the rgbmatrix library detects the SoC at runtime
Bill of materials
Section titled “Bill of materials”| Component | Quantity | Notes |
|---|---|---|
| Raspberry Pi 4 Model B | 1 | 2 GB is plenty. A microSD card and a USB-C power supply for the Pi itself. |
| 32x16 RGB matrix panel | 5 | HUB75 panels with the standard 16-pin IDC connector. Matched panels render most consistently. |
| Adafruit RGB matrix bonnet | 1 | Sets hardware_mapping = "adafruit-hat" in the config. Bonnet and HAT are Adafruit’s two names for boards that do the same job — plug onto the Pi’s GPIO header and drive HUB75 panels; the bonnet is the low-profile option. |
| 5V power supply | 1 | Size for the chain — five 32x16 panels at full white draw several amps. A 5V / 10A brick is a comfortable headroom pick. Do NOT power the panels off the Pi’s USB rail. |
| 16-pin IDC ribbon cables | 4 | Flat ribbon cable with HUB75 plugs — carries data panel-to-panel. One between each pair of panels. Most panels ship with one short cable; you may need extras to reach all four gaps. |
| Power pigtails / barrel jack | 1+ | Whatever your panels expect. Each panel has its own 2-pin power input. |
| Shroud / case (optional) | 1 | Acrylic diffuser + a 3D-printed frame is the typical finish. Not required to run. |
Total cost (rough): ~$150–200 USD depending on panel sourcing and shipping. The Pi 4 is the largest single line item; the Adafruit bonnet or HAT runs $20–25; matched 32×16 panels run $15–25 each at retail (cheaper in lots from AliExpress). Cross-check current pricing before ordering — these numbers were verified in early 2026 and panel prices fluctuate.
The config tells the rgbmatrix library you’re using the bonnet/HAT via
hardware_mapping = "adafruit-hat". If you swap to a bare wiring harness,
that flag changes too — see the upstream hzeller/rpi-rgb-led-matrix
README (the base for the
jamesawesome/rpi-rgb-led-matrix
fork led-ticker ships) for alternatives.
Wiring
Section titled “Wiring”Before wiring, finish Adafruit’s own assembly steps for the bonnet — solder the GPIO header if yours shipped unsoldered (see Adafruit’s bonnet guide). That product prep is Adafruit’s territory; everything after it is covered here.
Chain the five panels left-to-right with the IDC ribbon cables. Chain order matters: the first panel in the chain (where the bonnet’s data output plugs in) is the LEFTMOST panel on screen, and the last panel in the chain is the RIGHTMOST. If your text shows up mirrored or split across the wrong panels, you’ve got the chain reversed.
Each panel has two HUB75 connectors, labeled IN and OUT on the silkscreen — data flows bonnet → panel 1 IN, then panel 1 OUT → panel 2 IN, and so on down the chain.
Each panel also needs its own 5V power lead — the data ribbon does not carry panel power. Most builds share one splitter or busbar from the 5V supply, with a short 2-pin lead to each panel’s power connector.
Seat the bonnet so its 40-pin socket covers all 40 pins of the Pi’s
GPIO header — it only fits flush one way, with the bonnet’s board
overhanging the Pi’s USB-port end when seated correctly. The bonnet’s
HUB75 output plugs into the first panel’s data input. The
chain_length field in the config (next section) tells led-ticker how
many panels are linked.
Config snippet
Section titled “Config snippet”The minimal [display] block for the smallsign — straight from
config/config.example.toml:
[display]rows = 16 # panel heightcols = 32 # panel widthchain_length = 5 # number of panels chained left-to-rightbrightness = 60 # 0-100, not 0-255gpio_slowdown = 2 # mandatory on Pi 4 — bump to 3+ if flickerhardware_mapping = "adafruit-hat" # Adafruit bonnet or HATThe 160x16 logical canvas is cols * chain_length wide by rows tall. Every
widget on the smallsign draws to that canvas at native pixel
resolution — no ScaledCanvas wrapper, no logical-to-physical block
expansion.
Sharp edges
Section titled “Sharp edges”gpio_slowdown is mandatory on the Pi 4
Section titled “gpio_slowdown is mandatory on the Pi 4”Without it (or with it set to 0) the rgbmatrix library updates the GPIO faster than the Pi 4 can keep up, and you’ll see panels glitch, flicker, or display garbage. Start at 2 — that’s the example config’s default — and bump it to 3 or higher if you still see flicker. Higher values trade refresh rate for stability.
Do not set default_scale to anything other than 1 here
Section titled “Do not set default_scale to anything other than 1 here”The smallsign is 16 logical rows tall and 16 physical rows tall — there is no logical-to-physical wrapping to do. The ScaledCanvas wrapper only kicks in when default_scale > 1 and assumes the panel is taller than 16 logical rows, so cranking it on the smallsign just clips your content.
Brightness is 0-100, not 0-255
Section titled “Brightness is 0-100, not 0-255”Setting brightness = 255 doesn’t make the panels brighter — the rgbmatrix library reads it as “the maximum, plus a lot more” and behavior is undefined. Stay in 0-100; 60 is a typical indoor value.
Wiring or driver problem? Run panel-test first
Section titled “Wiring or driver problem? Run panel-test first”If your widgets render with the wrong colors, only one panel lit, or visible flicker during scrolling, the issue is most likely in the hardware layer (led_rgb_sequence, chain_length, gpio_slowdown) — not in your config. The panel-test diagnostic isolates the hardware layer by painting flat R/G/B/W/B colors, so you can verify wiring before debugging widgets.
Reference config
Section titled “Reference config”A complete working config for this build. Drop this into config/config.toml
and adjust the per-widget content for your sign.
Complete config.example.toml (160×16 smallsign)
# led-ticker configuration — smallsign starter (160×16 canvas)## 5× chained 32×16 panels = 160×16 logical canvas. Driven by a Raspberry Pi 4# through an Adafruit RGB Matrix bonnet. No pixel_mapper_config needed — panels# are a single horizontal chain_length, left-to-right.## This starter runs entirely on BUILT-IN widgets — no plugins required, so it# boots straight from `make setup`. Want live data (weather, RSS, crypto, MLB# scores, pool temps)? Those are first-party plugins — see "Add live data with# plugins" near the bottom and https://docs.ledticker.dev/plugins/ .## Copy this to config.toml and adjust the playlist sections for your content.# Full config-options reference: https://docs.ledticker.dev/reference/config-options/
[display]rows = 16cols = 32chain_length = 5brightness = 60gpio_slowdown = 2 # mandatory on Pi 4 — bump to 3+ if you see flickerhardware_mapping = "adafruit-hat"# hot_reload = true # live-reload config.toml edits without a restart (default on; set false to disable)
# --- Optional: brightness schedule (uncomment + edit to use) ---# Example: full brightness by day, a softer evening, and a dim overnight# (10pm–7am, every day). Times are local wall-clock — set `timezone` or they# follow the Pi's system clock (often UTC). Any hour not covered by a window# uses the [display] `brightness` above.# [display.schedule]# enabled = true# timezone = "America/New_York" # set this! defaults to the Pi's system time (often UTC)## [[display.schedule.windows]]# start = "07:00"# end = "18:00"# brightness = 100## [[display.schedule.windows]]# start = "18:00"# end = "22:00"# brightness = 40## [[display.schedule.windows]] # dim overnight, 10pm → 7am, every day# start = "22:00" # 10pm — wraps past midnight to 7am# end = "07:00"# brightness = 15 # dim, not off (set brightness = 0 to switch the panel off)# # days omitted = every day; e.g. days = ["mon", "tue", "wed", "thu", "fri"] for weekdays only
[title]delay = 5
# --- Transitions ---# Global transition defaults. Full transition catalogue and per-family tuning# lives at https://docs.ledticker.dev/transitions/. The values below give a# 0.5-sec wipe between widgets within a section and a dissolve between sections# — readable on a 160×16 smallsign without being distracting.
[transitions]default = "wipe_alternating"duration = 0.5easing = "ease_out"between_sections = "dissolve"
# --- Playlist ---# Each [[playlist.section]] displays in order, then the whole playlist loops.# Sections, modes, and timing knobs: https://docs.ledticker.dev/concepts/sections-and-modes/
# Welcome / brand messages.
[[playlist.section]]mode = "ticker"loop_count = 1
[playlist.section.title]type = "message"text = "#DevOps News"font_color = "random"
[[playlist.section.widget]]type = "message"text = "May the uptime be with you!"
[[playlist.section.widget]]type = "message"text = "Always be shipping!"
# Counts down to any date. Message + remaining days scroll across the panel.
[[playlist.section]]mode = "ticker"loop_count = 2
[playlist.section.title]type = "message"text = "Count Downs"font_color = "random"
[[playlist.section.widget]]type = "countdown"text = "Days Until Spring"countdown_date = 2027-03-20 # ← change to your own date
[[playlist.section.widget]]type = "countdown"text = "Days Until Summer"countdown_date = 2027-06-20
# Counts up from any past date. Widget disappears before countup_date arrives.[[playlist.section.widget]]type = "countup"text = "Open for"countup_date = 2024-01-01
# Local time — the built-in clock widget. `format` is "12h"/"24h" or a strftime# template (e.g. "%I:%M %p %Z"); set `timezone` to an IANA name or it follows# the Pi's system clock. Knobs: https://docs.ledticker.dev/widgets/clock/
[[playlist.section]]mode = "slideshow"hold_time = 5loop_count = 1
[playlist.section.title]type = "message"text = "Local Time"font_color = "random"
[[playlist.section.widget]]type = "clock"format = "12h"timezone = "America/New_York"font_color = "rainbow"
# A bundled sample animation — no plugin, no API key, and no media of your own# required. config/assets/ ships a few CC0 clips; swap `path` for your own art# in config/local/ (gitignored). Image/gif knobs: https://docs.ledticker.dev/widgets/gif/
[[playlist.section]]mode = "slideshow"hold_time = 5loop_count = 1
[[playlist.section.widget]]type = "gif"path = "assets/phoenix.gif"fit = "pillarbox"image_align = "center"
# ── Add live data with plugins ────────────────────────────────────────# Everything above is built in. For live feeds, install a first-party plugin# (https://docs.ledticker.dev/plugins/) — copy the requirements file, add the# plugin you want, and restart (no image rebuild):## cp config/requirements-plugins.example.txt config/requirements-plugins.txt# # uncomment the plugin line(s) you want, then:# docker compose restart## (or use the web UI Store tab, which writes that file for you). Then add a# section using the plugin's widget type:## led-ticker-weather weather.current current conditions (WeatherAPI.com)# led-ticker-rss rss.feed RSS / Atom headlines# led-ticker-crypto crypto.coingecko crypto prices (CoinGecko)# led-ticker-baseball baseball.scores / .standings / … + baseball.roll* transition# led-ticker-calendar calendar.events calendar (.ics) agenda / next event# led-ticker-pool pool.monitor pool water temperature (InfluxDB)## Example — current weather (needs led-ticker-weather + WEATHERAPI_KEY in .env;# location can be a city name, zip code, or "lat,lon"):## [[playlist.section]]# mode = "slideshow"# loop_count = 1## [playlist.section.title]# type = "message"# text = "Weather"# font_color = "random"## [[playlist.section.widget]]# type = "weather.current"# text = "NYC"# location = "New York"# units = "imperial"# ──────────────────────────────────────────────────────────────────────
# --- Images and media ---# Bundled sample art lives in config/assets/ and is referenced as# path = "assets/<file>" (e.g. path = "assets/phoenix.gif").# Your own media goes in config/local/ (gitignored), referenced as# path = "local/<file>" (e.g. path = "local/logo.gif").# The entire config/ directory is mounted into the container, so both# locations resolve correctly at runtime.
# Busy light — a persistent corner dot that lights up while a local file# exists. The dot composites over every section and transition. (Real# calendar/Slack sources are a future addition behind the same overlay.)## DOCKER (the usual deploy): `~/.busy` is the CONTAINER's home, which your# host can't reach. Point file_path at the bind-mounted config dir instead# and toggle the file on the host:# file_path = "/code/config/.busy" # in-container path (./config:/code/config:ro)# touch config/.busy # ON rm config/.busy # OFF## [busy_light]# enabled = true# file_path = "/code/config/.busy"# poll_interval = 5.0# corner = "top_right" # top_left | top_right | bottom_left | bottom_right# color = [255, 0, 0] # 3 ints, each 0-255# size = 4 # dot side in physical px: ~4 on the 16-tall# # smallsign, ~8-10 on the 64-tall bigsign/longboi# token = "" # fallback only — prefer LED_TICKER_BUSY_TOKEN in .env
# Web status UI — serves a live dashboard of what's on the panel at# http://<pi>:8080. Presence of this block enables status publishing by the# display process; the sidecar reads status.json and handles HTTP.## DOCKER (the usual deploy): the compose.yaml webui service sits behind the# `webui` compose profile — start with `COMPOSE_PROFILES=webui docker compose up -d`# (or add COMPOSE_PROFILES=webui to your .env to make it sticky).# Docs: https://docs.ledticker.dev/concepts/web-status-ui/## [web]# http_host = "0.0.0.0"# http_port = 8080# token = "" # fallback only — prefer LED_TICKER_WEB_TOKEN in .env (secrets don't belong in config.toml)# status_path = "/run/led-ticker/status.json" # default works in Docker too (shared volume mounts here)# allow_restart = true # enables the "Restart to apply" button in the web UI; requires a process supervisor# # to bring the display back — Docker's `restart: unless-stopped` (shipped in compose.yaml)# # handles this automatically. Defaults to false.