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.140.45 × 0.32
- ad slot0.0500.28 × 0.18
- social embed0.0100.12 × 0.08
- sticky header0fixed
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.jsonUsage
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 thecb-labelstyle) and adescriptionsub-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 theimpact × distanceformula trails on wide layouts. - Timeline bar. A track sized to
durationMs. The per-entry marker sits attime / 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
- Score derivation. Each entry's score is
impactFraction × distance— the same formula the browser uses. Pass a pre-computedshiftfield when you already have an authoritative number fromPerformanceObserver. - Verdicts. A row paints
badwhen its score exceedsrowThreshold(defaults to half the page-level threshold),goodwhen the entry isfixed, andneutralotherwise. The footer paintsbadwhen the cumulative score exceedsthreshold. - Timeline window. When
durationMsis omitted, the widget rounds the largesttimeup to the nearest 500 ms. Set explicitly to align with a fixed observation window. - 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 underprefers-reduced-motion.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
shifts | LayoutStabilityShift[] | required | Ordered list of layout-shift entries. |
title | ReactNode | — | Optional heading above the rail. |
description | ReactNode | — | Optional sub-headline under the title. |
durationMs | number | derived from largest time | Total window the timeline spans. |
threshold | number | 0.1 | Cumulative-score pass/fail threshold. |
rowThreshold | number | 0.05 | Per-entry score that paints a row bad. |
hideTotal | boolean | false | Hide the cumulative-score footer. |
headingAs | 'h2' | 'h3' | 'h4' | 'h3' | Tag for the title element. |
className | string | — | Merged onto the root via cn(). |
LayoutStabilityShift
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier. |
label | ReactNode | Visible row label. Falls back to id. |
time | number | Milliseconds from the start of the recording. |
distance | number | Fraction of the viewport the element moved, 0 to 1. |
impactFraction | number | Fraction of the viewport affected, 0 to 1. |
shift | number | Pre-computed score. Overrides impactFraction × distance. |
fixed | boolean | Render the row as fixed — good tone, 0 value. |
Accessibility
- The wrapper is a
<section>withdata-cb-edu="layout-stability-widget"anddata-cb-verdictmirroring the cumulative verdict. Rows form arole="list"ofrole="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 × distancecell carries a descriptivearia-labelso 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 pulledclsSourcesout of aPerfContextand rendered the row width as a flat percentage of0.15. This rewrite drops the context dependency, generalises the data shape to anythingPerformanceObservercan 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.