Loading wipe
A loader that is swept off the page by a ribbon of colour, its edge churning like cloud or flame.
loading-wipe/loading-wipe.tsxA loading surface covers the page.
Colour
Sweep
Ribbon
Edge noise
Grain
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
- The one moment a loading surface hands over to the content: a splash, a first paint, an app shell opening.
- Brand moments where the hand-over itself is worth a second of attention, with a ribbon in the product’s colours.
- Tuning a reveal with a designer: every setting is a live uniform of the shader, and
Scrubholds any frame. - Hero sections or case studies that show a transition, where the reader may replay it with
Sweep.
When not to use
- Loading that repeats often, like a list paging or a tab switching. A skeleton or a quiet fade wears better.
- Waits of unknown length. The wipe is the exit, not the wait: show progress or a spinner until the content is ready.
- Pages that must stay light: it needs WebGL while it runs. Without it, or under reduced motion, it falls back to a cross-fade.
- Small surfaces like a card or a button: the turbulent edge needs room to read. Use a plain cross-fade there.
You can add this component with the shadcn CLI or by hand:
Into components/loading-wipe of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/loading-wipe.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.2 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 { LoadingWipe } from './loading-wipe';// The wipe and its panel, without the demo: the reader sweeps it.export function LoaderLab() { return <LoadingWipe autoplay={false} defaultPreset="ember" defaultAngle={0} />;}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.
A loading surface that leaves by being swept away. A band of colour with a soft, turbulent edge crosses it in any of eight directions, then dissolves behind into the real page. A panel sets the colours, the direction and the noise, and a scrub plays the wipe by hand.
- The page under the loader is plain HTML. At first the loader is a CSS stand-in: a plain surface with a fine noise grain and its words. Once the wipe starts, a WebGL canvas over the preview takes over. It draws the loader, its words (a texture of the real text, placed exactly where it sits), the ribbon, and nothing where the page is clear.
- The wipe is a field: each point’s distance behind a moving front, pushed about by value noise. Slow swells grow with the turbulence, and a finer churn grows with its cube until it tears the ribbon into lace. Swirl bends the noise’s domain with more noise, and bleed uses a second noise to hold points back near the trailing edge, so colour lingers in blotches that then dissolve.
- The field is computed on a coarse grid, one value every 6 CSS px, into a small texture, and read back at full resolution with bilinear filtering, which keeps it smooth and gives its contours a faintly blocky look at high turbulence. From it the shader picks the four ribbon colours (palest at the front, the deepest only tinting the dissolve), the feathered edges and a fine grain. The costly noise runs a few thousand times a frame, not a million.
- One requestAnimationFrame loop plays a sweep on an ease-in-out curve, and only while something moves. The Scrub slider follows the progress through a tiny external store, so only that row re-renders each frame. Every slider is a live shader uniform, and a wipe on show is redrawn as soon as one changes.
- GPU memory stays low four ways: the pixel ratio is capped at 2 and matched to the displayed size, so a shrunk gallery card gets fewer pixels; the canvas gives its pixels back after 4 s at rest, and at once off screen; the controls panel scrolls by hand (wheel, touch with coasting, keyboard focus) instead of being a native scroll area; and the card is a layout boundary of a fixed size, with the demo pointer built of boxes around static drawings, so nothing that moves inside it makes Safari lay out and repaint the whole page.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. The WebGL renderer reads the loader’s colour from the canvas’s computed style each draw, and a 1 ms colour transition tells it when the scheme changes; the ribbon’s colours stay as they are.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultPreset | 'sky' | 'ember' | 'dusk' | 'moss' | 'mono' | 'sky' | The ribbon’s colours at first. |
defaultAngle | number | 270 | Where the sweep travels at first, in degrees: 0 towards the right, 90 down, 270 up. |
defaultDuration | number | 1200 | How long a sweep takes at first, in ms (300 to 4000). |
defaultProgress | number | — | Opens with the wipe this far along, from 0 (the loader whole) to 1 (the page clear), at rest. Turns the demo off. |
defaultSettings | Partial<Settings> | — | The panel’s other settings at first (spread, bleed, feather, swirl, turbulence, grain…). Turns the demo off. |
onReveal | () => void | — | Called each time a sweep has run to the end and the page is clear. |
autoplay | boolean | true | While on screen and untouched, a demo pointer plays with the wipe on its own. |
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, a stage of its own. |
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, useEffectEvent, useId, useRef, useState, type CSSProperties } from 'react';import { DirectionPicker, PresetPicker, RibbonRow, ScrubRow, Section, SliderRow, snap,} from './controls';import { DemoPointer, runDemo, type Demo, type Point } from './demo-pointer';import { SweepIcon } from './icons';import { DEFAULTS, SECTIONS, SLIDERS, presetOf, type PresetId, type Settings, type SliderKey,/* Loading wipe (loading-wipe.tsx): a stage with one card in its middle, the preview on the left (the page, the loader over it, the canvas while the wipe runs) and the panel of controls on the right, which scrolls. In a narrow container the panel goes under the preview. Colours. The interface's are the tokens below, each light-dark(light, dark): 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). The ribbon's colours (presets.ts) are the effect: the same in both. The canvas paints the loader in the colour .canvas carries, and its words in theirs, both read from the computed style (sweep.ts), so they follow the scheme too. Memory. A phone keeps a page's pixels in tiles the width of the screen, about 7 MB each, and a tile that is painted again gets a second buffer. So what changes while the wipe plays stays off the page's tiles: the panel, the loader's stand-in, the button and the lines for screen readers each paint on a small layer of their own (will-change: opacity), and the card is a layout boundary, so a change inside it lays out the card alone. There must be only one: a browser lays out one boundary at a time, and two in the same frame (say the panel and the preview) make it lay out the whole page instead, which paints its tiles again. The pointer over the card needs no layout while the demo plays (demo-pointer.tsx). */.root { /* The stage; the card on it, which is also the page under the loader, and the unpicked buttons; the band of a section's title. */ --surface: #f2f1f4; --surface-2: #fff;'use client';import { useId, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type ReactNode,} from 'react';import { ArrowIcon } from './icons';import { DIRECTIONS, PRESETS, SLIDERS, type Preset, type PresetId, type SliderSpec,} from './presets';import type { Progress } from './sweep';import styles from './loading-wipe.module.css';/* * The panel's controls. The presets and the directions are radio groups (one tab stop each; the * arrow keys move and pick), the settings are native range inputs, whose fill is a CSS gradientimport { createWipe, pixelRatio, type Box, type Wipe, type WipeFrame } from './wipe';/* * The sweep: the wipe's progress, from 0 (the loader whole) to 1 (the page clear), the one * animation frame loop that plays it, and what shows it in the preview. * * - At first the loader is a CSS stand-in. Once the progress leaves 0, the canvas takes over: * sized to the preview, it draws the loader, its words and the ribbon, and the stand-in hides * under it. It keeps showing the last frame at rest (the page clear, or the loader back after a * reset, which fades in), and gives its pixels back after a few seconds unused, when the * stand-in returns. A sweep soon after reuses the pixels rather than asking for new ones: on * iOS, pixels given back linger in a pool for a while, so trading them often costs memory. * - A sweep plays the progress to 1 on an ease-in-out curve; a scrub sets it by hand. The loop * runs only while a sweep or a fade plays: a scrub, a new setting or a resize draws one frame. * - Off screen, the canvas gives its pixels back at once, and a sweep in progress keeps its * clock, so the wipe is where it should be when it comes back. * - Under reduced motion, or without WebGL, the canvas is never used: the stand-in fades out as * the progress grows, so a sweep is a short cross-fade. * - The canvas paints in the stylesheet's colours, read from the computed style each time it * draws: the loader's (the colour .canvas carries) and its words'. A change of colour scheme * runs a short transition on the canvas's colour, and its end draws the wipe again. *//** Covered: the loader is whole. Paused: part swept, at rest. Clear: the page shows. *//* * The wipe, drawn by WebGL over the whole preview: the loader ahead of the front, the ribbon of * colour on it, and nothing behind it, so the page underneath shows through. Two passes: * * - The field. Every point knows how far along the sweep it lies: 0 where the wipe comes in, 1 * where it leaves; the front travels from before 0 to past 1 as the progress goes from 0 to 1. * Fractal value noise, on a domain bent by more noise (swirl), pushes the front back and forth * (turbulence), and near the trailing edge a finer noise holds some points back (bleed), so * colour lingers there in blotches. The result, v, is each point's distance behind the front in * ribbon widths. It is computed on a coarse grid, one value every few CSS pixels, into a small * texture: the expensive noise runs a few thousand times a frame, not a million. * - The picture. At full resolution, v is read back from the texture with bilinear filtering, so * the field is smooth, and its contours turn faintly blocky when the turbulence is high. v picks * the colour (the ribbon's palest first, its deepest last) and the edges: the loader ahead, the * ribbon, then its dissolve into the page. The loader is drawn too, with its words (a texture * of the real text, placed where it is) and a fine grain, so the canvas stands in for it * seamlessly while the wipe runs. *//** * Pixels per CSS pixel: the screen's, times the scale of the box the wipe sits in (a gallery may * draw it shrunk), capped at 2 (sharp enough, for half the memory of 3×). */export const pixelRatio = (scale = 1) => Math.min(2, (window.devicePixelRatio || 1) * scale);/* * What the panel tunes: the ribbon's colour presets, the eight sweep directions, and every * setting with its range, its default and how it reads (on screen and to a screen reader). */export type PresetId = 'sky' | 'ember' | 'dusk' | 'moss' | 'mono';export type Preset = { id: PresetId; name: string; /** The ribbon's four colours, from its trailing edge (the deepest) to its leading one. */ ribbon: readonly [string, string, string, string]; /** The same four, as a screen reader says them. */ names: readonly [string, string, string, string];};export const PRESETS: readonly Preset[] = [ { id: 'sky', name: 'Sky', ribbon: ['#5a5cf2', '#22bbf8', '#4ee6fc', '#dff1fe'], names: ['indigo', 'sky blue', 'cyan', 'ice'], }, {'use client';import { useEffect, type RefObject } from 'react';/* * Scrolls the panel by hand. The panel is overflow: hidden, and this moves its scrollTop: the * wheel, a vertical touch drag (which coasts a little when let go), and the browser itself when a * control inside takes the focus. Why not a native scroll area: Safari on iOS gives each one a * layer of its own at full device resolution, which here cost more GPU memory than the whole wipe; * a hidden-overflow box is repainted in place instead. * * At the panel's ends the gesture is left to the page, which scrolls on. While the panel moves, a * thin bar over its right edge shows where it is, then fades. *//** How long the bar stays after the panel stops, in ms. */const LINGER = 700;/** How far a touch moves before it counts as a drag, in px. */const SLOP = 6;type Touch = { x: number; y: number; at: number; vertical: boolean | null; speed: number };export function usePanelScroll( panel: RefObject<HTMLElement | null>,'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, * glide the pointer onto a control and press it, or grab a slider's thumb and drag it, calling * the component's own handlers as a hand would. Stopping the demo (the reader takes over, or it * scrolls away) ends the step in progress at once: its promise rejects, which ends the script * right there, and the pointer fades out. * * Nothing the pointer does while the demo plays needs a layout, because one here in the same * frame as one in the card would make the browser lay out the whole page (see the note at the top * of the stylesheet). So it moves by animations the compositor plays (a drag too: the dragged * value follows that animation's clock); the press scales a box that keeps a transform at rest, * since a box that gains or loses one is laid out again; and the arrow and the hand are HTML boxes * that swap by their visibility (a change of style inside an SVG lays the SVG out again). */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>;/* The icons: drawn on a 16 px grid in the current colour, decorative (their buttons are named). *//** * The sweep button's icon: a play mark (Sweep), or two arrows chasing each other round a circle * (Sweeping and Reset). Both are drawn in one SVG and the stylesheet shows one of them: putting a * new SVG into the card would make Safari lay out the whole page (see .sweepIcon). */export function SweepIcon({ className }: { className?: string }) { return ( <svg className={className} viewBox="0 0 16 16" aria-hidden="true"> <path data-icon="play" d="M4.9 3.5c0-.7.75-1.1 1.33-.73l6.6 4.5c.5.34.5 1.1 0 1.45l-6.6 4.5c-.58.38-1.33-.03-1.33-.73z" fill="currentColor" /> <g data-icon="loop" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" > <path d="M2.2 8a5.8 5.8 0 0 1 10.39-3.55" />'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;- Sweep is a real button whose label follows the state: Sweep, Sweeping…, Reset. A hidden line describing what the loader is doing is linked to it, and it is aria-disabled while sweeping. Sweeps and resets the reader starts are announced in a polite live region; the demo’s own actions stay silent.
- The presets and the eight directions are radio groups with one tab stop each, and the arrow keys, Home and End move and pick. Each setting is a native range input with a visible label and an aria-valuetext that includes its unit. The ribbon swatches are one image named by their colours.
- The panel scrolls with the wheel and touch, and the browser scrolls it to whichever control takes the keyboard focus. Every control shows a visible focus ring.
- Under reduced motion there is no demo, and a sweep is a 0.4 s cross-fade instead of a moving edge. Without JavaScript or WebGL, the CSS loader stands in.