Layout Stability Widget

A compact panel that visualises cumulative layout shift (CLS) across a recording window. Each shift entry renders as a row — its position on an animated timeline, its per-entry score, and the impactFraction × distance formula that produced it. A footer prints the cumulative score against the CLS budget so the page-level verdict reads at a glance.

Preview

Layout shift sources

Captured over the first five seconds of page load.

  • hero image0.14
  • ad slot0.050
  • social embed0.010
  • sticky header0
Total CLStarget 0.100.20
Timeline spans 5000 milliseconds.
Customize
Hero image shift
0.45
0.32
Ad slot shift
0.28
0.18
Options

Installation

npx shadcn@latest add https://craftbits.dev/r/layout-stability-widget.json

Usage

import { LayoutStabilityWidget } from "@craft-bits/core";
 
<LayoutStabilityWidget
  title="Layout shift sources"
  durationMs={5000}
  shifts={[
    { id: "hero", label: "hero image", time: 820, impactFraction: 0.45, distance: 0.32 },
    { id: "ad", label: "ad slot", time: 1740, impactFraction: 0.28, distance: 0.18 },
    { id: "embed", label: "social embed", time: 2960, impactFraction: 0.12, distance: 0.08 },
  ]}
/>

Mark an entry as fixed to render the same row in the good tone with a 0 value — useful for showing the same source before and after a fix lands:

<LayoutStabilityWidget
  shifts={[
    { id: "sticky-header", time: 3400, impactFraction: 0.18, distance: 0.04, fixed: true },
  ]}
/>

Anatomy

  • Header. Optional title (renders with the cb-label style) and a description sub-line. Omit both for a chromeless panel.
  • Row. One <li> per shift. The label sits left, the timeline bar fills the middle, the score sits on the right, and the impact × distance formula trails on wide layouts.
  • Timeline bar. A track sized to durationMs. The per-entry marker sits at time / durationMs; the bar width scales to the shift score relative to the threshold.
  • Footer. Renders the cumulative CLS against threshold. Colour matches the page-level verdict.

Understanding the component

  1. Score derivation. Each entry's score is impactFraction × distance — the same formula the browser uses. Pass a pre-computed shift field when you already have an authoritative number from PerformanceObserver.
  2. Verdicts. A row paints bad when its score exceeds rowThreshold (defaults to half the page-level threshold), good when the entry is fixed, and neutral otherwise. The footer paints bad when the cumulative score exceeds threshold.
  3. Timeline window. When durationMs is omitted, the widget rounds the largest time up to the nearest 500 ms. Set explicitly to align with a fixed observation window.
  4. Motion. The bar grows from zero on the first paint, the marker dot pops in with SPRINGS.snap, and the value cell re-animates whenever the score changes. All animations short-circuit under prefers-reduced-motion.

Props

PropTypeDefaultDescription
shiftsLayoutStabilityShift[]requiredOrdered list of layout-shift entries.
titleReactNodeOptional heading above the rail.
descriptionReactNodeOptional sub-headline under the title.
durationMsnumberderived from largest timeTotal window the timeline spans.
thresholdnumber0.1Cumulative-score pass/fail threshold.
rowThresholdnumber0.05Per-entry score that paints a row bad.
hideTotalbooleanfalseHide the cumulative-score footer.
headingAs'h2' | 'h3' | 'h4''h3'Tag for the title element.
classNamestringMerged onto the root via cn().

LayoutStabilityShift

FieldTypeDescription
idstringStable identifier.
labelReactNodeVisible row label. Falls back to id.
timenumberMilliseconds from the start of the recording.
distancenumberFraction of the viewport the element moved, 0 to 1.
impactFractionnumberFraction of the viewport affected, 0 to 1.
shiftnumberPre-computed score. Overrides impactFraction × distance.
fixedbooleanRender the row as fixed — good tone, 0 value.

Accessibility

  • The wrapper is a <section> with data-cb-edu="layout-stability-widget" and data-cb-verdict mirroring the cumulative verdict. Rows form a role="list" of role="listitem" entries.
  • The score column carries aria-live="polite" so updates are announced without interrupting the user.
  • Each row exposes data-cb-verdict="good" | "neutral" | "bad" so consumers can extend tone-specific styling without monkey-patching CSS.
  • The impact × distance cell carries a descriptive aria-label so the formula reads aloud as numbers, not as visual punctuation.
  • The timeline span is announced via a visually-hidden footer so screen-reader users know the window the markers cover.
  • Animations short-circuit under prefers-reduced-motion.

Credits

  • Extracted from: terminal-dreams (src/components/frontend-design/sdp-web-performance/ui/LayoutStabilityWidget.tsx). The original pulled clsSources out of a PerfContext and rendered the row width as a flat percentage of 0.15. This rewrite drops the context dependency, generalises the data shape to anything PerformanceObserver can produce (time / impactFraction / distance / optional pre-computed score), adds an animated timeline marker so shifts read in time, and lets consumers tune both the per-entry and cumulative thresholds.