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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/message-skeleton.jsonPrompt
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
countwhile 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
announceDelayms 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.
Chat Day Divider
A muted separator for chat transcripts — day chip, unread watermark and context event — that derives Today/Yesterday from calendar days, relabels itself at local midnight, and can pin to the top while its day scrolls.
Streaming Markdown
A progressive Markdown renderer for LLM streams that paints only what can no longer change — the ambiguous tail waits in a buffer instead of flashing raw markup.