Loading state
NewA pixel-grid loader with a shimmering status word and an elapsed timer, in four styles.
loading-state/loading-state.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- An assistant or agent working on an answer for an unknown time: a few seconds to a few minutes.
- Work where the elapsed time matters to the reader, so they can judge whether to wait or stop it.
- A chat or a console line, where the loader takes one quiet row and the reply replaces it.
- Long waits that need a little life: the Surfer loader adds a small clip to watch under the word.
When not to use
- Progress you can measure, like an upload or an import. Show a bar or a count of what is left.
- Waits under a second: the timer never passes 0.9s and the shimmer barely starts. Show nothing.
- A loader inside a button or a field. Use a spinner at text size and keep the button’s label.
- Several jobs at once: one shimmering line each competes for attention. Use one list with a status per row.
- Telling people what went wrong: the loader only knows working. Replace it with the error in words.
You can add this component with the shadcn CLI or by hand:
Into components/loading-state of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/loading-state.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { LoadingState } from './loading-state';export function Reply({ startedAt }: { startedAt: number }) { return <LoadingState label="Thinking" since={startedAt} switcher={false} />;}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
What an assistant shows while it works: a 3 × 3 grid of pixels pulses, the status word shimmers and a monospaced timer counts the seconds. A small switch picks the loader: Drive, a chevron wave of square pixels; Dots, the same wave in dots; Orbit, a light running round the edge; and Surfer, which adds a little endless-runner clip that pops in underneath.
- Every pixel plays the same keyframes, dim to lit in the first 18% of its lap, held to 42%, dim again by 62%, each from its own delay. The delays draw the pattern: a chevron wave (650 ms laps, 90 ms apart) for Drive and Dots, a clockwise run round the eight outer pixels (950 ms laps, 110 ms apart) for Orbit, whose middle pixel rests at 7%.
- Only opacity animates, on nine 4 px boxes, so the compositor plays it all. Each lap starts 400 ms in, so a held grid already shows its pattern.
- The word is clipped to a background twice its width, muted at 35%, bright at 50%, muted again at 65%. It slides from 150% to −50% in 1.4 s, linear, so a highlight sweeps through the word left to right.
- The timer reads the clock every 100 ms and shows tenths of a second, counting from `since` or from when the loader appeared, so it is right whenever it is looked at.
- The Surfer clip is a canvas, not a video: one perspective projection (x = K·X / z, y = horizon + K·(1 − Y) / z), and every polygon painted far to near. The tracks, arches and bushes repeat along z; the runner, its coins and the trains follow a 9.6 s script of six beats, so each frame is a pure function of one clock and a paused clip resumes exactly where it stopped.
- The clip pops in from its top-left corner, opacity 0 → 1 and scale 0.95 → 1 in 200 ms on cubic-bezier(0.16, 1, 0.3, 1).
- Off screen or in a hidden tab, the pixels, the sweep and the clip pause (animation-play-state and no frames drawn).
- Every colour is a light-dark() token on the root, so the loader follows the page’s color-scheme, or the colorScheme prop. The picked button sits on a white pill with a grey ring by day, and on a slightly lighter pill with a faint light ring and a drop shadow at night.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'drive' | 'dots' | 'orbit' | 'surfer' | — | The loader shown. With it the component is controlled; pair it with onVariantChange. |
defaultVariant | 'drive' | 'dots' | 'orbit' | 'surfer' | 'drive' | The loader shown first, when uncontrolled. |
onVariantChange | (variant: LoaderVariant) => void | — | Called with the loader picked in the switch. |
label | string | 'Churning' | The status word. |
surferLabel | string | 'Rail surfing' | The Surfer loader’s status word. |
since | number | — | When the work began, as a Date.now() timestamp; without it, the timer counts from when the loader appeared. |
elapsed | number | — | A fixed timer reading, in ms: the timer shows it and holds still instead of counting. |
switcher | boolean | true | Shows the switch between the four loaders at the foot of the stage. |
autoplay | boolean | true | While on screen, the pixels pulse, the word shimmers and the clip runs; off, they hold still (the timer still counts). |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';import { RunnerClip } from './runner-clip';import { useAutoplay } from './use-autoplay';import styles from './loading-state.module.css';/* * Loading state: what an assistant shows while it works. A tiny 3 × 3 grid of pixels pulses, a * status word shimmers, and a monospaced timer counts the seconds since the work began. Four * styles of loader, picked from a segmented switch under it: * * - Drive: square pixels light up in a chevron wave, left to right, 650 ms a lap. * - Dots: the same wave in round dots. * - Orbit: a light runs clockwise round the eight outer pixels, 950 ms a lap, its tail fading * behind it; the middle pixel stays dim. * - Surfer: the Drive grid with a different word, and under it a small clip of an endless runner * that pops in (drawn on a canvas, runner-clip.tsx). * * How it moves * - Each pixel plays the same CSS keyframes (dim → lit for a quarter of the lap → dim), started * at its own delay; the delays draw the pattern. Only opacity changes, so the compositor plays// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef } from 'react';/* * The Surfer loader's little clip: an endless runner, drawn on a canvas instead of played from a * video file. A runner dashes down three tracks between two arched viaduct walls, hops from lane * to lane, jumps now and then, picks up coins, and lets the trains pass in the other lanes. * * - One camera, one projection: a point X across, Y up and z ahead of the camera is drawn at * (CX + K·X / z, HORIZON + K·(1 − Y) / z). Everything is a polygon of projected points, painted * far to near. * - It is all a function of one clock: the tracks, arches and bushes repeat along z; the runner, * coins and trains follow a 9.6 s script of six 1.6 s beats. So a frame never depends on the * one before it, and a paused clip resumes exactly where it stopped. * - It draws only while `playing`; otherwise it holds its last frame (or, the first time, draws * a still one). *//** The clip's own size in CSS px (it is drawn at the device's pixel ratio, up to 2). */const W = 224;const H = 126;/* Loading state (loading-state.tsx): a quiet grey stage with the loader in its middle and the switch between loaders at its foot. At night it is near-black, the pixels and the shimmer's highlight almost white; by day it is a pale grey, and they turn near-black. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Memory. Everything that moves is opacity, a transform or a background position on a few small boxes; the clip is one 224 × 126 canvas, drawn only while it plays. */.root { /* The words and the lit pixels; a step down; the muted grey of the timer and the idle words. */ --ink: oklch(24.7% 0.006 258.361); --ink-2: oklch(50.6% 0.01 264.477); --ink-3: oklch(69.5% 0.009 264.505); /* The stage, its edge, the switch's track and its picked button. */ --canvas: oklch(96.1% 0.002 247.84); --line: oklch(94.6% 0.003 264.542); --field: oklch(96.1% 0.001 286.375); --surface: oklch(100% 0 0); /* The picked button's edge and lift: a ring and a soft glow by day, a faint light ring and a drop shadow at night (the layer one scheme doesn't use is transparent). */ --button-ring: oklch(91.2% 0.005 258.326);'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The status word is a polite live region (role="status"): it is announced when it changes, never on every tick of the timer. The timer’s tenths are hidden from screen readers, which read whole seconds instead.
- The switch is a radio group named “Loader style”: one tab stop, arrow keys, Home and End move and pick, with a visible focus ring.
- The pixel grid and the clip are decorative (aria-hidden).
- Under reduced motion nothing moves: the grid is drawn still, part way through its laps, the word is plain, the clip shows one still frame and pops in without animation.