Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Skeleton build

New

Cards drawn first as dotted blueprints, measured from the cards themselves, then swept in by a scan line one after another.

skeleton-build/skeleton-build.tsx
Live · interactive

Heart rate

72 beats per minute

Recovery

Current
1.6 min
Average
2.2 min
Max
4.2 min

Token spend

Jul 1 – 7


1.7M Tokens

  • Kestrel Large640K
  • Kestrel Mini480K
  • Embeddings300K
  • Vision240K
Busiest
Thu · 311K
Average
237K
vs last week
+12%

Night Ferry

Iris Calder

Liked by Rhea Lund, Omar Faris, Mina Cho and 6 more

Building the cards

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 launch, a hero or a showcase where the arrival of the interface is the point: generated UI, a design tool, an AI that builds screens.
  • A few cards (two to four) whose layout is known before their data, so the blueprint matches what replaces it.
  • A wait long enough to notice (half a second or more), where a plain spinner would leave the space blank.
  • Content that should arrive in reading order, one card after another, rather than all at once.

When not to use

  • Everyday loading in a product screen. Use a plain skeleton: grey blocks, no labels, no clock.
  • Waits under a few hundred milliseconds: the build would only delay what is already there.
  • Long lists or tables. The blueprint is measured per card, and dozens of them would crowd the page.
  • Content whose size is unknown until it loads: the blueprint is drawn from the real card, so it needs one to measure.

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

Into components/skeleton-build of any React project, with no setup:

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

profile.tsx
import { SkeletonReveal } from './skeleton-build';export function Profile({ user }: { user?: User }) {  return (    <SkeletonReveal loading={!user} onRevealed={() => console.log('shown')}>      <article className="card">        <h3><span data-sk="text">{user?.name ?? 'Name Surname'}</span></h3>        <img data-sk="box" data-sk-label="" src={user?.avatar} alt="" />        <button data-sk="box">Follow</button>      </article>    </SkeletonReveal>  );}

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 loading state that looks like an interface being generated. Each card shows as its own blueprint (dotted runs for its words, dotted boxes and rings for its controls, its outline with corner handles, guides and sizes, over a grid of dots) while a bracket hops from part to part naming what it places. Then a dotted scan line runs down each card in turn and the real card resolves above it, soft at first and then sharp. The three sample cards (a heart-rate monitor, a stacked bar chart of token spend and a music player) are live once shown.

  • The card is rendered from the start, in its place: it sets the size of the whole, so nothing moves when it appears. While loading it is clipped away to nothing with clip-path, and it is inert, so neither the keyboard nor a screen reader reaches it.
  • The blueprint is measured from the card itself. Every element marked data-sk ("text", "box" or "circle") becomes a dotted run, a dotted box with its corner radius or a dotted ring, at its exact place and size; a text run is the width of its words, read from a Range, not of its box. A ResizeObserver keeps it in step, and inside a zoomed or scaled preview the rectangles are brought back to the card’s own px.
  • The dots are SVG strokes with a zero-length dash and round caps, so every dotted line is round dots at an even pitch; the grid behind is one radial-gradient tile, faded at its edges by two intersecting masks.
  • The blueprint’s parts come in one by one in reading order over 900 ms, a bracket hops from part to part every 190 ms with its size (TEXT 41×15), and a few dots of the grid flicker darker. All of it is CSS animation except the bracket’s hop.
  • The reveal is one phase change. The card’s clip-path opens from the top to its bottom edge while the scan line moves down by a transform, both on the same duration, delay and easing, so the line is always the clip’s edge. The card starts faint and blurred (85% opacity, 2 px) and sharpens as it shows; a band with a 5 px backdrop blur just above the line keeps the newest rows soft.
  • The blueprint lies under the card, so the opaque card covers it as it shows and its dots come through while the card is still faint. Then the blueprint fades.
  • Cards reveal stagger ms apart; the last one calls onRevealed. Loading again, each card blurs out over its blueprint, which builds itself again.
  • Uncontrolled, the stage builds for 1.3 s once it is on screen, then reveals; Replay builds it again. While on screen and untouched, a demo pointer switches the chart’s period, plays and skips a track and likes it, then the stage builds again. Any press or key hands it to the reader.
  • Three cards need 1098 px in a row: a narrower stage draws them at 80% or 66% (CSS zoom), and below that stacks them at their own size.
  • Every animation rests while its card is off screen or the tab is hidden. Under reduced motion there is no sweep: the cards fade in.
PropTypeDefaultDescription
loadingboolean—Controls the stage: true holds the blueprints, false reveals the cards. Without it, the stage builds itself and reveals on its own (SkeletonReveal requires it).
staggernumber375Ms between one card’s reveal and the next’s. On SkeletonReveal, the same is its delay prop.
durationnumber350Ms each card’s sweep takes.
onRevealed() => void—Called once the last card is fully shown.
defaultRevealednumber—Uncontrolled only: opens with this many cards shown and the rest held as blueprints, with no demo. Replay builds them all again and reveals them.
autoplaybooleantrueBuild on its own once on screen and let a demo pointer try the cards. False shows the cards at once (Replay still builds them).
colorScheme'light' | 'dark' | 'system''system'Forces a scheme, or follows the page’s color-scheme.
classNamestring—Extra classes for the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

