AI

Message Skeleton

A chat-shaped loading placeholder — seeded conversation rhythm, alternating bubbles or document rows, a sweep phase-shifted through the thread, and one late screen-reader announcement.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Motion
 *
 * The sweep keyframe ships with the component through a React 19 hoisted
 * <style> — no Tailwind config edits, and every instance on the page dedupes by
 * href.
 *
 * The highlight span carries NO base transform, so when prefers-reduced-motion
 * switches the animation off it rests at translateX(0) — the bar keeps a soft

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/message-skeleton.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "MessageSkeleton" component: the loading
state of a chat transcript. It is not a generic box stack — it reproduces the
rhythm of a conversation (alternating sides, short questions, long answers,
ragged last lines, the occasional code block) so the wait reads as "a thread is
coming", and it reserves the same box the real messages will occupy.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
  minus children; the root spreads the remaining props.
- count?: number (default 4) — placeholder turns, clamped to 0..24 after
  rounding; a non-finite value and 0 both render no rows and announce nothing.
- startWith?: "user" | "assistant" (default "user") — role of the first turn;
  roles alternate from there (role = index % 2).
- showAvatars?: boolean (default true) — reserve the avatar circle beside every
  turn.
- layout?: "bubbles" | "rows" (default "bubbles") — alternating aligned bubbles
  versus full-width document rows with a name line.
- animation?: "shimmer" | "pulse" | "none" (default "shimmer").
- seed?: number (default 1) — picks the rhythm; the same seed always produces
  the same layout.
- fade?: boolean (default false) — ramp opacity from 0.4 on the oldest turn to
  1 on the newest, so the thread fades into history at the top.
- label?: string (default "Loading conversation…") — the single sentence a
  screen reader hears; "" opts out entirely.
- announceDelay?: number (default 400) — ms before `label` enters the live
  region.
- No data contract and no four-state branch: this component IS the loading
  state of somebody else's data flow.

Behavior — deterministic rhythm
- Every width comes from a hash of (seed, turnIndex, slot), never from
  Math.random(). Two consequences, both load-bearing:
  (a) server and client compute identical markup, so the skeleton can be
      server-rendered without the classic hydration mismatch;
  (b) the plan for turn i never reads turn i-1, so raising `count` APPENDS
      turns and leaves the ones already on screen pixel-identical.
- Use a small avalanche mix (mulberry32-style: Math.imul on three constants,
  xor-shifts, divide by 2^32) instead of a stateful PRNG stream — a stream
  would make turn i depend on how many turns came before it.
- planTurn(seed, i): a user turn gets 1-2 lines with base width 52% plus up to
  40%; an assistant turn gets 2-5 lines with base 68% plus up to 32%, and takes
  a wide block placeholder (code / table / image) with probability 0.28.
- Only the LAST line of a multi-line turn is ragged — 34%..74% of its full
  width, floored at 24% so it still reads as a line. A one-line turn is the
  whole message and is never cut.
- In "bubbles" the bubble is exactly as wide as its widest line (100% once the
  turn carries a block) and each line is a percentage OF THE BUBBLE. That
  nesting is what makes a column read as speech instead of as equal cards.
- Memoise the plan on (count, seed, startWith). No state, no observers.

Behavior — motion
- One keyframe: translateX(-100%) → translateX(100%) on an absolutely
  positioned gradient span inside each bar. Ship it in a React 19 hoisted
  <style href precedence> so several skeletons on a page dedupe by href.
- Turn i gets animationDelay = -(0.14s * i). Negative, so no bar waits for its
  first frame; offset, so the highlight travels through the thread instead of
  every bar flashing in unison.
- The span has NO base transform. With the animation switched off it rests at
  translateX(0) — a soft static sheen. That IS the reduced-motion fallback:
  motion-reduce:[animation:none] on the sweep, animate-pulse paired with
  motion-reduce:animate-none. Reduced motion loses the movement, not the
  affordance, and never leaves a flat grey slab.
- animation="none" is a first-class value, not an afterthought: it also drops
  the delay strings, so a screenshot test or a print stylesheet gets a
  perfectly static tree.

Behavior — screen readers and lifecycle
- Every turn is aria-hidden: the bars are decorative shapes, and reading their
  count aloud tells nobody anything.
- aria-busy sits on the rows WRAPPER, not on the root — assistive tech may hold
  a live region back while an ancestor is busy, which would swallow the
  announcement.
