Skeleton (loading placeholder)
components specs/components/skeleton.kmd
Skeleton placeholder primitives — block / line / circle / image — that mirror the final content shape during load. Reduces perceived load time and avoids cumulative layout shift on content swap. Modeled after Cedar (REI), Material 3, Polaris, Carbon.
When this spec applies
Primary triggers
- Show loading state for any async content surface
All triggers
- Replace a generic spinner with a content-shape placeholder
- Avoid CLS on async content render
Specification body
Component — Skeleton
Status: v0.1.0 — Draft.
R1 — Primitives
| Primitive | Default size | Use for |
|---|---|---|
KoderSkeletonBlock | configurable W×H, rounded --kdr-radius-sm | Cards, images, panels |
KoderSkeletonLine | full width × --kdr-fs-base height | Single line of text |
KoderSkeletonCircle | configurable radius | Avatars, status dots |
KoderSkeletonImage | configurable W×H, aspect-ratio preserved | Image slots |
Composers assemble these into placeholders mirroring the final layout (e.g., card skeleton = Image on top + 2 Lines + Block button).
R2 — Animation
- Default: subtle shimmer — linear gradient sweep across the surface, 1.5s loop.
prefers-reduced-motion: reduce: static pulse (opacity 0.6 → 1.0 → 0.6, 2s loop). NO sweep.prefers-reduced-motion: no-preference+ battery saver / Data saver detected: also fall back to static pulse.
R3 — Shape mirroring
The skeleton MUST replace the final content's bounding box at the same dimensions. Acceptance: when content arrives and the skeleton is removed, NO layout shift on the surrounding page (CLS contribution = 0).
Anti-pattern: spinner-in-a-fixed-box → content fills box. That's NOT a skeleton; that's a spinner.
R4 — Accessibility
- Surface wrapping the skeleton primitives carries
aria-busy="true"andaria-label="Loading"(translatable perspecs/i18n/contract.kmd). - Once content swaps in,
aria-busy="false"andaria-labelis removed. - Live-region MAY announce "Loading complete" on swap (per consumer choice; not mandated).
R5 — Color
- Default light:
--kdr-surface-2for base +--kdr-surfacefor highlight. - Default dark: same tokens (which already swap per theme).
- High-contrast /
forced-colorsmedia: skeleton usesCanvasandCanvasTextsystem colors with a 1px border.
R6 — Composer pattern
For common shapes, KDS ships composer presets (in koder_kit / koder_web_kit):
KoderSkeletonCardKoderSkeletonListRowKoderSkeletonTableRow(consumed by data-table empty/loading perspecs/components/data-table.kmdR12)KoderSkeletonAvatar
R7 — OUIA
Per specs/testing/ouia-test-hooks.kmd:
data-ouia-component-type="Skeleton"data-ouia-safe="false"always (skeleton IS the not-ready state).- The surface wrapping the skeleton ALSO carries the OUIA attrs of the eventual real component, with
data-ouia-safe="false"until swap.
Não-escopo
- Pre-fetched-content swap-in transitions (separate motion ticket).
- Server-rendered skeletons (SSR) — out of v0; renders on client mount.
Requirements (testable)
Requirement: Shape mirroring with zero layout shift {#req-skeleton-shape-mirroring}
The skeleton SHALL occupy the same bounding box and dimensions as the final content it stands in for, so that when the content arrives and the skeleton is removed the surrounding page SHALL NOT reflow (CLS contribution equal to 0). A spinner rendered inside a fixed box that the content later fills is NOT a conforming skeleton.
Scenario: Content swap produces no layout shift
- GIVEN a surface showing skeleton primitives sized to mirror the eventual content layout
- WHEN the async content resolves and the skeleton is replaced by the real content
- THEN the surrounding page elements SHALL retain their positions
- AND the cumulative layout shift contribution from the swap SHALL equal 0
Scenario: Skeleton box matches final content box
- GIVEN a content region with a known width and height
- WHEN the skeleton placeholder is rendered for that region before load
- THEN the skeleton's bounding box SHALL equal the final content's bounding box in width and height
Requirement: Motion respects reduced-motion and saver signals {#req-skeleton-animation}
The skeleton SHALL animate with a subtle shimmer sweep (linear gradient, 1.5s loop) by default, and SHALL fall back to a static opacity pulse (0.6 to 1.0 to 0.6, 2s loop) with NO sweep whenever prefers-reduced-motion: reduce is set OR a battery-saver / data-saver condition is detected.
Scenario: Default shimmer sweep
- GIVEN a viewer with
prefers-reduced-motion: no-preferenceand no saver mode active - WHEN a skeleton primitive is rendered
- THEN it SHALL animate a shimmer gradient sweep across the surface
- AND the sweep SHALL loop every 1.5s
Scenario: Reduced motion falls back to static pulse
- GIVEN a viewer with
prefers-reduced-motion: reduce - WHEN a skeleton primitive is rendered
- THEN it SHALL NOT render the gradient sweep
- AND it SHALL animate a static opacity pulse looping every 2s
Scenario: Saver mode falls back to static pulse
- GIVEN a viewer with
prefers-reduced-motion: no-preferenceand battery-saver or data-saver detected - WHEN a skeleton primitive is rendered
- THEN it SHALL render the static opacity pulse instead of the shimmer sweep
Requirement: Loading state is exposed to assistive technology {#req-skeleton-a11y-aria-busy}
The surface wrapping the skeleton primitives SHALL carry aria-busy="true" and a translatable aria-label="Loading" while loading, and once the real content swaps in it SHALL set aria-busy="false" and remove the aria-label.
Scenario: Busy state announced during load
- GIVEN a surface displaying skeleton primitives during an async load
- WHEN a screen reader inspects the wrapping surface
- THEN the surface SHALL expose
aria-busy="true" - AND the surface SHALL expose
aria-label="Loading"
Scenario: Busy state cleared after content swap
- GIVEN a surface that was showing skeletons with
aria-busy="true" - WHEN the real content swaps in
- THEN the surface SHALL set
aria-busy="false" - AND the
aria-labelSHALL be removed
Requirement: Forced-colors rendering uses system colors {#req-skeleton-forced-colors}
Under a high-contrast / forced-colors active media condition the skeleton SHALL render using the Canvas and CanvasText system colors with a 1px border, rather than the default --kdr-surface-2 base and --kdr-surface highlight tokens.
Scenario: Forced-colors mode overrides token colors
- GIVEN a viewer with
forced-colors: active - WHEN a skeleton primitive is rendered
- THEN it SHALL use
Canvasfor its fill andCanvasText-derived contrast - AND it SHALL render a 1px border
Scenario: Default color mode uses theme tokens
- GIVEN a viewer with
forced-colors: none - WHEN a skeleton primitive is rendered
- THEN it SHALL use
--kdr-surface-2as its base color and--kdr-surfaceas its highlight color
Requirement: OUIA test hooks mark the not-ready state {#req-skeleton-ouia}
Every skeleton SHALL expose data-ouia-component-type="Skeleton" with data-ouia-safe="false", and the wrapping surface SHALL also carry the OUIA attributes of the eventual real component with data-ouia-safe="false" until the content swap completes.
Scenario: Skeleton marks itself unsafe
- GIVEN a rendered skeleton surface during load
- WHEN a test harness reads its OUIA attributes
- THEN
data-ouia-component-typeSHALL equal"Skeleton" - AND
data-ouia-safeSHALL equal"false"
Scenario: Wrapping surface stays unsafe until swap
- GIVEN a surface wrapping skeletons that also carries the eventual component's OUIA type
- WHEN the content has not yet swapped in
- THEN the surface's
data-ouia-safeSHALL equal"false"
References
specs/patterns/empty-state.kmdspecs/themes/verge.kmdspecs/components/data-table.kmd