Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

World status

Advanced

The progress widget of a world generator: a card with a live WebGL terrain that folds into an orb of liquid colour.

world-status/world-status.tsx
Live · interactive

Building your world…

“Snowy peaks over a warm valley”

Activity

  1. Raising the land, done
  2. Carving the rivers…
  3. Naming every peak
38%
Step 2 of 3
Orrery 2Balanced

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

  • A long generation (a 3D scene, a video, a big render) that runs for seconds to minutes, where a picture of the result taking shape says more than a bar.
  • Work that goes through a few named steps, two to five, worth reading as they pass: Raising the land, Carving the rivers.
  • A build the reader should be free to put aside: Hide folds the card into a small orb that keeps the percentage and the step, beside the composer.
  • A creative tool where the next idea is typed while the last one finishes, so the status and the composer belong together.

When not to use

  • Short waits, under two or three seconds. A spinner or a pressed button’s own busy state is enough.
  • Uploads, downloads or anything measured in bytes. A plain progress bar with sizes and time left reads faster.
  • Several jobs at once. Use a list of rows, each with its own small progress, rather than one card per job.
  • Pages that can’t spare WebGL: each instance holds two contexts. Without WebGL it falls back to still sketches, so don’t pick it for those.
  • Steps with no real meaning behind them. Made-up stages read as theatre; show a percentage alone instead.

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

Into components/world-status of any React project, with no setup:

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

build-panel.tsx
import { WorldStatus } from './world-status';// The status card, its composer and a demo that plays while it is on screen.// The steps, the ideas it types and the levels of detail are in worlds.ts.export function BuildPanel() {  return <WorldStatus duration={15000} />;}

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.

The status of a tool that builds 3D worlds from a sentence. Open, a card lists the build’s steps beside a terrain that rises out of the sea while colour flows up its slopes; hidden, it folds into a gooey orb that keeps the percentage beside the composer. Send an idea and a new world is built, and the same idea always builds the same world.

  • The terrain is a grid of triangles that a vertex shader lifts into a mountain and two hills: soft cones placed by a seed (a hash of the prompt), roughened by ridged noise. Land below sea level is discarded, so the peaks rise out of the card as the build progresses.
  • Its fragment shader draws a topographic model. Contour lines stay a pixel and a half wide on any slope, measured with screen-space derivatives. On the mountain, bands of blue, cream and orange are indexed by height minus time, so they flow up the slopes, up to a front of colour that climbs with the build.
  • The orb is one fragment shader over the compact card. Two circles are melted together by a smooth minimum of their distances, which grows the gooey neck between them, and filled with noise warped twice by noise so it marbles, with a pale rim and a faint contour map behind.
  • The fold is CSS. The card’s background is one rectangle clipped by an inset() clip-path that transitions between the two cards; the percentage and the step counter are the same elements in both and glide by transform. The terrain shrinks into the orb while the orb’s small circle comes out of the big one, on the same easing curve.
  • One requestAnimationFrame loop advances the build and draws, only while the widget is near the screen and something moves. The canvases cap the pixel ratio at 2 and give their pixels back off screen. Without WebGL or JavaScript, CSS sketches stand in.
  • Every colour is a light-dark() token on the root, so the widget follows the page’s color-scheme, or the colorScheme prop: a white card and composer edged with a hairline by day, dark ones at night, on the same light stage. The terrain keeps its own colours; the orb’s shader reads its rim, the inside of its small circle and its map lines from the tokens, again when the root’s unseen colour transition ends.
PropTypeDefaultDescription
stepsreadonly string[]STEPSThe steps of a build, in order: two to five short labels (three in worlds.ts). The card grows with them.
durationnumber12000How long a world takes to build at the Balanced level of detail, in ms. Draft takes 0.7 of it, Detailed 1.4.
defaultOpenbooleantrueWhether it starts open, as the card, or folded into the orb.
defaultState'thinking' | 'building' | 'ready'—Opens in this phase of the first build, held still there with the demo off until the reader sends an idea.
defaultProgressnumber—The percentage, 0 to 99, to hold the build at; it implies defaultState 'building'.
defaultValuestring—An idea already typed in the composer. Turns the demo off.
defaultLevel'draft' | 'balanced' | 'detailed'—The level of detail picked in the composer at first ('balanced' when left out). Given, it turns the demo off.
onSend(idea: string) => void—Called with the idea when the reader sends one, as a new world starts building.
onOpenChange(open: boolean) => void—Called when the card folds into the orb (false) or opens out of it (true).
onLevelChange(level: LevelId) => void—Called when the reader picks another level of detail.
autoplaybooleantrueFold the card mid-build, then type and send the next idea, while visible and untouched.
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 light 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.