skeleton-build.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useRef, useState, type CSSProperties } from 'react';import { HeartCard, PlayerCard, TokenCard } from './cards';import { DemoPointer, runDemo, type Demo } from './demo-pointer';import { hideDelay, SkeletonReveal } from './skeleton-reveal';import { useAutoplay } from './use-autoplay';import styles from './skeleton-build.module.css';export { SkeletonReveal, type SkeletonRevealProps } from './skeleton-reveal';/* * Skeleton build: cards that are drawn first as blueprints, then swept in one after another, as * if an interface were being generated in front of the reader. * * - Each card sits in a SkeletonReveal (skeleton-reveal.tsx), which measures it and draws its *   blueprint: dotted runs for its words, dotted boxes and rings for its controls, its outline with *   corner handles, guides and size, over a grid of dots. On reveal, a scan line sweeps it in. * - The cards reveal `stagger` ms apart, in reading order; the last one calls onRevealed. * - Uncontrolled (no loading prop), the stage builds its blueprints for BUILD ms once it is on *   screen, then reveals; Replay builds it all again. Controlled, it shows what `loading` says. * - Once shown, the cards are live: the heart rate drifts, the chart switches period, the player
skeleton-reveal.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type ReactNode,} from 'react';import styles from './skeleton-build.module.css';/* * Skeleton reveal: one card drawn first as its own blueprint, then swept into the real thing. * * - The card is rendered from the start, in its place, so it sets the size of the whole and nothing *   moves when it appears. While loading it is clipped away to nothing. * - The blueprint is measured from the card itself: every element marked data-sk ("text", "box" or *   "circle") becomes a dotted run, a dotted outline or a dotted ring at its exact place and size, *   over a grid of dots, inside the card's own dotted outline with its corner handles and guides. *   A text run is the width of its words, read from a Range, not of its box. Parts come in one by
cards.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useState, type CSSProperties } from 'react';import styles from './skeleton-build.module.css';/* * The three sample cards the stage builds: a heart-rate monitor, a stacked bar chart of token * spend and a music player. All the content is invented. Every part a blueprint should draw is * marked data-sk ("text", "box" or "circle"; data-sk-label adds its size and corner handles); * the main parts also carry data-part, for the docs. * * Each card works on its own once shown: the heart rate drifts and its trace runs, the chart * switches between week, month and year and opens its details, the player plays, skips, likes * and lists who liked. `live` stops anything that runs on a clock (off screen, hidden tab, or * while the card is still a blueprint). *//* Heart rate ---------------------------------------------------------------------------------- *//** The readings the monitor drifts through, one every BEAT ms. */const READINGS = [72, 74, 77, 76, 73, 75];const BEAT = 1900;
demo-pointer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'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, or * glide the pointer somewhere. 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. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (anything that * plays its own animation on the same clock). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'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. It also tells whether the element is on screen * in a visible tab, and whether the reader prefers reduced motion. */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);
skeleton-build.module.css
/* Skeleton build (skeleton-build.tsx, skeleton-reveal.tsx, cards.tsx): a pale stage with three   cards in a row (stacked when narrow), each drawn first as a dotted blueprint and then swept in.   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. The blueprint is one small SVG per card plus a CSS grid of dots; the sweep moves one   element by a transform while the card's clip-path opens on the same clock. Nothing runs frame   by frame in JavaScript, and every animation rests off screen (data-paused). */.root {  /* The stage, the cards on it, and the cards' quieter surfaces. */  --stage: light-dark(#fafafa, #111112);  --card: light-dark(#fff, #1c1c1e);  --soft: light-dark(#f2f2f3, #2a2a2d);  --chart: light-dark(#f7f7f8, #232326);  --grid-line: light-dark(#e9e9eb, #313134);  --rule: light-dark(#efefef, #2c2c2f);  /* Words: main, secondary and faint. */  --ink: light-dark(#141414, #f3f3f3);  --muted: light-dark(#7b7b7e, #9c9ca1);  --faint: light-dark(#a3a3a6, #75757a);  /* The cards' edge and lift. */
  • While loading, each card is inert and its wrapper aria-busy: the keyboard and screen readers skip what is not there yet. The blueprint and the scan line are aria-hidden.
  • A polite live region says "Building the cards" and then "Cards ready".
  • The cards are real controls: the period is a group of toggle buttons (aria-pressed), Details and the likes list are disclosures (aria-expanded, aria-controls), the like is a toggle, playback is a progressbar with its time as text, and the chart is an image with every bar in its label.
  • Focus shows as a blue ring on every control; the demo pointer is decorative and stops at the first press or key.
  • Under reduced motion there is no sweep, no blur and no flicker: the cards fade in.