Scanlines
A CRT line grille whose pitch is snapped to the display's physical pixel grid, with an optional one-pixel-per-step roll and a WCAG-clamped flicker.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scanlines.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Scanlines" component — a CRT line
grille used as a decorative background layer. Its only dependency is a cn()
class merger (clsx + tailwind-merge). It needs "use client" for exactly one
reason: the line pitch has to be read off the display, see Behavior.
Contract
- export function Scanlines(props): props extend
Omit<React.ComponentProps<"div">, "children"> (rest props + ref spread onto
the root div) plus:
- spacing?: number (default 4) — distance from one line to the next, in CSS
px, before physical-pixel snapping.
- thickness?: number (default 2) — thickness of the line itself in CSS px,
before snapping. Always leaves at least one physical pixel of gap.
- opacity?: number (default 0.1) — opacity of the whole layer. This is the
readability knob; see the measured ceilings below.
- lineColor?: string (default "foreground") — a theme token name WITHOUT the
leading "--".
- drift?: boolean (default true) — roll the pattern downward.
- speed?: number (default 2) — seconds to travel one full spacing period.
- flicker?: boolean (default false) — pulse the layer's brightness. OFF by
default on purpose, see Behavior.
- flickerHz?: number (default 2) — flicker cycles per second, hard-clamped
to 3.
- It renders no children: it is a layer, not a wrapper. The consumer puts it
inside a `relative overflow-hidden` ancestor and writes content in a
`relative` sibling, which stacks above it with no z-index bookkeeping.
- Clamp every numeric prop and reject NaN/Infinity first: spacing 1..64,
thickness 0.25..spacing, opacity 0..1, speed 0.4..120, flickerHz 0.2..3.
Behavior — physical-pixel snapping (this is the whole component)
- Evenly spaced hairlines are the worst possible case for a fractional device
pixel ratio. At 1.5x a 3 CSS px pitch is 4.5 device px, so consecutive lines
land on alternating subpixel phases; the renderer antialiases them
differently and the pattern beats into visible moiré banding. Measured on
Chromium with a control build that skips the snapping: line-to-line period
stddev 0.2500 device px, per-line ink coefficient of variation 0.1945 at
2.25x. With snapping: 0.0000 / 0.0000.
- So: read devicePixelRatio (clamped 1..4, server snapshot 1), convert the
pitch to WHOLE device pixels and convert back:
periodDevice = max(2, round(spacing * dpr))
lineDevice = clamp(round(thickness * dpr), 1, periodDevice - 1)
periodCss = periodDevice / dpr ; lineCss = lineDevice / dpr
Every line then shares one subpixel phase, so the grille rasterizes
identically from the first row to the last. Document that the effective
spacing can therefore land up to half a device pixel from the requested one,
and that the max(2, ...) floor means one lit + one dark physical pixel is
the tightest grille that can exist.
- Subscribe with useSyncExternalStore over
matchMedia(`(resolution: ${devicePixelRatio}dppx)`). That query only reports
the moment you LEAVE the current ratio, so the subscribe callback must
re-arm itself onto the new ratio on every change, or it goes deaf after the
first zoom or monitor hop. Return the unsubscriber; nothing else to clean up.
- Paint it as ONE repeating-linear-gradient with hard stops:
repeating-linear-gradient(to bottom, ink 0, ink Lpx, transparent Lpx,
transparent Ppx)
Do NOT use linear-gradient + background-size tiling instead: measured on the
same harness, the tiled path drifts (period 4.9920 instead of 5.0000 at 1.5x,
ink CV 0.0680) because the tile rect gets snapped independently of the
gradient inside it. One gradient, no tiling, is the exact path.
- There is no ResizeObserver and no canvas: the pattern is defined by pitch,
not by container size, so a resize cannot perturb it and there is no
observer that could feed back into layout. Verified across six container
sizes: identical period, zero residual.
- Drift: one keyframe moving background-position from 0 0 to one period, with
a steps(periodDevice) timing function so every frame lands on a whole device
pixel instead of resampling the entire pattern each tick. Because the step
equals exactly one period the loop is seamless.
- Flicker and photosensitivity: WCAG 2.3.1 allows no more than three flashes
in any one second, so flickerHz is hard-clamped to 3 AND flicker defaults to
off. This is not decorative caution: at opacity 0.6 the measured rendered
luminance swing is 0.178, which is past the 0.1 relative-luminance change
that WCAG counts as a flash at all, over an area that is by definition
full-bleed. Keep the trough a dim (45% of opacity), never a blackout, and
keep the easing smooth rather than a square wave.
- prefers-reduced-motion: motion-reduce:[animation:none] on BOTH the flicker
layer and the roll layer. The grille stays fully drawn and only stops moving
— a background that vanishes leaves a blank panel.
- The whole layer is aria-hidden, pointer-events-none, has no tabindex and no
focusable descendants.
Rendering & styling
- Semantic tokens only: the ink is var(--<lineColor>). "foreground" reads as
dark lines on light themes and as a lit grille on dark ones; "primary" gives
a tinted phosphor. No hex / rgb() / oklch() anywhere.
- opacity is the single dimmer, so the token stays at full strength and the
two knobs never fight.
- Readability, measured over bg-background with this exact gradient:
text-foreground survives to opacity 0.45 (6.28:1 light, 4.68:1 dark) and is
15.86:1 / 16.84:1 at the default 0.1. text-muted-foreground has almost no
headroom — it starts at 4.95:1 on the light theme with no layer at all and
is already under 4.5:1 by opacity 0.05. Put body copy on text-foreground.
- The keyframes ship inside the component via a React 19 hoisted
<style href="zyeon-scanlines" precedence="medium"> tag — no Tailwind config
edits, and multiple instances dedupe to one style tag by href.
- Merge consumer className via cn() on the root so the call site can retarget
the layer (top band only: bottom-1/2), restack it or round it.
Customization levers
- Pitch: spacing 2 is an aperture grille, 4 is a TV raster, 8+ is a venetian
blind. thickness sets the duty cycle — equal to spacing/2 is the classic
50% CRT look, thinner reads as a wire mesh.
- Weight: opacity is the only dimmer you should touch. 0.08–0.12 is a texture,
0.2–0.35 is a statement, past 0.45 body copy stops passing 4.5:1.
- Palette: lineColor takes any token name — "foreground" (neutral ink),
"chart-1" (a real hue in both themes, so it reads as a phosphor tint),
"border" (barely there), "destructive" (alarm console). In the stock shadcn
palette "primary" is achromatic, so it only shifts the weight, not the hue.
- Motion: drift={false} for a held frame; speed 0.4–1 is a visible roll,
6–12 is an ambient crawl. Because the roll is stepped, one step is always
one physical pixel, so a slow speed on a low-density display looks chunkier
by design.
- Flicker: leave it off for anything a user has to read. When you do enable it,
flickerHz 1–2 reads as an unstable signal; 3 is the ceiling the clamp allows.
- Curvature/vignette: wrap the layer in a parent with a radial mask, or add a
sibling radial-gradient, to fake CRT bulge — both are one extra div and do
not touch the pitch maths.Concepts
- Device-pixel snapping — the pitch is rounded to a whole number of physical pixels and divided back into CSS pixels, so every line lands on the same subpixel phase; the effective spacing can differ from the requested one by up to half a device pixel, and that is the price of a grille that never beats.
- Moiré beat — when the period is fractional, consecutive lines are antialiased differently and the difference cycles, producing low-frequency bands that swim as the page scrolls or zooms. It is a property of the geometry, not of the colours, which is why the fix is arithmetic rather than blur.
- Re-arming resolution query —
(resolution: Xdppx)only fires when you leave X, so the subscription has to move itself onto the new ratio inside its own change handler; a fixed query hears the first zoom and nothing after it. - Stepped scroll loop — the roll advances
background-positionby exactly one period per cycle with asteps()timing function, so the loop closes on itself and every intermediate frame is still whole-pixel aligned instead of being resampled 60 times a second. - Flash threshold — WCAG 2.3.1 caps flashing at three times per second; the component clamps
flickerHzthere and ships with flicker disabled, because a full-bleed high-contrast pulse is a photosensitive-seizure trigger rather than a styling choice. - Pitch, not geometry — the pattern is defined by spacing alone, so there is no size measurement, no
ResizeObserverand therefore no resize feedback loop; the layer is inert until the display density itself changes.
Bokeh Lights
Slowly floating out-of-focus light discs on one canvas — seeded for a reproducible picture, blitted from a pre-rendered sprite instead of a per-frame blur.
Donut Chart · Center Stat
A four-state donut widget with a headline stat in the hole and a value/percentage legend, driven by one contract.