Skip to content

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

All triggers

Specification body

Component — Skeleton

Status: v0.1.0 — Draft.

R1 — Primitives

PrimitiveDefault sizeUse for
KoderSkeletonBlockconfigurable W×H, rounded --kdr-radius-smCards, images, panels
KoderSkeletonLinefull width × --kdr-fs-base heightSingle line of text
KoderSkeletonCircleconfigurable radiusAvatars, status dots
KoderSkeletonImageconfigurable W×H, aspect-ratio preservedImage 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" and aria-label="Loading" (translatable per specs/i18n/contract.kmd).
  • Once content swaps in, aria-busy="false" and aria-label is removed.
  • Live-region MAY announce "Loading complete" on swap (per consumer choice; not mandated).

R5 — Color

  • Default light: --kdr-surface-2 for base + --kdr-surface for highlight.
  • Default dark: same tokens (which already swap per theme).
  • High-contrast / forced-colors media: skeleton uses Canvas and CanvasText system colors with a 1px border.

R6 — Composer pattern

For common shapes, KDS ships composer presets (in koder_kit / koder_web_kit):

  • KoderSkeletonCard
  • KoderSkeletonListRow
  • KoderSkeletonTableRow (consumed by data-table empty/loading per specs/components/data-table.kmd R12)
  • 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-preference and 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-preference and 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-label SHALL 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 Canvas for its fill and CanvasText-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-2 as its base color and --kdr-surface as 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-type SHALL equal "Skeleton"
  • AND data-ouia-safe SHALL 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-safe SHALL equal "false"

References