world-status.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';import {  FOLD,  createBuilder,  progressAt,  statusAt,  type Builder,  type Phase,  type Status,} from './builder';import { Composer } from './composer';import { CheckIcon, ChevronIcon } from './icons';import { ORB } from './orb';import { useAutoplay } from './use-autoplay';import { FIRST_IDEA, IDEAS, LEVELS, STEPS, worldOf, type World } from './worlds';import styles from './world-status.module.css';/* * World status: the progress widget of a tool that builds 3D worlds from a sentence. Open, it is a * card that lists the build's steps beside a live terrain, rising out of the sea as the build
world-status.module.css
/* World status (world-status.tsx): a light stage with the widget in its middle, a card over the   composer that folds into a compact card. Every size and place is a custom property on the   frame, set for the narrow layout and again from 600px (a container query on the widget's own   width), so the layout and the fold between its two states are plain CSS transitions.   Colours are the tokens below, light-dark(light, dark): the widget 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). By day the card and the composer are white, edged with a hairline; at   night they are dark. The stage is the same on both, and so is the terrain, a model in its own   colours. The orb's rim, the inside of its small circle and the map behind it are tokens that   its shader reads (orb.ts). */.root {  --stage: #fcfcfc;  /* The card and the composer, their edge (a hairline by day), and what is raised on them: the     percentage, Add images, a button under the pointer. */  --card: #fff;  --card-edge: rgb(0 0 0 / 0.1);  --raised: #f1f1f3;  /* The words: titles and values, then labels, then the next steps, captions and placeholder. */  --ink: #18181b;  --muted: #52525b;  --faint: #71717a;  /* The title's passing sheen while a world builds, and the composer's rule and divider. */
composer.tsx
import { useId } from 'react';import { ArrowUpIcon, ChevronIcon, MicIcon, PlusIcon } from './icons';import { LEVELS, type Level } from './worlds';import styles from './world-status.module.css';/* * The chat composer under the status: a field for the idea, images to add (in this demo they are * counted, never read), the level of detail, a voice toggle (its pressed state only) and the send * button. Sending (the button, or Enter in the field) builds a world. * * The level picker is a native <select>, laid invisibly over its label: it opens the system's own * list, on every device, and the label shows what it holds. */export function Composer({  value,  onChange,  onSend,  level,  onLevel,  images,  onImages,  listening,  onListen,
builder.ts
import { createOrb, type Orb } from './orb';import { createTerrain, type Terrain } from './terrain';/* * The build behind the widget: a clock that turns time into progress, and the one animation frame * loop that draws the terrain and the orb from it. * * - A build thinks for a moment (the last world sinks back into the sea), then builds: its progress *   runs through the steps, each quick at first and slower as it ends, while the land rises and *   colour flows up the mountain. Then it is ready, and the flow settles to a stop. * - The loop runs only while the widget is near the screen (and the tab visible), and only while *   something moves: a build, the flow settling, or the card folding into the orb or opening out *   of it. At rest it does nothing. * - The canvases give their pixels back (they are sized to 0 × 0) when the widget leaves the *   screen, and get them back, redrawn, as it returns. On screen they keep them, so a fold costs *   no new buffers: the canvas that is not shown is simply not drawn. * - Under reduced motion the build still progresses, but the canvases are drawn as still frames, *   when a step changes, the card folds or they are resized. */export type Phase = 'thinking' | 'building' | 'ready';/** What the widget shows: the phase, the percentage, and the current step (-1 before the first). */export type Status = { phase: Phase; percent: number; step: number };
terrain.ts
import { COLOURS, NOISE, createProgram, getContext, pixelRatio, uniformsOf } from './gl';import { random } from './worlds';/* * The terrain: a flat grid of triangles that a vertex shader lifts into a mountain and two hills, * drawn by a fragment shader as a topographic model. * * - The height of a point is a sum of soft cones (the mountain and the hills, placed by the world's *   seed) roughened by ridged noise. Below sea level the fragment shader discards the land, so the *   peaks stand as islands on the card, and as the build progresses the land rises out of the sea. * - Dark contour lines run at fixed heights, a pixel and a half wide whatever the slope (their *   width is measured with screen-space derivatives). * - On the mountain, colour flows up the slopes: bands of blue, cream and orange whose index is the *   height minus time, stirred by noise across the land and around the peak (the streaks). The *   colour fills the slopes up to a front that climbs with the build; above it and on the hills, *   the land stays grey. */export type TerrainFrame = {  /** The world's seed: where its mountain and hills stand. */  seed: number;  /** How far the land has risen out of the sea: 0 (under it) to 1. */  rise: number;  /** How high colour has flowed up the mountain: 0 (not at all) to 1 (over the summit). */
orb.ts
import { COLOURS, NOISE, createProgram, getContext, pixelRatio, uniformsOf } from './gl';import { random } from './worlds';/* * The orb: the folded status, drawn by one fragment shader over the whole compact card. * * - The shape is two circles melted together: the distance to each is blended with a smooth *   minimum, which grows a neck between them where they are close (a metaball). The big circle *   holds the percentage, the small one the step counter. * - Inside, a liquid: noise whose coordinates are pushed around by more noise (domain warping), *   twice, then mapped to the world's colours, so it marbles and swirls as time flows. * - A pale rim runs along the outline; the small circle is a ring, dark inside for its counter *   (light, by day). * - Behind the orb, faint contour lines: a map of a world. * - `split` slides the small circle out of the big one as the card folds, the neck stretching *   between them. * - The rim, the inside of the small circle and the map's lines follow the colour scheme: they are *   CSS tokens, read from the computed style through a hidden probe inside the canvas, at first *   and whenever `recolour` is called. The liquid's colours are the world's own, on both. *//** The two circles, in CSS px from the compact card's top left corner. */export const ORB = {  big: { x: 50, y: 66, radius: 35 },
gl.ts
/* * What the terrain and the orb share: a WebGL context with standard derivatives (for crisp contour * lines at any zoom), a program from two shaders, the GLSL noise both are made of, and the world's * colours. *//** Pixels per CSS pixel: the screen's, capped at 2 (sharp enough, for half the memory of 3×). */export const pixelRatio = () => Math.min(2, window.devicePixelRatio || 1);/** A WebGL context, or null where WebGL or the derivatives extension is missing. */export function getContext(canvas: HTMLCanvasElement, options: WebGLContextAttributes) {  const gl = canvas.getContext('webgl', {    alpha: true,    premultipliedAlpha: true,    powerPreference: 'low-power',    ...options,  });  if (!gl || !gl.getExtension('OES_standard_derivatives')) return null;  return gl;}function compile(gl: WebGLRenderingContext, type: number, source: string) {  const shader = gl.createShader(type);  if (!shader) return null;
worlds.ts
/* * The demo's content: the steps of a build, the ideas it types in turn, and the levels of detail * the composer offers. A world is a prompt and a seed: the seed, a hash of the prompt, decides where * its mountain and hills stand, so the same idea always builds the same world. */export type World = { prompt: string; seed: number };/** The steps a build goes through, in order. */export const STEPS = ['Raising the land', 'Carving the rivers', 'Naming every peak'] as const;/** The world on screen before anything is typed, and the ideas the demo types after it. */export const FIRST_IDEA = 'Snowy peaks over a warm valley';export const IDEAS = [  'A canyon city at dusk',  'Glass islands on a quiet sea',  'Terraced hills after the rain',  'A volcano wrapped in fog',];export type Level = { id: string; label: string; pace: number; lines: number };/** How much detail a world gets: how long it takes, and how many contour lines it has. */export const LEVELS: readonly Level[] = [
icons.tsx
import type { SVGProps } from 'react';/* * The widget's icons, drawn on a 16 × 16 grid with 1.5px strokes in `currentColor`, so they take * the colour of the text around them. */type IconProps = SVGProps<SVGSVGElement>;function Icon({ children, ...props }: IconProps) {  return (    <svg      viewBox="0 0 16 16"      width="16"      height="16"      fill="none"      stroke="currentColor"      strokeWidth="1.5"      strokeLinecap="round"      strokeLinejoin="round"      aria-hidden="true"      {...props}    >      {children}
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;
  • Hide and Expand are buttons with aria-expanded. The folded side is inert, and when the pressed toggle disappears, focus moves to the one that replaces it.
  • The percentage is a progressbar and the counter reads “Step 2 of 3”. The current step has aria-current="step", and finished steps say they are done.
  • A build the reader starts is announced in a polite live region, step by step and when the world is ready. The demo’s own builds stay quiet, and the demo never types into the field while the reader is in it.
  • Under reduced motion there is no demo and no fold animation. A build still progresses, with its terrain and orb drawn as still frames at each step.