Skip to content
2 min read450 words★ Featured

Building a pixel-perfect UI system in Tailwind

How I built a set of 8-bit UI primitives — hard shadows, notched frames and a hand-rolled sprite renderer — on top of Tailwind v4 and shadcn/ui.

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:

  1. Radius is always zero. Every radius token resolves to 0px.
  2. Shadows never blur. box-shadow: 4px 4px 0 0 #03030a, never 0 4px 6px.
  3. Motion is stepped, not eased. steps(4, end), never cubic-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.

css
@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.

tsx
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:

css
@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:

tsx
<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:

ts
export const SPRITES = {
  invader: [
    "..1..1..",
    "...11...",
    "..1111..",
    ".111111.",
    "11.11.11",
    ".11..11.",
    "1.1..1.1",
  ],
} as const

Because 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:

tsx
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 →

Command palette

Search for a command to run