Skip to content

AI loader

A pixel-grid shimmer with elapsed seconds for work an AI model is doing.

Status
Beta
Level
Atom
Category
AI
Adoption
Not used yet
import { AILoader } from "@oration/canon/components/ai/ai-loader";
packages/canon/src/components/ai/ai-loader.tsx

When does the next ACH run go out?

Searching documents…
import { AILoader } from "@oration/canon/components/ai/ai-loader";import * as React from "react";export function Hero() {    const [startedAt] = React.useState(() => Date.now());    const [answered, setAnswered] = React.useState(false);    React.useEffect(() => {        const id = window.setTimeout(() => setAnswered(true), 3400);        return () => window.clearTimeout(id);    }, []);    return (        <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border">            <p className="self-end rounded-xl bg-muted px-3 py-2 text-13 text-foreground">                When does the next ACH run go out?            </p>            {answered ? (                <p className="text-13 leading-5 text-pretty text-foreground">                    ACH runs go out every Tuesday and Friday. The next one is                    Tuesday, September 29, so invoices approved by Monday at                    5:00 PM CT are paid then.                </p>            ) : (                <AILoader                    label="Searching documents…"                    startedAt={startedAt}                    size="sm"                />            )}        </div>    );}

Usage#

AI loader is the house wait state for work a model is doing: a small pixel grid that ripples, a shimmering label that names the work and the seconds elapsed. It fills the gap between a request and its first output, then gets replaced in place by the result. The common mistake is using it for ordinary data loading. When the shape of the result is known (a list, a table, a card), load it with a skeleton; the AI loader is for work whose output doesn't exist yet.

When to use

  • While a model searches, reads or drafts and nothing has streamed yet: Searching documents…, Reading the call…
  • In the slot where the answer will appear, so the result replaces it without a layout jump.
  • When the wait can run past a couple of seconds and the elapsed count reassures people that it is still working.
  • As a PixelGrid alone inside a compact affordance whose text already says what is happening, such as a pending field action or a Rewriting… bar.
  • With useElapsed and formatElapsed when a custom row needs the same ticking seconds, such as a running campaign.

When not to use

  • To load records, rows or cards whose shape you already know. Use Skeleton
  • For a generic busy glyph inside a button or a non-AI request. Use Spinner
  • When the work has visible steps a person may want to read. Use Thinking
  • For a plan whose rows tick through queued, running and done. Use Task list
  • For work with a known amount done, such as an import at 40%. Use Progress
  • For the state of a live voice call. Use Voice orb

The Quiet Indigo Rule

The grid is drawn in ink by default. Indigo is for the primary action, selection, focus and labelled live state, so an indigo pixel grid reads as decoration. Keep tone at ink or muted.

Shimmer only while working

The shimmering label is the one gradient-clipped text Canon allows, and only on an in-progress label. When the work finishes, the loader leaves and the result takes its place. Never leave a shimmer on finished text.

Anatomy#

Reading the call…
  1. Pixel grid. A 3×3, 4×4 or 5×5 grid of square cells with 1px corners. Opacity ripples diagonally across it on a 1.2s cycle.
  2. Label. The live status, in medium weight with the text-shimmer sweep. It names the work: a verb in -ing form, the object and an ellipsis.
  3. Elapsed. Whole seconds in Slate Meta tabular figures, 12s then 1m 14s. Hidden from screen readers so it doesn't announce every tick.

Examples#

Sizes

sm for panels, rows and chat turns; md (the default) for a section waiting on its content; lg when the loader stands alone in an empty region.

Reading the call…
Reading the call…
Reading the call…
import { AILoader } from "@oration/canon/components/ai/ai-loader";export function Sizes() {    return (        <div className="flex flex-col items-start gap-5">            <AILoader size="sm" label="Reading the call…" />            <AILoader size="md" label="Reading the call…" />            <AILoader size="lg" label="Reading the call…" />        </div>    );}

Tones

Ink is the default and the right choice almost everywhere. Muted sits quieter inside dense panels. Primary exists but spends indigo on decoration, so avoid it.

Drafting a reply…
Drafting a reply…
Drafting a reply…
import { AILoader } from "@oration/canon/components/ai/ai-loader";export function Tones() {    return (        <div className="flex flex-col items-start gap-5">            <AILoader tone="ink" label="Drafting a reply…" />            <AILoader tone="muted" label="Drafting a reply…" />            <AILoader tone="primary" label="Drafting a reply…" />        </div>    );}

A label that follows the work

Change label as the work moves forward and keep startedAt fixed, so the counter keeps running. When the result is ready, it replaces the loader in place.

Reading the invoice…
import { AILoader } from "@oration/canon/components/ai/ai-loader";import * as React from "react";export function ChangingLabel() {    const steps = [        "Reading the invoice…",        "Checking the payment run…",        "Matching the remittance…",        "Writing the reply…",    ];    const [startedAt] = React.useState(() => Date.now());    const [step, setStep] = React.useState(0);    const current = steps[step];    React.useEffect(() => {        if (!current) return;        const id = window.setTimeout(() => setStep((n) => n + 1), 1500);        return () => window.clearTimeout(id);    }, [current]);    return (        <div className="flex min-h-5 w-full max-w-sm items-center">            {current ? (                <AILoader label={current} startedAt={startedAt} />            ) : (                <p className="text-13 text-foreground">                    Reply drafted for Northwind Freight. Review it before                    sending.                </p>            )}        </div>    );}

Elapsed time

Past a minute the counter reads minutes and seconds. For waits that are always short, turn it off with showElapsed={false}.

Summarizing 212 invoices…
Checking the W-9 on file…
import { AILoader } from "@oration/canon/components/ai/ai-loader";import * as React from "react";export function Elapsed() {    const [longAgo] = React.useState(() => Date.now() - 74_000);    return (        <div className="flex flex-col items-start gap-5">            <AILoader label="Summarizing 212 invoices…" startedAt={longAgo} />            <AILoader label="Checking the W-9 on file…" showElapsed={false} />        </div>    );}

Pixel grid alone

The grid at its three sizes, rippling and at rest, and inside a pending bar where the text beside it names the work. The grid is always hidden from screen readers.

Rewriting…
import { PixelGrid } from "@oration/canon/components/ai/ai-loader";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function GridOnly() {    const [state, setState] = React.useState<"working" | "canceled">("working");    return (        <div className="flex flex-col items-center gap-6">            <div className="flex items-end gap-5">                <PixelGrid size="sm" />                <PixelGrid size="md" />                <PixelGrid size="lg" />                <span className="h-7 w-px bg-border" aria-hidden="true" />                <PixelGrid size="sm" active={false} />                <PixelGrid size="md" active={false} />                <PixelGrid size="lg" active={false} />            </div>            {state === "working" ? (                <div                    role="status"                    className="flex h-8 items-center gap-2 rounded-[10px] bg-popover pr-1 pl-2.5 shadow-popover"                >                    <PixelGrid size="sm" />                    <span className="text-13 text-shimmer">Rewriting…</span>                    <Button                        type="button"                        variant="ghost"                        size="xs"                        onClick={() => {                            setState("canceled");                            toast.add({                                title: "Rewrite canceled",                                description: "The original text is unchanged.",                            });                        }}                    >                        Cancel                    </Button>                </div>            ) : (                <Button                    type="button"                    variant="outline"                    size="sm"                    onClick={() => setState("working")}                >                    Rewrite again                </Button>            )}        </div>    );}

A custom readout

useElapsed and formatElapsed give any row the same ticking seconds. Here a running outbound campaign pauses and resumes without losing its count.

Overdue W-9 collectionCalling 48 suppliers, 0s
import { formatElapsed, PixelGrid, useElapsed } from "@oration/canon/components/ai/ai-loader";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function CustomReadout() {    const [startedAt, setStartedAt] = React.useState(() => Date.now() - 38_000);    const [running, setRunning] = React.useState(true);    const seconds = useElapsed(startedAt, running);    return (        <div className="flex w-full max-w-md items-center gap-3 rounded-xl bg-card px-4 py-3 text-left shadow-border">            <PixelGrid size="md" active={running} />            <div className="flex min-w-0 flex-1 flex-col">                <span className="text-13 font-medium text-foreground">                    Overdue W-9 collection                </span>                <span className="text-xs text-muted-foreground tabular-nums">                    {running                        ? `Calling 48 suppliers, ${formatElapsed(seconds)}`                        : `Paused after ${formatElapsed(seconds)}`}                </span>            </div>            <Button                type="button"                variant="outline"                size="sm"                onClick={() => {                    if (running) {                        setRunning(false);                        toast.add({                            title: "Campaign paused",                            description: "Calls in progress finish first.",                        });                    } else {                        setStartedAt(Date.now() - seconds * 1000);                        setRunning(true);                    }                }}            >                {running ? "Pause" : "Resume"}            </Button>        </div>    );}

States#

Working
Searching documents…
No counter
Searching documents…
Past a minute
Searching documents…
Reduced motion
Searching documents…12s
import { AILoader, PixelGrid } from "@oration/canon/components/ai/ai-loader";import * as React from "react";export function StatesMatrix() {    const [startedAt] = React.useState(() => Date.now() - 12_000);    const [longAgo] = React.useState(() => Date.now() - 74_000);    return (        <div className="grid w-full grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2">            <div className="flex flex-col gap-2">                <span className="text-xs text-muted-foreground">Working</span>                <AILoader label="Searching documents…" startedAt={startedAt} />            </div>            <div className="flex flex-col gap-2">                <span className="text-xs text-muted-foreground">                    No counter                </span>                <AILoader label="Searching documents…" showElapsed={false} />            </div>            <div className="flex flex-col gap-2">                <span className="text-xs text-muted-foreground">                    Past a minute                </span>                <AILoader label="Searching documents…" startedAt={longAgo} />            </div>            <div className="flex flex-col gap-2">                <span className="text-xs text-muted-foreground">                    Reduced motion                </span>                <div className="inline-flex items-center gap-2 text-13">                    <PixelGrid active={false} />                    <span className="font-medium text-muted-foreground">                        Searching documents…                    </span>                    <span className="text-muted-foreground tabular-nums">                        12s                    </span>                </div>            </div>        </div>    );}
States
StateTreatment
WorkingThe grid ripples, the label shimmers and the counter ticks once a second from startedAt or from mount.
Label changePass a new label as the work moves on. The status region announces the new text once.
No countershowElapsed={false} hides the seconds and stops the interval. Use it for waits that are always short.
Past a minuteformatElapsed switches to minutes and seconds: 1m 14s.
Resting gridPixelGrid active={false} holds a fixed checker of four opacities, so it still reads as pixels rather than a block.
Reduced motionThe grid holds its resting pattern and the label shimmer stops. The label and counter still update.

Behavior#

  • The grid animates each cell's opacity with the Web Animations API (0.16 to 1 and back, 1.2s, on the house ease-in-out). It keeps running smoothly while the main thread is busy rendering a stream.
  • Each cell starts 90ms after its diagonal neighbour, with a negative delay so the ripple is already in motion on the first frame.
  • useElapsed(startedAt, running) reads whole seconds since startedAt (epoch ms), or since mount when it is left out, and ticks with a one-second interval while running is true.
  • Pass startedAt when the loader might remount, for example when a skeleton swaps for another state, so the counter doesn't restart at 0s.
  • Replace the loader with the result in the same place. The loader has no exit animation of its own.
  • Under reduced motion the ripple doesn't start and .text-shimmer stops through the global reduced-motion rule. Nothing else changes.

Do and don't#

Checking payment PMT-58213…
Do. Name the work in the label, so the wait explains itself: Checking payment PMT-58213…
Loading…
Don't. Use a generic label such as Loading… or Please wait. It says nothing a spinner doesn't.
Do. Load a known shape, such as the supplier list, with a skeleton that matches it.
Thinking…
Don't. Put the AI loader where a list or table is loading. It promises model work that isn't happening.
Drafting a reply…
Do. Show one loader in the slot where the result will appear.
Drafting a reply…

Please wait

Don't. Stack a spinner, a pixel grid and a Please wait line. Three indicators for one wait is noise.

Content#

  • Write the label as a verb in -ing form plus its object, ending in a single ellipsis character: Searching documents…, Reading the call…, Drafting a reply…
  • Be specific when you can: Checking payment PMT-58213… beats Thinking… (the default).
  • Keep it to one short line. It truncates nowhere, so a long label wraps and pushes content down.
  • Don't put the time in the label. The counter already shows it.
  • Change the label as the work moves forward, no more often than every second or so. Past tense belongs to the result, not the loader.

Accessibility#

  • The loader is a role="status" region, so its label is announced politely and again whenever it changes.
  • The elapsed counter is aria-hidden, so screen readers aren't interrupted every second.
  • PixelGrid is always aria-hidden. When you use it alone, the text beside it or the control's aria-label has to say what is happening, as the pending AI field action does with Generating a draft.
  • Under reduced motion the grid is static and the shimmer stops. The label carries the state on its own, which is why it must always be present.
  • When the result arrives, move focus only if the person asked for it. Otherwise let the result render in place.

Design tokens#

Design tokens
TokenUsed for
--foregroundGrid cells at ink; the bright band of the shimmer
--muted-foregroundBase color of the shimmer, the elapsed counter and the muted grid
--primaryGrid cells at tone="primary" (avoid)
text-shimmerGradient-clipped label sweep, stopped under reduced motion
--animate-shimmer1.6s linear sweep behind text-shimmer
--ease-in-outEasing of the cell ripple, cubic-bezier(0.77, 0, 0.175, 1)
text-13Label and counter at md; text-xs at sm, text-sm at lg

API reference#

AILoader

The grid, label and counter in one role="status" row.

Other props spread onto Nothing. Only the props below are read..

Props of AILoader
PropTypeDefaultDescription
labelstring"Thinking…"The live status. Name the work.
startedAtnumberNo defaultEpoch ms the work began. Defaults to when the loader mounted.
size"sm" | "md" | "lg""md"Grid of 3, 4 or 5 cells a side, with 12, 13 or 14px text. sm suits panels and rows.
tone"ink" | "muted" | "primary""ink"Grid color. Keep ink; primary spends indigo on decoration.
showElapsedbooleantrueShows the seconds counter and runs its interval.
classNamestringNo defaultMerged onto the row.

PixelGrid

The rippling grid on its own. Always aria-hidden.

Other props spread onto Nothing.

Props of PixelGrid
PropTypeDefaultDescription
size"sm" | "md" | "lg""md"11, 16.5 or 28px square.
tone"ink" | "muted" | "primary""ink"Cell color.
activebooleantrueRuns the ripple. False holds the resting pattern.
classNamestringNo defaultMerged onto the grid.

useElapsed

Hook. Returns whole seconds since startedAt, ticking once a second.

Props of useElapsed
PropTypeDefaultDescription
startedAtnumber | undefinedNo defaultFirst argument. Epoch ms; defaults to when the calling component mounted.
runningbooleantrueSecond argument. False reads the value once and stops ticking.
returnsnumberNo defaultElapsed whole seconds.

formatElapsed

Formats seconds as 12s or 1m 14s.

Props of formatElapsed
PropTypeDefaultDescription
secondsRequirednumberNo defaultSeconds to format.
returnsstringNo defaultThe formatted duration.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Only one product screen uses AILoader: the knowledge base question panel. Other AI waits hand-roll the look with a text-shimmer span (Checking which skill fits… in the skill test panel, Summarizing… in the ticket composer), a spinning refresh icon (Analyzing… in analysis settings) or a Spinner (Starting a chat with… in the web call). Use AILoader for these.

tone="primary" draws indigo pixels. Nothing uses it, and under the Quiet Indigo Rule it is decoration.

formatElapsed has no hour unit: at 3,600 seconds it reads 60m 0s.

The loader has no exit transition, so the swap to the result is instant. That is fine for text, but a result much taller than the loader will push content down in one step.