AI loader
A pixel-grid shimmer with elapsed seconds for work an AI model is doing.
When does the next ACH run go out?
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
PixelGridalone inside a compact affordance whose text already says what is happening, such as a pending field action or a Rewriting… bar. - With
useElapsedandformatElapsedwhen 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
tone at ink or muted.Shimmer only while working
Anatomy#
- 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.
- Label. The live status, in medium weight with the
text-shimmersweep. It names the work: a verb in -ing form, the object and an ellipsis. - 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.
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.
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.
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}.
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.
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.
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#
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> );}| State | Treatment |
|---|---|
| Working | The grid ripples, the label shimmers and the counter ticks once a second from startedAt or from mount. |
| Label change | Pass a new label as the work moves on. The status region announces the new text once. |
| No counter | showElapsed={false} hides the seconds and stops the interval. Use it for waits that are always short. |
| Past a minute | formatElapsed switches to minutes and seconds: 1m 14s. |
| Resting grid | PixelGrid active={false} holds a fixed checker of four opacities, so it still reads as pixels rather than a block. |
| Reduced motion | The 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 sincestartedAt(epoch ms), or since mount when it is left out, and ticks with a one-second interval whilerunningis true.- Pass
startedAtwhen 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-shimmerstops through the global reduced-motion rule. Nothing else changes.
Do and don't#
Please wait
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. PixelGridis alwaysaria-hidden. When you use it alone, the text beside it or the control'saria-labelhas 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#
| Token | Used for |
|---|---|
--foreground | Grid cells at ink; the bright band of the shimmer |
--muted-foreground | Base color of the shimmer, the elapsed counter and the muted grid |
--primary | Grid cells at tone="primary" (avoid) |
text-shimmer | Gradient-clipped label sweep, stopped under reduced motion |
--animate-shimmer | 1.6s linear sweep behind text-shimmer |
--ease-in-out | Easing of the cell ripple, cubic-bezier(0.77, 0, 0.175, 1) |
text-13 | Label 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..
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Thinking…" | The live status. Name the work. |
startedAt | number | No default | Epoch 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. |
showElapsed | boolean | true | Shows the seconds counter and runs its interval. |
className | string | No default | Merged onto the row. |
PixelGrid
The rippling grid on its own. Always aria-hidden.
Other props spread onto Nothing.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | 11, 16.5 or 28px square. |
tone | "ink" | "muted" | "primary" | "ink" | Cell color. |
active | boolean | true | Runs the ripple. False holds the resting pattern. |
className | string | No default | Merged onto the grid. |
useElapsed
Hook. Returns whole seconds since startedAt, ticking once a second.
| Prop | Type | Default | Description |
|---|---|---|---|
startedAt | number | undefined | No default | First argument. Epoch ms; defaults to when the calling component mounted. |
running | boolean | true | Second argument. False reads the value once and stops ticking. |
returns | number | No default | Elapsed whole seconds. |
formatElapsed
Formats seconds as 12s or 1m 14s.
| Prop | Type | Default | Description |
|---|---|---|---|
secondsRequired | number | No default | Seconds to format. |
returns | string | No default | The 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.