Thinking orbs
NewTen small animated dot orbs that say an assistant is busy, and how, with a gallery of them in pills and a playground.
thinking-orbs/thinking-orbs.tsxPlayground
import { ThinkingOrb } from './thinking-orbs';
<ThinkingOrb state="listening" size={64} />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 that is busy for a few seconds and can say how: searching, writing, listening, connecting, solving.
- A status line in a chat, beside a few words: a 20 px orb in a pill reads at text size, a 56 px one heads a turn.
- Work whose kind changes while it runs: a change of
statemorphs the dots in 700 ms instead of swapping icons. - A voice or agent surface that needs one quiet sign of life, in the page’s ink or a named one, in light or dark.
When not to use
- Progress you can measure. Use a progress bar or a percentage, so people know how long is left.
- A wait on a button or a form field. Use the button’s own busy state, with a plain spinner at text size.
- Waits under half a second: the orb never settles and reads as a flicker. Show nothing, or the result.
- Many busy rows at once, in a table or a list: a column of moving globes competes for attention. Use a static status per row.
- Errors and stopped work: every state means busy. Say what failed, or that it stopped, in words.
You can add this component with the shadcn CLI or by hand:
Into components/thinking-orbs of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/thinking-orbs.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 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 { ThinkingOrb } from './thinking-orbs';export function AssistantStatus({ busy }: { busy: 'searching' | 'composing' | null }) { if (!busy) return null; return ( <p className="status"> <ThinkingOrb state={busy} size={20} label="" /> {busy === 'searching' ? 'Searching the docs…' : 'Writing…'} </p> );}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.
Loading indicators for an AI interface, drawn in dots on a canvas: a lattice globe that tumbles while solving, a globe swept by a beam while searching, one that squashes to a voice while listening, a wired cloud while connecting, a hollowed shell under a moving waterline while composing, orbits while working, a ring of dashes that breathes, rings that weave, an outline that turns from triangle to square to circle, and a quiet ring for thinking. A change of state morphs: the dots glide to their new places. Below them, a playground tries every state with its size, speed, colour, dot count, dot size and a pull toward the cursor.
- Each state is a painter: a function of time that writes the frame’s dots (x, y, size, alpha, in units of the orb’s radius) into flat typed arrays reused every frame. Painters keep no state, so any moment can be drawn: a still frame under reduced motion, or the old state while it morphs into the new one.
- The globes are points on latitude rings, as many on a ring as its girth allows, turned by a yaw and a pitch with a light perspective; the far side fades or hides, and nearer dots draw bigger and brighter.
- Solving tumbles the lattice a quarter turn at a time, every 1.2 s with an ease in and out: twelve turns about x, y or z and then their undoing, so it comes back to where it began. Mid-turn the dots scatter a little and settle back on the lattice.
- Searching lights one patch at a time: a beam picks a new spot on the front every 0.72 s and rises and falls there, the dots under it growing and brightening. Listening squashes and stretches the globe with a voice level made of three sines, while a bright band runs up and down its rings.
- Composing is a dense shell of meridians stopped short of the poles. A waterline across the view, high in the middle and low at the sides, ripples with two sines: above it the near side is lit, below it only the inside of the far side shows, dim, so the shell reads as a bowl.
- Connecting wires each of about seventy nodes on a sphere to its three nearest, once, and turns the whole cloud about a tilted axis; lines and nodes fade with depth. Working runs short trains of nodes round thirteen tilted orbits, each orbit traced in faint specks.
- Breathing is a ring of 56 short dashes on a blob whose radius is three sines of the angle drifting in time; thinking is the same idea in 20 dots; weaving turns three rings of dots against each other, each dot swinging in and out across its neighbours; shaping walks 24 dots round a triangle, a square and a circle, 1.07 s each, and morphs between them in 0.36 s.
- A change of state runs both painters for 700 ms: dot i of the old one glides to dot i of the new one, size and alpha blending too, and dots without a partner fly from (or to) one while fading. Lines cross-fade.
- The canvas is half the orb’s size bigger than its box on each side, so dots pulled toward the cursor (gravity: within 2.5 sizes, by up to 0.3 of a size, eased in and out) are not cut off; the box still takes only `size` in the layout. It is sharp at up to twice the pixel ratio, and smaller orbs thin their globes out so they stay legible.
- The dots are drawn in the canvas’s CSS colour, read back each frame, so the colour prop, a theme or a scheme change shows at once, transition included. Each orb draws only while on screen.
- The pills’ words are dim, with a band of light that runs through them every 2 s: a copy of the text on top, clipped to a gradient four times its width that slides from right to left.
- While on screen and untouched, the playground walks through the states, 3.2 s on each; any press or key in it hands it to the reader for 8 s.
| Prop | Type | Default | Description |
|---|---|---|---|
state | 'working' | 'searching' | 'solving' | 'listening' | 'connecting' | 'composing' | 'breathing' | 'weaving' | 'shaping' | 'thinking' | 'thinking' | ThinkingOrb: what the orb shows. A change morphs from the old state to the new one in 700 ms. |
size | number | 64 | ThinkingOrb: its width and height in px. Tuned at 64, 32 and 20; smaller orbs draw fewer dots on their globes. |
color | 'mono' | 'sky' | 'amber' | 'rose' | 'mint' | string | 'mono' | ThinkingOrb: a named ink, each with a light and a dark shade, or any CSS colour. The showcase’s color prop sets it for every orb. |
speed | number | 1 | ThinkingOrb: how fast it moves; 1 is the tuned pace. |
dotSize | number | 1 | ThinkingOrb: how big its dots are; 1 is the tuned size. |
density | number | 1 | ThinkingOrb: how many dots, as a share of the state’s own count. |
gravity | boolean | false | ThinkingOrb: whether the dots lean toward a nearby cursor. |
paused | boolean | false | ThinkingOrb: hold a still frame. |
label | string | — | ThinkingOrb: what screen readers hear (the state’s name by default); an empty string hides the orb from them, for when words next to it say the same. |
defaultState | OrbState | 'listening' | ThinkingOrbs (the showcase): the state the playground starts on. |
gallery | boolean | true | ThinkingOrbs: whether to show the gallery of pills above the playground. |
autoplay | boolean | true | ThinkingOrbs: whether the playground walks through the states on its own, while on screen and untouched. |
colorScheme | 'light' | 'dark' | 'system' | — | ThinkingOrbs: the colour scheme; without it, the page’s. |
className | string | — | A class for the root, on either component. |
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, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';import { ThinkingOrb, type OrbColor } from './orb';import { STATE_LABELS, STATES, type OrbState } from './states';import { useAutoplay } from './use-autoplay';import styles from './thinking-orbs.module.css';export { ThinkingOrb, type OrbColor, type ThinkingOrbProps } from './orb';export { STATES, type OrbState } from './states';/* * Thinking orbs: small animated dot drawings that say an assistant is busy, and how: working, * searching, solving, listening, connecting, composing, breathing, weaving, shaping or thinking. * Each is a canvas of dots (orb.tsx) that a painter (states.ts) lays out frame by frame: globes * of latitude rings that turn, squash or tumble, a wired cloud, orbits, rings and outlines. * * - This file is the showcase: a gallery of orbs in pills, as they would sit in a chat, their * words lit by a band of light that runs through them; and a playground, where the state, the * size, the cursor's pull, the speed, the colour, the number and size of the dots can be tried, * with the code to match. * - A change of state morphs: the old dots glide to the new ones' places in 700 ms.// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, type CSSProperties } from 'react';import { easeInOut, Frame, PAINTERS, STATE_LABELS, type OrbState } from './states';import styles from './thinking-orbs.module.css';/* * One thinking orb: a canvas that a state's painter (states.ts) fills with dots every frame. * * - The painter gives dots in the orb's own units (radius 1); the orb maps them to pixels, sized * by the size prop, sharp on a dense screen, in the colour of its CSS `color`, read back each * frame so a theme or scheme change (and its CSS transition) shows at once. * - A change of state morphs: for 700 ms both painters run, and dot i of the old one glides to dot * i of the new one, its size and alpha blending too; dots one has and the other lacks fade in or * out from (or to) a partner. Lines cross-fade. * - The canvas is bigger than the orb's box by half its size, so dots pulled by the cursor * (gravity) are not cut at the edge; the box still takes only `size` in the layout. * - It draws only while on screen, and holds one still frame under reduced motion or when paused. */export type OrbColor = 'mono' | 'sky' | 'amber' | 'rose' | 'mint';// Copyright (c) 2026 Bruno Delorence. MIT License./* * The orb's states, each a painter: given the time, it writes the frame's dots (and, for some, * lines) into a Frame. Everything is in the orb's own units: the sphere's radius is 1, the * middle is 0,0, y points down. A dot's size is in "dot units" (1 is the base dot, which the * orb scales with its size and the dotSize prop) and its alpha runs from 0 to 1. * * Painters keep no state between frames: the same time draws the same frame, so a state can be * drawn still (reduced motion) or morphed from another one (transitions) at any moment. */export const STATES = [ 'working', 'searching', 'solving', 'listening', 'connecting', 'composing', 'breathing', 'weaving', 'shaping', 'thinking',] as const;/* Thinking orbs (thinking-orbs.tsx): a gallery of orbs in pills on quiet cells, and a playground with a stage and a panel of controls. At night the page is near-black and the cells a step lighter; by day the cells are white on a pale grey. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page, or the one its colorScheme prop sets (data-color-scheme). An orb draws its dots in its CSS `color`, read back from the canvas. */.root { /* The page, the cells, the controls' panel. */ --page: #f6f6f6; --cell: #fff; --cell-edge: rgb(0 0 0 / 0.05); --panel: #fff; --panel-lift: rgb(0 0 0 / 0.06); --panel-edge: rgb(0 0 0 / 0.07); /* The pills an orb sits in, and their words with the light that runs over them. */ --pill: rgb(0 0 0 / 0.025); --pill-edge: rgb(0 0 0 / 0.07); --words: rgb(20 20 20 / 0.5); --shine: #000; /* The controls. */ --heading: #171717; --label: #767676;'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;- An orb is an image named for its state (“Searching…”), or hidden from screen readers with label="" when the words beside it already say it; the canvas itself is always hidden.
- The playground’s choices are radio groups of buttons: Tab reaches the chosen one, the arrows (and Home, End) move and choose. Speed, dots amount and dot size are real range inputs with their values spoken; the colours are a radio group of named swatches.
- A state the reader picks is announced politely; the demo’s own changes are not, so it never chatters.
- Focus rings show for keyboard use. The play button says whether the orb is paused (aria-pressed).
- Under reduced motion the orbs hold a still frame, states change without a morph, the words’ light stops and the demo never plays.