Skeleton bar

The grey bar that stands where a line of content will be. It is the only thing in this product that moves on its own, and the keyframe it moves on deliberately lives somewhere else.

Atom8 grey pages89 places1 axis: widthno states, not interactive

Anatomy

One zone and no content slot at all, which makes it the simplest atom in the system: it is a rectangle with a corner, a fill and a pulse. Everything below is drawn by design/system/components/skeleton-bar.css through the same entry point a product screen loads.

The motion, and why the keyframe is not in this file

The bars above are animating: opacity 1 to 0.55 and back, over 1.4 seconds, easing at both ends. It is a breath rather than a sweep, and there is no shimmer travelling across it, because a moving highlight is a progress claim and nothing here knows how far along the request is.

@keyframes pulse lives in design/system/base.css and must stay there. It belongs to no single component, and putting it in here would tie a shared animation to this file's lifetime. That is not a hypothetical failure mode. At stage 07 the keyframe lived only inside the July .phone block while this atom referenced it, and deleting that block on schedule would have stopped every skeleton in the product from animating, silently and with no error anywhere. It was migrated out first, and the incident is written up in docs/inventory.md. Read the code before repeating the summary: base.css names two carriers, the skeleton bar and the logo placeholder, and in the stylesheet today exactly one rule declares animation: pulse, the one on this page. .logo.is-skel takes the same fill token and sits still. Shared in intent, single in fact, and either way the keyframe stays where it is.

The reduced-motion guard is not in this file either, for a stronger reason: a person who asks the operating system for no motion asks the whole product, not one component. base.css guards *, *::before, *::after. At stage 07 the guard was written as a bare .skel at specificity 0-1-0 against a scoped .app .skel at 0-2-0, lost the cascade and did nothing at all: every skeleton on five loading states pulsed forever for somebody who had asked for none. It looked correct in the file. A universal selector cannot lose that way. DESIGN.md lists it among the Do's: "Do stop the skeleton pulse under prefers-reduced-motion; it is already wired."

Variants and sizes

One axis, width, and it is set two different ways depending on whether the width is known. There is no tone axis and no emphasis axis: a skeleton has one fill everywhere, because a loading state that ranks its own placeholders is asking a person to read something that is not there yet.

How the width is set

If the real content has a known width, the HOST sets it in pixels. If it does not, the element takes one of the six percentages. Never both, and never a new number

Class or rule Where it stands
On the element, in percent .w40 .w50 .w60
.w70 .w80 .w90
44 places. Prose of unknown length: a merchant name, a date line, a sentence in a card. The set is uneven on purpose, because a column of identical bars reads as a progress meter and a loading state is not progress
By the host, in pixels .pairs .skel 84
.pairs dd .skel 110
.group-head .skel 104 / 90
.charges .skel 56
Five rules, and they live with their hosts rather than here: a pair list saying how wide its own label column is, a group head saying how wide its heading is. This file owns the bar, the host owns the slot
Short .line.short width: 40% A second name for 40%, kept because it is what the last line of a paragraph is, rather than a number somebody picked
The amount .amt-skel 56 by 13, flex: none 10 places, all of them the figure at the end of a row. flex: none so it holds its width while the name beside it takes the slack, exactly as the real amount does
The total .total 44 by 190 3 places, and the one bar with a height of its own. It stands in for the largest object on any screen in the product, and a placeholder that does not reserve the full height lets the whole page jump when the figure lands
A tone, or a second fill - Forbidden with a reason rather than missing. One fill, --bg-placeholder, on every skeleton in the product. A darker bar for "the important one" would be a hierarchy invented out of nothing: at this moment nobody knows which line matters, least of all the interface

Two neighbours in this file are not the bar itself, and both were folded in at step 2 rather than counted as one-offs. .skel-list is the 26px of air above a stack of skeleton rows, which the real list gets from its group head. .row.is-skel takes the pointer away from a row that is temporarily not a link: in all 13 places in the grey the row is written as a div rather than an anchor, so the rule only removes the cursor a real row sets.

