I recently shipped a fullscreen clock app, digitalclock.xyz, that offers eight themes. Four of them look like a mechanical flip clock, three like a retro pixel LED display, and one like a seven-segment alarm clock. Three genuinely different display technologies — and I wanted all of them rendered by the same component tree, with zero canvas, zero SVG, and zero image assets.
That constraint turned out to be the most interesting engineering decision in the project. Here's how the architecture actually works.
A theme is just data
The first rule I set: a theme is not a component, not a CSS file, not a variant prop scattered across the tree. It's a plain object.
export type ThemeStyle = "flip" | "pixel" | "segment";
export interface Theme {
id: ThemeId;
label: string;
style: ThemeStyle;
pageBg: string;
cardBg: string;
cardBgGradient?: string;
digitColor: string;
divider: string;
glow?: string;
fontClassVar: "var(--font-flip)" | "var(--font-flip-mono)" | "var(--font-pixel)";
separatorColor: string;
subtleText: string;
}
Everything a theme knows is color tokens, an optional glow, a font variable — and one 3-value enum, style, which decides which of three rendering branches the digit component takes. All eight themes fit in a single array in themes.ts. "Flip Ocean" and "Pixel Amber" differ only in the values of these fields.
The important consequence: adding a ninth theme is a data change, not a code change. Adding a fourth display technology is a code change — one new branch — but nothing else in the tree has to know about it.
The only interface between JS and styling is custom properties
React never writes real CSS declarations inline. The digit component's inline style contains nothing but CSS custom properties:
style={{
"--seg-on": theme.digitColor,
"--seg-off": `color-mix(in srgb, ${theme.digitColor} 10%, transparent)`,
"--seg-glow": theme.glow ?? "none",
} as React.CSSProperties}
Static CSS rules in globals.css consume those variables. JS decides what the values are; CSS decides what they mean. That split is what keeps three unrelated display technologies from leaking into each other — the React side genuinely does not know how a segment gets its bevel or how a flip card folds.
One detail I like a lot: the seven-segment theme only declares a single color, digitColor. The dim "off" segments are derived in that snippet above with color-mix(in srgb, <digitColor> 10%, transparent), and the glow is another derivation of the same hue. One token in, an entire lit/unlit/halo palette out. When I added the theme picker, no theme ever shipped with mismatched on/off colors, because there's nothing to mismatch.
Branch 1: pixel — the trivial case
The pixel LED branch is almost embarrassingly simple, which is the point:
if (theme.style === "pixel") {
return (
<span className="flip-pixel-digit"
style={{ color: theme.digitColor, textShadow: theme.glow }}>
{value}
</span>
);
}
A pixel font does the heavy lifting for the dot-matrix look, and a two-layer text-shadow (an 18px halo plus a wider 36px falloff) makes it bloom like a backlit display. The glyph is text, so the glow technique that fits is text-shadow — it hugs the letterforms for free.
Branch 2: seven-segment — the font is in the stylesheet
This is the branch people don't believe until they open devtools. A seven-segment digit renders as seven empty spans:
<span className="seg-digit" data-value={value} aria-label={value}>
<span className="seg seg-h seg-a" />
<span className="seg seg-v seg-b" />
<span className="seg seg-v seg-c" />
<span className="seg seg-h seg-d" />
<span className="seg seg-v seg-e" />
<span className="seg seg-v seg-f" />
<span className="seg seg-h seg-g" />
</span>
No text content at all. The "font" — which segments light up for which digit — lives entirely in CSS as ten attribute-selector rules. It's literally the 0–9 truth table you'd find in a 7-segment decoder datasheet, written in selectors:
.seg-digit[data-value="2"] :is(.seg-a, .seg-b, .seg-d, .seg-e, .seg-g),
.seg-digit[data-value="4"] :is(.seg-b, .seg-c, .seg-f, .seg-g),
.seg-digit[data-value="8"] :is(.seg-a, .seg-b, .seg-c, .seg-d, .seg-e, .seg-f, .seg-g) {
background: var(--seg-on);
}
React's entire job here is setting data-value="4". The cascade does the decoding.
The geometry is absolutely positioned in em units — seg-a at top: 0, seg-g at top: 0.455em, seg-d at top: 0.91em, verticals hung at the corners — so the whole digit scales with font-size like real text does. The classic tapered segment ends are one clip-path: polygon(...) per orientation, hexagons instead of rectangles.
Two branch-specific details worth stealing:
-
Glow:
text-shadowis useless here because there's no text. The segment branch usesfilter: drop-shadow(var(--seg-glow))on the container, which follows the clipped hexagon shapes exactly — including the tapered tips. -
Accessibility: a pile of empty spans is meaningless to a screen reader, so the container carries
aria-label={value}. Cheap insurance for a fully decorative DOM structure.
Branch 3: flip — the only branch that needs memory
Flip is the one technology that can't be stateless, because a flip has a before and an after. The component keeps the previous value in state and runs a two-phase animation — the top half folds down over 300ms, then the bottom half unfolds for another 300ms — before committing:
useEffect(() => {
if (value === prev) return;
if (theme.style !== "flip") {
setPrev(value); // pixel/segment themes: no animation, just sync
return;
}
setFlipping(true);
timerRef.current = window.setTimeout(() => {
setPrev(value);
setFlipping(false);
}, 600);
}, [value, prev, theme.style]);
Note the second early return: when the active theme is pixel or segment, the same component silently degrades into "just track the value." The flip machinery — the extra state, the timeout, the transient fold elements rendered during flipping — costs nothing on the branches that don't use it. That's what lets one FlipDigit component serve all three technologies instead of three sibling components duplicating the value-diffing logic.
The one SSR wrinkle
A clock is the canonical hydration-mismatch generator: the server has no idea what time it is in the client's timezone. My useNow hook returns Date | null, starts as null, and only picks up a real Date inside useEffect — so the server render and first client render agree on "no time yet," and the 250ms interval is purely a refresh rate after that.
What this bought me
The final tally for eight themes across three display technologies: one digit component with three branches, one theme array, and a few hundred lines of static CSS. No canvas contexts to manage, no SVG sprites to generate, nothing to preload. The DOM stays inspectable — you can watch digit 4 light up b, c, f, g in the elements panel, which also made every rendering bug a devtools problem instead of a bitmap-debugging problem.
If you want to poke at the result (devtools open, ideally), it's live at digitalclock.xyz. And if you've pushed attribute selectors somewhere equally questionable, I'd honestly love to hear about it in the comments.
Top comments (0)