Every good 8-bit interface is an exercise in constrained geometry. There are no sub-pixel radii, no soft shadows, no easing curves. A border is N pixels. A shadow is N pixels. A press moves 2 pixels. Once you accept those rules, the design space gets tiny — and tiny design spaces are fun.
This is how I built the retro layer you are currently looking at.
Start with the constraints
Three decisions did most of the work:
- Radius is always zero. Every radius token resolves to
0px. - Shadows never blur.
box-shadow: 4px 4px 0 0 #03030a, never0 4px 6px. - Motion is stepped, not eased.
steps(4, end), nevercubic-bezier.
Number one alone retrofits an entire component library. Because shadcn's radius tokens
are all derived from a single --radius variable, zeroing it out squares off every
card, dialog, badge and input for free.
@theme inline {
--radius-sm: 0px;
--radius-md: 0px;
--radius-lg: 0px;
}
@utility shadow-pixel {
box-shadow: 4px 4px 0 0 var(--crt-shadow);
}@utility is the important detail. Tailwind v4 lets you register custom utilities that
participate in variant resolution, so hover:shadow-pixel and md:notch both work.
Hard shadows need a matching offset
The shadow offset has to equal the border width, or the whole thing reads as a mistake
rather than a style. If the border is 4px, the shadow moves 4px. Tailwind's spacing
scale happens to line up: translate-1 is 4px.
const borderClass = thickness === 2 ? "border-2" : "border-4"
const shadowClass = thickness === 2 ? "shadow-pixel-sm" : "shadow-pixel"The notched-corner problem
Diagonal corner cuts are the signature of a 16-bit UI frame, and clip-path is the
obvious way to get them:
@utility notch {
clip-path: polygon(
var(--notch) 0,
calc(100% - var(--notch)) 0,
100% var(--notch),
/* ... */
);
}But clip-path also clips box-shadow. The moment you add it, the hard shadow
disappears. There is no clip-path-shadow property coming anytime soon.
The fix is to stop asking the shadow to live on the same element:
<Component className="relative">
<div aria-hidden className="absolute inset-0 translate-x-1 translate-y-1 bg-crt-shadow notch" />
<div className={cn(face, "notch")}>{children}</div>
</Component>Two elements, both notched, one offset. Deterministic, no z-index tricks, no stacking
context surprises.
Sprites as data
The icons are 8×8 character grids where . is transparent and a digit indexes into a
palette of CSS variables:
export const SPRITES = {
invader: [
"..1..1..",
"...11...",
"..1111..",
".111111.",
"11.11.11",
".11..11.",
"1.1..1.1",
],
} as constBecause every pixel resolves to var(--px-1) rather than a literal colour, the whole
icon set re-tints itself when the visitor switches CRT phosphor. One data-phosphor
attribute on <html> repaints every sprite on the page.
Rendering them is a matter of merging horizontal runs into <rect> elements — a sprite
costs a handful of nodes instead of one per pixel:
function gridToRects(grid: SpriteGrid): Rect[] {
const rects: Rect[] = []
grid.forEach((row, y) => {
let runStart = -1
let runColor = ""
const flush = (endX: number) => {
const fill = PALETTE[runColor]
if (runStart !== -1 && fill) {
rects.push({ x: runStart, y, width: endX - runStart + 1, height: 1, fill })
}
runStart = -1
runColor = ""
}
for (let x = 0; x < row.length; x++) {
const char = row[x]
if (char === "." || !PALETTE[char]) {
flush(x - 1)
continue
}
if (char !== runColor) {
flush(x - 1)
runStart = x
runColor = char
}
}
flush(row.length - 1)
})
return rects
}Always set shapeRendering="crispEdges" and image-rendering: pixelated. Without them
the browser helpfully anti-aliases your art into mush.
Make it toggleable
A CRT filter that can't be turned off is a bug waiting for a visitor with photosensitive
epilepsy or a slow machine. Everything here — scanlines, grain, vignette, glow, motion —
is behind a setting, honours prefers-reduced-motion, and degrades to nothing.
The accessibility work and the aesthetic work turned out to be the same work.
Filed under AUG 14 '26
All posts →