Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Orbit loader

New

A task card’s loader: a bright ring swings round a slowly turning ring of dashes in depth, then settles into it with a check when the task is done.

orbit-loader/orbit-loader.tsx
Live · interactive

Start of week planning

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

  • One named task that runs for a few seconds to a minute, shown as a card: a plan being drafted, an import, a sync.
  • Work whose end is a clear moment worth marking: the ring grows over the dashes and a check pops in.
  • A hero or empty-state moment where the wait itself is on show, and a little character helps it feel shorter.
  • A task that can be run again from the same place: the stage’s own button finishes it and starts it over.

When not to use

  • Progress you can measure. Show a bar or a percentage, so people know how long is left.
  • Small inline waits, in a button or a table row. Use a plain spinner at text size.
  • Several tasks at once: a page of orbiting rings competes for attention. Use one list with a status per row.
  • Waits under half a second: the lap never reads, and the finish looks like a flash.
  • Errors and partial results: the card only knows running and done. Say what failed in words.

You can add this component with the shadcn CLI or by hand:

Into components/orbit-loader of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/orbit-loader.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

task-card.tsx
import { OrbitLoader } from './orbit-loader';// The card follows your task's state: it orbits while the task runs and settles with a check when it's done.export function TaskCard({ running }: { running: boolean }) {  return <OrbitLoader title="Weekly report" loading={running} autoplay={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.

A task card whose loader is a ring of dim dashes that turns slowly while a bright, thick ring orbits it in 3D. The bright ring grows as it swings across the front and slips behind the dashed ring’s disc on the way back, while a warm glow breathes along the card’s foot. When the task finishes, the dashes stop, the bright ring comes forward and grows over them, and a check pops up; run the task again and the ring shrinks back out into its orbit.

  • The orbit is a circle in depth seen from the front. The ring’s place along a 45° line follows the sine of its angle, so it slows at both ends, and its scale follows perspective, 1 / (1 − 0.265 × cos angle): about 1.36 × in front and 0.79 × behind. orbit.ts samples this into 24 keyframes for a 2-second lap.
  • The Web Animations API plays the keyframes on the compositor, with no work frame by frame. The bright ring has two copies, one drawn under the dashed ring’s disc and one over it; each shows for its half of the lap, and they swap at the ends of the swing, where the ring is clear of the disc.
  • The dashed ring is a small layer of its own that turns anticlockwise once every seven laps, on the same clock as the laps. It holds its angle when the task finishes and turns again when the task runs.
  • The bright ring is drawn at the dashed ring’s size and scaled down to orbit. Finishing reads where it is drawn now, then grows it on a spring over the dashes, and a check pops up. Running again shrinks it back out to the upper right end of the swing, where a lap starts from standing still.
  • The glow along the card’s foot is a gradient on a small layer whose opacity breathes once every two laps. Everything pauses while the card is off screen, and the card is a fixed-size layout boundary, so the browser does no layout or style work while the loader orbits.
  • Every colour is a light-dark() token, so the stage and the card follow the page’s color-scheme, or the colorScheme prop: a white card on a pale grey stage by day, a dark one at night. On white the dim dashes turn grey, while the bright ring, its check and the warm glow keep their colour.
PropTypeDefaultDescription
titlestring'Start of week planning'The task’s name, along the top of the card.
loadingboolean—Whether the task is still running. Leave it out and the stage holds its own state, with a button to finish the task and run it again.
defaultLoadingbooleantrueWithout loading: whether the stage’s own task starts running, or done with its button reading Run again. Starting done turns the demo off.
stillbooleanfalseHolds every part still, as under reduced motion: the ring stands at the upper right of its swing, and finishing shows the full ring and its check at once. Turns the demo off.
onLoadingChange(loading: boolean) => void—Called when the stage’s own button, or the demo pointer, finishes the task or runs it again.
autoplaybooleantrueWhile on screen and untouched, a demo pointer finishes the task and runs it again, when the stage holds its own state.
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, 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.