- One sr-only <div role="status" aria-live="polite"> sibling, OUTSIDE that busy
  subtree, is empty on first paint and receives `label` after announceDelay ms.
  A skeleton that flashes for 150ms must not make a screen reader say
  "loading"; a wait long enough to notice should be spoken exactly once.
- Store the announced TEXT in state rather than a boolean flag: changing
  `label` then re-announces, and the region can be emptied by dropping to
  count=0.
- Route even a zero delay through setTimeout — never fill a live region in the
  same commit that mounted it, or the text is part of the initial tree and no
  announcement fires. Clear the timeout on unmount and whenever
  announceDelay / label / busy change.

Rendering & styling
- Semantic tokens only: bg-muted, bg-muted/40, bg-muted-foreground/20,
  bg-primary/10, bg-primary/25, via-foreground/15, from-transparent. No hex, no
  rgb(), no oklch().
- Tone follows the speaker — user bars carry the primary tint, assistant bars
  the muted one — so the alternation survives greyscale and dark mode without
  relying on alignment alone.
- bubbles: row is flex items-end gap-2, reversed for the user; the column is
  capped at max-w-[78%] (user) / max-w-[86%] (assistant); the bubble is
  rounded-2xl with the corner nearest its speaker squared off (rounded-br-md /
  rounded-bl-md), min-w-24, px-4 py-3, gap-2 between h-3 rounded-full bars, and
  an h-16 block.
- rows: flex gap-3 rounded-xl p-3, user rows tinted bg-muted/40, a square-ish
  size-7 avatar, a short h-2.5 w-16 name bar above full-width lines, h-20 block.
- Geometry parity with the real message is the contract: same avatar size, same
  paddings, same radii, same row gap. If the real bubble changes its padding,
  change it here too — otherwise the swap jumps and the skeleton has failed at
  its one job.
- cn() merges the consumer className into the root; expose data-busy and
  data-layout on the root for styling hooks and tests.

Customization levers
- Rhythm: line counts (1-2 / 2-5), base widths (52 / 68), spans (40 / 32),
  block probability (0.28) and the ragged range (0.34-0.74) are the five knobs
  that decide what "a conversation" looks like in your product — a support
  inbox is nearly all short turns, a coding assistant wants far more blocks.
- Sweep: the 1.8s duration and the 0.14s per-turn step. Raise the step for a
  slow diagonal wave, set it to 0 for one synchronized flash, or swap the
  gradient for a wider/narrower highlight.
- Tone: replace bg-primary/25 with bg-muted-foreground/20 for a monochrome
  skeleton, or raise the alphas for a denser, more opaque placeholder.
- Density: bubble px-4 py-3, gap-2 between lines, gap-4 between turns in
  bubbles / gap-2 in rows.
- Fade direction: the ramp runs oldest → newest; flip it when your transcript
  is newest-first, or fade both ends by making the ramp a triangle.
- Sub-blocks: drop the block placeholder if answers are always plain prose,
  drop the name bar in rows, or add a timestamp bar under every k-th bubble.
- Announcement policy: label="" when the surrounding message list already owns
  a live region, announceDelay={0} when the skeleton only ever appears after a
  user-initiated navigation.
- Count policy: keep the 0..24 clamp — a skeleton is a hint, not a rendering
  budget; drive `count` from the last known thread length for an even closer
  box match.

Concepts

  • Seeded rhythm — widths are a hash of (seed, turn index, slot) rather than random numbers, so the same skeleton renders identically on the server and in the browser (no hydration mismatch) and a screenshot test never flakes.
  • Append without reshuffle — because a turn's plan never reads the turn before it, raising count while the request is still in flight adds rows at the bottom and leaves everything already on screen pixel-identical, instead of redrawing the whole thread.
  • Ragged tail — only the last line of a multi-line turn is cut short, and a one-line turn is never cut at all; that single rule is most of the difference between "a conversation is loading" and "here are some grey boxes".
  • Widest-line bubble — a bubble is sized by its longest line and the lines inside are percentages of the bubble, which reproduces how real speech bubbles hug their text.
  • Phase-shifted sweep — each turn starts the same highlight animation 0.14s further along (a negative delay, so nothing waits for its first frame), turning a page of independent pulses into one wave moving through the thread.
  • Quiet, then once — the live region stays empty until the wait has lasted announceDelay ms and then gains a single line, so a 150ms flash says nothing while a real wait is announced exactly once; the timer is cleared on unmount.

On This Page