When to use it

A skeleton says "this is coming, and here is the shape of it", and it exists on exactly eight screens, all of them a loading state. The wireframes state the rule in their own comment: "a skeleton in the shape of the content, never a spinner over it". A spinner tells a person that something is happening to them; a skeleton tells them what is about to arrive, and on a screen built for somebody who is anxious about money that difference is the whole point.

The shape has to be the real shape. Five bars where five rows will be, one 44px block where the monthly total will be, a 56px bar where each amount will be. When the content lands, nothing moves: no reflow, no jump, no second reading of a screen a person has already started to read. That is why .total carries a height rather than inheriting one, and why the amount skeleton is flex: none.

Where a person meets it: the first few seconds of Home while the list is fetched, the same on a subscription detail, on Add a subscription while the presets load, on History and trends, and on Upgrade, processing, which is the one screen in the product with no way out on purpose.

The rule, and the anti-rule

Use a skeleton

Content is on its way and we know its shape. The bars sit exactly where the text will, so the screen a person starts reading is the screen they finish reading.

Use an empty block instead
No subscriptions here yet. Connect a bank, or add one yourself.

Nothing is coming, and pretending otherwise is the worst thing this component can do: a skeleton that never resolves is the interface lying about the future. When there is genuinely nothing, the object is the empty block, .empty, which says so in a sentence and offers the way on. The two are one word apart and opposite in meaning: a skeleton says "wait", an empty state says "there is none, and here is what to do".

Let the host set a known width

An amount is 56px because the real amount is. A pair list's label is 84px because the real label is. The host knows its own slot, and a skeleton that borrows that number cannot drift away from the content it stands in for.

Never add a seventh percentage

Six values are enough to look unplanned, which is the entire job of the set. A .w65 added for one screen is a value nobody can justify a second time, and by stage 12 there are nine of them and no rule. If the six do not fit, the width is known, and the answer is a host rule in pixels instead.

States

None, and that is a decision rather than an omission. A skeleton is a placeholder: nothing about it can be hovered, focused or pressed, and .row.is-skel exists precisely to take the pointer away from a row that is temporarily not a link. skeleton-bar.css declares no :hover, no :focus-visible and no [disabled]. The pulse is a behaviour, not a state: it is not reached by an interaction, it is on from the first frame, and its off switch is the operating system's reduced-motion setting rather than anything the interface does. It is documented in the anatomy above, where it belongs.

The technical half

ReadsWhich tokenGrowing from
the bar--bg-placeholder--skeleton. 1.23:1 against the surface in the light theme and 1.15:1 in the dark: a placeholder is not content, and a bar that measured like text would read as content that had failed to load rather than as content on its way
the corner--radius-xsprimitive, straight: geometry has nothing to override
the pulse@keyframes pulse in base.css, --dur-pulse in tokens.cssthe duration became a token on 2026-08-16, at stage 11 step 2, exactly as this row said it would. The keyframe stays in base.css, because it belongs to no single component; the 1.4s left this file for --dur-pulse and did not move by a millisecond. ease-in-out stays a keyword: the loop breathes symmetrically and there is nothing about it a second consumer could need to agree with

Lives in design/system/components/skeleton-bar.css. Stands on 8 grey pages in 89 places, and on 8 coloured ones in 89 places, which is every loading state in the product: Home, loading, Subscription Detail, loading, Add a Subscription, loading, History and trends, loading, Upgrade, processing, Alerts, loading.

<span class="skel line w60"></span> <span class="skel line short"></span> <span class="skel amt-skel"></span> <span class="skel total"></span> a loading row, in full, from home-loading: <div class="row is-skel"> <span class="logo is-skel"></span> <span class="body"><span class="skel line w60"></span><span class="skel line w80"></span></span> <span class="skel amt-skel"></span> </div>