orbit-loader.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, useState, type FocusEvent } from 'react';import { DemoPointer, runDemo } from './demo-pointer';import { GLOW_START, SETTLED, START, transformOf } from './orbit';import { useAutoplay } from './use-autoplay';import { useOrbit } from './use-orbit';import styles from './orbit-loader.module.css';/* * Orbit loader: a task card whose loading ring has a moon. A ring of short, dim dashes sits in * the card's lower left corner, turning slowly, and a bright, thick ring circles it in depth: it * swings across the front from the upper right, growing as it comes closer, and goes back round * behind, smaller, hidden by the dashed ring's disc. A warm glow breathes along the card's foot. * * - The orbit is a sine and a perspective scale sampled into keyframes (orbit.ts), played by the *   Web Animations API on the compositor (use-orbit.ts): no work frame by frame. The bright ring *   has two copies, one under the dashed ring's disc and one over it; each shows for its half of *   the lap, and they swap where the ring is clear of the disc. Off screen, the laps pause. * - Finished, the ring leaves its orbit from wherever it is, comes forward and grows on a spring *   until it covers the dashes, and a check pops up in its middle. Running again, the check goes *   and the ring shrinks back out to the upper right, where a lap starts from standing still.
orbit-loader.module.css
/* Orbit loader (orbit-loader.tsx): a light stage with a white task card in its middle, or at night   a dark stage and card, and under the card the button that finishes the task and runs it again.   Everything on the card is sized from --d, the loader's size (the dashed ring's outer diameter),   which follows the stage's width.   Colours are the tokens below, 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).   Memory. A phone keeps a page's pixels in tiles the width of the screen, and a tile painted again   gets a second buffer. So what moves stays off the page's tiles: the dashed ring, the two copies   of the bright ring, the check and the glow are small layers of their own, moved by transforms   and opacity, and the card is a layout boundary of a fixed size. */.stage {  /* The stage, and the card on it (also the discs that hide the ring behind, and the button). */  --surface: #f0f0f2;  --surface-2: #fff;  /* The hairline round the card and the button; the card's and the button's shadows (none at     night). */  --line: rgb(0 0 0 / 0.08);  --shadow: rgb(0 0 0 / 0.08);  --shadow-near: rgb(0 0 0 / 0.05);  /* The title and the button's label; the button under the pointer, and its label. */
orbit.ts
/* * The orbit, as numbers. The bright ring travels a circle in depth around the dashed ring, seen * from the front: across the front it swings from the upper right to the lower left and grows as * it comes closer; behind, it goes back up, smaller, and the dashed ring's disc hides it. * * Seen from the front, a circle in depth is a line: the ring's place along that line goes as the * sine of its angle around the orbit, so it slows at both ends and is quickest as it passes in * front. Its size goes with its depth, as perspective makes it: 1 / (1 - DEPTH × cos angle). * * Everything is in shares of the loader's size (the dashed ring's outer diameter), so the same * keyframes serve every size. The ring is drawn at the loader's full size (its finished state) * and scaled down to orbit. *//** One lap, in ms. */export const PERIOD = 2000;/** The dashed ring turns slowly anticlockwise meanwhile, once every seven laps. */export const SPIN = PERIOD * 7;/** How far the ring swings from the middle, as a share of the loader's size. */const REACH = 0.847;/** The ring's diameter at either end of its swing, as a share of the loader's size. */const SIZE = 0.432;/** How strongly depth scales it: about 1.36 × in front, 0.79 × behind. */const DEPTH = 0.265;
use-orbit.ts
'use client';import { useEffect, useRef } from 'react';import {  GLOW_KEYFRAMES,  GLOW_START,  PERIOD,  SETTLED,  SPIN,  START,  orbitKeyframes,  transformOf,} from './orbit';import { easingOf, spring } from './spring';/* * The loader's motion, with the Web Animations API. While loading, the laps play: both copies of * the bright ring and the glow, on one clock, and the dashed ring turns slowly; all of it pauses * while the loader is off screen. Finished, the dashed ring holds still, the bright ring leaves * its orbit from wherever it is drawn and grows over it, and the check pops up. Running again, * the check goes, the ring shrinks back out to where a lap starts, and the laps take over. * * Each move starts from what is on screen (the computed styles), so a change in the middle of * another one carries on from there. Under reduced motion, every part is simply set.
spring.ts
/* * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first, * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws * any curve through a list of points, so the spring's motion is sampled once, and the same curve * serves CSS transitions and the Web Animations API. */export type Spring = {  /** The curve, as a CSS `linear()` easing. */  easing: string;  /** How long it takes to settle, in ms: the duration to play the curve over. */  duration: number;};/** Points sampled along the curve: plenty for a smooth motion. */const SAMPLES = 40;/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring {  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.  const damping = 1 - bounce;
demo-pointer.tsx
'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 button and press it, calling the component's own handler 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 needs a layout: it moves by animations the compositor plays, and the * press scales a box that keeps a transform at rest (a box that gains or loses one is laid out * again). */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`, a little below and right of its middle. */  moveTo: (target: Element | null | undefined) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;
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 card is a group named by its title. It has aria-busy while loading, and the loader is an image labelled “In progress” or “Done”.
  • Finish and Run again is a real button with a visible focus ring. When the reader presses it, “Start of week planning is done.” or “…is running again.” is announced in a polite live region; the demo’s presses stay silent.
  • When keyboard focus is on the stage, the demo leaves it alone, and the reader’s first press or key stops the demo until they leave it alone.
  • Under reduced motion the ring stands still at the top of its swing and the dashes don’t turn; finishing shows the full ring and its check at once, and the demo never plays. Without JavaScript the resting frame, glow included, is drawn on the server.