Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Loading state

New

A pixel-grid loader with a shimmering status word and an elapsed timer, in four styles.

loading-state/loading-state.tsx
Live · interactive
Churning, 0 seconds elapsed
Loader style

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

Terminal
npx shadcn@latest add https://brunodelorence.com/r/loading-state.json

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

reply.tsx
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.
PropTypeDefaultDescription
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.
labelstring'Churning'The status word.
surferLabelstring'Rail surfing'The Surfer loader’s status word.
sincenumber—When the work began, as a Date.now() timestamp; without it, the timer counts from when the loader appeared.
elapsednumber—A fixed timer reading, in ms: the timer shows it and holds still instead of counting.
switcherbooleantrueShows the switch between the four loaders at the foot of the stage.
autoplaybooleantrueWhile 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.
classNamestring—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.

loading-state.tsx
// 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
runner-clip.tsx
// 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.module.css
/* 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-autoplay.ts
'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.