Most LED-matrix libraries get you a scrolling "Hello, World" and stop. I built ScrollKit for what comes next: over-the-air updates to boards in the field, fault-tolerant data refresh, real transitions and effects, and a built-in web server users control from a browser. The hard part isn't any single feature. It's running all of them at once on a microcontroller without the display stuttering. It also runs on a desktop simulator I wrote that exports its own GIFs and videos, like the one below.
Built by Michael Czeiszperger
π Full documentation: scrollkit.dev
# Desktop development with simulator
pip install "scrollkit[simulator]"
# To modify ScrollKit itself (or run the demos): clone and install editable
git clone https://github.com/czei/scrollkit.git
cd scrollkit && pip install -e ".[simulator]"
# CircuitPython β copy scrollkit/ to your device's lib/ alongside your sourceimport asyncio
from scrollkit.app.base import ScrollKitApp
from scrollkit.display.content import ScrollingText
class HelloWorldApp(ScrollKitApp):
async def setup(self):
self.content_queue.add(
ScrollingText("Hello, LED Matrix!", y=12, color=0x00AAFF))
asyncio.run(HelloWorldApp().run()) # auto-detects MatrixPortal hardware vs desktop simulatorThe top-level
scrollkitpackage deliberately performs no imports (every import costs RAM on CircuitPython), so you always import from submodules, e.g.from scrollkit.app.base import ScrollKitApp. See the getting-started guide for the fullScrollKitApp/UnifiedDisplayAPI.
ScrollKit runs unchanged on the MatrixPortal S3 (CircuitPython) and a desktop
pygame simulator. Your app subclasses ScrollKitApp and talks to one display
abstraction; the library picks a backend at import time and brokers every external
system the sign touches:
flowchart TB
app["Your app<br/>(subclasses ScrollKitApp)"] --> core["ScrollKitApp Β· UnifiedDisplay<br/>ContentQueue Β· effects Β· config"]
core -->|CircuitPython| hw["MatrixPortal S3<br/>displayio β RGBMatrix panel"]
core -->|desktop| sim["pygame simulator"]
core <-->|HttpClient β synchronous| api(["HTTP data API"])
core <-->|SettingsWebServer| browser(["Browser config UI"])
core -->|raw.githubusercontent.com| gh(["GitHub OTA"])
Subsystem dependencies (dashed = lazy import; dev and simulator are
desktop-only, raising ImportError on the device):
flowchart LR
app["app"] --> display["display"]
app --> config["config"]
app -.->|lazy| utils["utils"]
app -.->|lazy| effects["effects"]
app -.->|lazy| web["web"]
effects --> display
display -.->|desktop| simulator["simulator"]
config -.->|lazy| utils
network["network"] --> config
network --> utils
ota["ota"] --> exceptions["exceptions"]
dev["dev"] --> display
dev --> effects
dev --> simulator
classDef desktop stroke-dasharray:6 4;
class dev,simulator desktop;
See the Architecture guide for the full write-up, including the invariants this graph enforces.
scrollkit/
βββ app/ # ScrollKitApp base class, async run loop, memory helpers
βββ display/ # UnifiedDisplay (auto-detects hardware vs simulator), content
β βββ unified.py # Production display (device + desktop)
β βββ content.py # DisplayContent / StaticText / ScrollingText / ContentQueue / Priority
β βββ bitmap_text.py # Animated bitmap-font text + palette effects
β βββ gradient_text.py # Gradient/multi-color text fill (GradientTextLayer)
β βββ colors.py # Continuous 24-bit color generators
βββ effects/ # Transition contract (transitions.py) + standalone splash/particle helpers
βββ network/ # Networking utilities
β βββ http_client.py # Dual-implementation HTTP client (raises NetworkError)
β βββ wifi_manager.py # WiFi connection lifecycle
β βββ mdns.py # <hostname>.local advertising (CircuitPython; no-op on desktop)
βββ config/ # Configuration management
β βββ settings_manager.py # JSON-based persistent settings
βββ ota/ # Over-the-air updates
β βββ client.py # GitHub-release-based OTA client
β βββ manifest.py # Update manifest model
β βββ display_progress.py # Display-progress adapter over OTAClient
β βββ publish.py # Host-side release publishing (desktop/CI only)
βββ utils/ # Utilities
βββ error_handler.py # Logging and error handling
βββ diagnostics.py # NVM boot/crash record + reboot-loop safe-mode breaker
βββ color_utils.py # Named colors + settings-UI hex-string color table (no conversion helpers; int-based conversions live in display/colors.py)
βββ system_utils.py # NTP / HTTP-Date system clock sync
βββ url_utils.py # URL decoding and credential loading
from scrollkit.display.unified import UnifiedDisplay
from scrollkit.display.content import ContentQueue, ScrollingText
# Create display (auto-detects CircuitPython vs desktop)
display = UnifiedDisplay(width=64, height=32)
display.initialize()
# ScrollKitApp drives this queue's render loop for you (see Quick Start above);
# add() is all a subclass's setup() typically needs to call.
queue = ContentQueue()
queue.add(ScrollingText("Scrolling text", y=12, color=0x00AAFF))from scrollkit.network.http_client import HttpClient
from scrollkit.exceptions import NetworkError
client = HttpClient()
try:
response = await client.get("https://api.example.com/data")
data = response.json()
except NetworkError as e:
print("fetch failed:", e)from scrollkit.config.settings_manager import SettingsManager
settings = SettingsManager("app_settings.json",
defaults={"hostname": "mydevice", "brightness": "0.5"},
bool_keys=["dark_mode"])
settings.set("hostname", "new-name")
settings.save_settings()from scrollkit.utils.error_handler import ErrorHandler
from scrollkit.display.colors import scale
from scrollkit.network.wifi_manager import is_dev_mode
logger = ErrorHandler("app.log")
logger.info("Application started")
color = scale(0xff0000, 0.5) # Dim red to 50%
if is_dev_mode():
print("running on desktop, not CircuitPython")| Platform | Backend | Status |
|---|---|---|
| Adafruit MatrixPortal S3 | CircuitPython + displayio | β Calibrated from device |
| Desktop (macOS/Linux/Windows) | SLDK Simulator | β |
| Custom CircuitPython boards | displayio / rgbmatrix | π Extensible (see Adding New Hardware) |
I wrote the first two shipping versions by hand in 2024, when all of this was still one application. Splitting it into a library and a separate app layer, then documenting the result, is the kind of project that dies quietly in a spare-time backlog. So I used Claude Code and spec-driven development to handle the refactoring and the first drafts, then went back through all of it in my own voice, with my own screenshots. Yes, AI has touched a lot of this code. It was also directed by an engineer who has shipped production software for a living, including time on one of Sun Microsystems' API teams. Both are true.
ScrollKit's over-the-air update feature was inspired by Ronald Dehuysser's micropython-ota-updater. The current implementation is ScrollKit's own manifest-based design and uses none of his code β but the idea came from his project, and it deserves the shout-out.
MIT
