Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Keyframes ship inside the component via React 19 hoisted <style> —
 * no tailwind config edits, and duplicates dedupe by href.
 * The roll advances background-position by exactly one line period, so the
 * loop closes on itself; the steps() timing makes every frame land on a whole
 * physical pixel instead of resampling the whole pattern every tick.
 */
const KEYFRAMES = `@keyframes zy-scanlines-roll{from{background-position:0 0}to{background-position:0 var(--zy-scanlines-period)}}@keyframes zy-scanlines-flicker{0%,100%{opacity:var(--zy-scanlines-peak)}50%{opacity:var(--zy-scanlines-trough)}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scanlines.json

Prompt

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-position by exactly one period per cycle with a steps() 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 flickerHz there 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 ResizeObserver and therefore no resize feedback loop; the layer is inert until the display density itself changes.

On This Page