GitHub (opens in a new tab)
Mode
Themes
Color-vision friendly
High contrast
03

CSS showcase

The same idea, looking forward: modern CSS worth knowing, grouped by where it stands across the current versions of Chrome, Firefox, and Safari — based on Interop and BaselineBaselineThe web platform’s shared support signal: “widely available” (stable in all engines for ~30 months), “newly available” (everywhere, but recent), “limited availability” (not yet in every engine). The showcase tiers borrow its exact vocabulary.. Everything is written as a progressive enhancement, so unsupported demos degrade instead of breaking. Every snippet here also ships in the agent skill, generated from the same registry, with a check in CI that fails when the two disagree.

Filter by topic

Widely available

Baseline widely available — in every engine for 30+ months. The foundation itself leans on these.

Container queries

Components respond to the space they actually get instead of the viewport. Together with auto-fit/minmax grids, this replaces most media queries — use the slider to resize the container, not the window.

Accessibility payoff:Layouts that adapt to their space survive 400% zoom and squeezed sidebars alike — low-vision users get reflow (1.4.10) for free.

Baseline · Widely availableChrome: supported since version 105Edge: supported since version 105Firefox: supported since version 110Safari: supported since version 16

Focus the demo — Container queries
No media queries

This grid is repeat(auto-fit, minmax(min(100%, 14rem), 1fr)) — it reflows on its own.

Self-aware cards

Each card goes horizontal when IT has room — via cq-from(), not the viewport.

Em-based sizes

Container sizes are in em, so layouts also adapt to the user’s font size setting.

Show the codeHide the code
HTML
<!-- The wrapper is the container; the card inside responds to it.
     An element can't query itself — only its descendants can. -->
<div class="card-cell">
  <article class="card">…</article>
</div>
CSS
.card-cell {
  container-type: inline-size;
}

/* The card reflows based on its container's width, not the viewport. */
@container (min-width: 20rem) {
  .card {
    grid-template-columns: 3rem 1fr;
  }
}

/* Paired with an intrinsic grid, this replaces most media queries. */
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}
View source on GitHub (opens in a new tab)

Container query units

The other half of container queries: cqi units inside clamp() scale type and spacing in proportion to the component’s own space — no breakpoints, no broken in-between states, and rem bounds keep the user’s font-size preference in charge. The timeline’s ghost years run on this.

Accessibility payoff:The rem bounds in every clamp() keep the user’s font-size preference in charge at any container width — no breakpoint ever overrides it.

Baseline · Widely availableChrome: supported since version 105Edge: supported since version 105Firefox: supported since version 110Safari: supported since version 16

Focus the demo — Container query units

Uptime this quarter

99.98%

Every size in here is clamp(rem, cqi, rem) — type, spacing, the swatch. Drag the slider: nothing snaps, it scales.

Show the codeHide the code
CSS
.stage {
  container-type: inline-size;
}

/* 1cqi = 1% of the container's inline size. Everything scales in
   proportion to the component's own space — no breakpoints, no
   viewport units, no "in-between" states to break. */
.card {
  padding: clamp(0.75rem, 6cqi, 2.5rem);
}

.card-stat {
  /* rem floor and ceiling: fluid in the middle, but the user's
     font-size preference always sets the limits. */
  font-size: clamp(1.75rem, 14cqi, 4.5rem);
}
View source on GitHub (opens in a new tab)

:has() relational selector

Style an element from its descendants’ state — something no upward selector can do. A row highlights while it :has(:checked), and persists after focus leaves (where :focus-within can’t). Focus is left to :focus-within — the right tool for that. Zero JS for the styling.

Accessibility payoff::has() styles states the DOM already knows — no JS class-toggling that can drift from the real, announced state.

Baseline · Widely availableChrome: supported since version 105Edge: supported since version 105Firefox: supported since version 121Safari: supported since version 15.4

Focus the demo — :has() relational selector

Tick a row: .row:has(:checked) styles the row from its checkbox's checked state — something no upward selector can do, and unlike :focus-within the highlight persists after focus leaves. Focus itself is handled by :focus-within — the lighter, right tool for that job. Two states, two selectors.

0 selected

Show the codeHide the code
HTML
<li class="row">
  <label>
    <input type="checkbox" />
    Review pull request
  </label>
</li>
CSS
/* Persistent: style the row from its checkbox's checked state — something no
   upward selector can do, and which :focus-within can't keep after blur. */
.row:has(:checked) {
  border-color: royalblue;
  background: #eef2ff;
}

/* Transient focus is the lighter job :focus-within is actually for. */
.row:focus-within {
  background: #f3f4f6;
}
View source on GitHub (opens in a new tab)

Count-aware layouts (quantity queries)

The chat-app photo bundle, in pure CSS: the grid counts its own children with exact-count quantity queries — :has(> :nth-child(3):last-child) reads "exactly three" — and re-composes its grid-template-areas per count. One photo renders big, three make a lead with a stacked pair, five or more fall into a mosaic. A pattern everyone assumes needs JS; the technique Heydon Pickering coined pre-:has() now works upward.

Accessibility payoff:Count-aware layout keeps items at usable sizes however many a CMS delivers — target size (2.5.8) survives real content.

Baseline · Widely availableChrome: supported since version 105Edge: supported since version 105Firefox: supported since version 121Safari: supported since version 15.4

Focus the demo — Count-aware layouts (quantity queries)

The photo bundle every chat app ships — and everyone assumes needs JS. One photo renders big; two split the bubble; three compose a lead with a stacked pair; four make a quad; five or more fall into a mosaic. The grid asks itself exactly how many children do I hold? with quantity queries like :has(> :nth-child(3):last-child) and re-maps its grid-template-areas. The buttons stand in for attaching photos — every composition decision is CSS.

1 photo
Show the codeHide the code
HTML
<ul class="gallery">
  <li><img src="…" alt="…" /></li>
  <!-- the CSS below re-composes as more photos arrive -->
</ul>
CSS
/* ":nth-child(n):last-child" = the nth child is also the last —
   i.e. the gallery holds EXACTLY n photos. */

/* Exactly 2 — split the bubble. */
.gallery:has(> :nth-child(2):last-child) {
  grid-template-columns: 1fr 1fr;
}

/* Exactly 3 — a lead photo plus a stacked pair. */
.gallery:has(> :nth-child(3):last-child) {
  grid-template-columns: 3fr 2fr;
  grid-template-areas:
    'lead top'
    'lead bottom';
}

.gallery:has(> :nth-child(3):last-child) > :first-child {
  grid-area: lead;
}

/* 5 or more — mosaic: first photo leads, the rest flow densely. */
.gallery:has(> :nth-child(5)) {
  grid-template-columns: repeat(3, 1fr);
  grid-auto-flow: dense;
}

.gallery:has(> :nth-child(5)) > :first-child {
  grid-column: span 2;
  grid-row: span 2;
}

/* Keep it gapless: the lead uses 4 cells, so n photos fill n + 3 cells,
   and any remainder shows up as short rows at the tail. Give each short
   row one wide photo: count ≡ 2 (mod 3) needs one, ≡ 1 (mod 3) needs two. */
.gallery:has(> :nth-child(5)):has(> :nth-child(3n + 2):last-child) > :nth-last-child(2),
.gallery:has(> :nth-child(5)):has(> :nth-child(3n + 1):last-child) > :nth-last-child(2),
.gallery:has(> :nth-child(5)):has(> :nth-child(3n + 1):last-child) > :nth-last-child(3) {
  grid-column: span 2;
}
View source on GitHub (opens in a new tab)

Subgrid

Nested grids adopt their parent’s tracks, so card internals align across siblings no matter how long the content gets — the kind of alignment that used to require fixed heights or JS.

Accessibility payoff:Aligned tracks across cards keep visual order and reading order identical — what a screen reader announces matches what eyes scan.

Baseline · Widely availableChrome: supported since version 117Edge: supported since version 117Firefox: supported since version 71Safari: supported since version 16

Focus the demo — Subgrid
Short

One line of text.

Aligned footer

A noticeably longer card title that wraps

Medium amount of body text across a couple of lines.

Aligned footer

Mid title

The longest body text of the three cards, so without subgrid this footer would sit lower than its neighbors.

Aligned footer

Show the codeHide the code
CSS
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}

/* Each card adopts THREE of the parent grid's rows, so title / body / footer
   line up across cards no matter how long each card's content runs. */
.card {
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
}
View source on GitHub (opens in a new tab)

Sliding selection indicator

A modern way to visualize selection: one pill that travels between options instead of fading per-item. Pure CSS — :has() reads the checked radio, the motion tokens drive the slide (instant under reduced motion) — over real, keyboard-navigable radios.

Accessibility payoff:The moving highlight is a native radio group underneath — arrow keys, focus, and announcements come from the platform; only the paint animates.

Baseline · Widely availableChrome: supported since version 105Edge: supported since version 105Firefox: supported since version 121Safari: supported since version 15.4

Focus the demo — Sliding selection indicator

A view switcher where the pill slides between options as one continuous object — no per-item opacity fade. It's pure CSS: :has() reads which radio is checked and the motion tokens drive the slide (so it's instant under reduced motion). Real radios underneath — arrow-key navigable and announced as a radio group.

Choose a view
Show the codeHide the code
HTML
<!-- Real radios underneath — arrow-key navigable, announced as a group. -->
<fieldset class="segmented">
  <legend class="visually-hidden">Choose a view</legend>
  <div class="track" style="--n: 3">
    <span class="indicator" aria-hidden="true"></span>
    <label><input type="radio" name="view" class="visually-hidden" /> List</label>
    <label><input type="radio" name="view" class="visually-hidden" /> Grid</label>
    <label><input type="radio" name="view" class="visually-hidden" /> Board</label>
  </div>
</fieldset>
CSS
.track {
  position: relative;
  display: grid;
  grid-template-columns: repeat(var(--n), 1fr);
}

/* One pill, one cell wide; it slides between options in whole steps, and only
   when the reader has not asked for reduced motion. Otherwise it jumps. */
.indicator {
  position: absolute;
  inset-block: 4px;
  inline-size: calc(100% / var(--n));
  transform: translateX(calc(var(--i, 0) * 100%));
}

@media (prefers-reduced-motion: no-preference) {
  .indicator {
    transition: transform 200ms;
  }
}

/* :has() maps the checked radio to the pill's slot. */
.track:has(label:nth-of-type(1) input:checked) { --i: 0; }
.track:has(label:nth-of-type(2) input:checked) { --i: 1; }
.track:has(label:nth-of-type(3) input:checked) { --i: 2; }
View source on GitHub (opens in a new tab)

Scroll snap

A horizontal strip where each card locks to centre as you scroll — scroll-snap-type on the track, scroll-snap-align on the cards. The strip is a focusable region, so the keyboard scrolls it too, and snapping rides the existing scroll-behavior, instant under reduced motion. No JS, no scroll listeners.

Accessibility payoff:Snap points give wheel, touch, and keyboard scrolling the same stopping places — no JS carousel intercepting arrow keys.

Baseline · Widely availableChrome: supported since version 69Edge: supported since version 79Firefox: supported since version 68Safari: supported since version 11

Focus the demo — Scroll snap

A horizontal strip where each card locks to centre as you scroll — scroll-snap-type: x mandatory on the track, scroll-snap-align on the cards. The strip is a focusable region, so arrow keys, Page Up/Down and Home/End scroll it from the keyboard, and snapping respects reduced motion (the foundation forces scroll-behavior: auto there).

  • 01Snaps to centre
  • 02Keyboard scrollable
  • 03No JS, no listeners
  • 04Off the main thread
  • 05Respects reduced motion
Show the codeHide the code
HTML
<!-- A focusable region, so arrow keys / Page / Home-End scroll it too. The
     list stays a real <ul> inside, so "list, 5 items" is still announced
     (putting role="region" on the <ul> would strip that list semantics). -->
<div class="track" tabindex="0" role="region" aria-label="Featured cards">
  <ul role="list">
    <li class="card">…</li>
  </ul>
</div>
CSS
.track {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

.card {
  flex: 0 0 16rem;
  scroll-snap-align: center;
}
View source on GitHub (opens in a new tab)

:user-valid / :user-invalid

Validation styling with manners — these pseudo-classes match only after the user has actually interacted with a field, so an empty required form isn’t flagged red on first paint. Newly Baseline widely available; the foundation’s own TextField builds on it.

Accessibility payoff:Errors wait until someone has actually been in the field — no red flags shouting at screen-magnifier users before they’ve typed a character.

Baseline · Widely availableChrome: supported since version 119Edge: supported since version 119Firefox: supported since version 88Safari: supported since version 16.5

Focus the demo — :user-valid / :user-invalid

Both fields require an email address and use identical native validation. The difference is manners: :invalid scolds the left field before you've typed a thing, while :user-invalid / :user-valid stay quiet until you've actually been in the right field and left it — then judge what you typed.

Flagged from first paint — you haven't done anything yet.

Neutral until you visit and leave; then valid turns green, invalid red.

Show the codeHide the code
CSS
/* :invalid fires immediately — an empty required field is
   "wrong" before the user has done anything at all. */
.field-eager:invalid {
  border-color: var(--color-error);
}

/* :user-invalid waits for real interaction: it only matches
   after the user has edited or left the field. Same native
   validation, judged at a humane moment. */
.field-patient:user-invalid {
  border-color: var(--color-error);
}

.field-patient:user-valid {
  border-color: var(--color-success);
}
View source on GitHub (opens in a new tab)

Newly available

Baseline newly available — interoperable everywhere, recently. Used with cheap fallbacks where it matters.

text-wrap: balance / pretty

Let the browser decide line breaks. balance evens out short blocks like headings; pretty stops body copy from ending on an orphaned word. Better readability for zero markup and no JS.

Accessibility payoff:Better line breaks with zero extra markup — nothing for assistive tech to trip on, and headings stay balanced at every zoom level.

Baseline 2024 · Newly availableChrome: supported since version 114Edge: supported since version 114Firefox: supported since version 121Safari: supported since version 17.5

Focus the demo — text-wrap: balance / pretty
Default wrapping
A well-balanced headline never leaves its last word stranded

Typography reads better when the browser, not the viewport, decides where lines break. pretty keeps the last line from becoming a single orphaned word.

balance + pretty
A well-balanced headline never leaves its last word stranded

Typography reads better when the browser, not the viewport, decides where lines break. pretty keeps the last line from becoming a single orphaned word.

The same text, two ways, at a width you control. Drag narrower and watch the default column strand a word on its own line while the balance column keeps its lines even — the browser choosing the breaks, no markup, no JS.

Show the codeHide the code
CSS
/* Evens out line lengths across a short block — ideal for headings. */
.headline {
  text-wrap: balance;
}

/* Targets just the last line to prevent a single orphaned word — cheap
   enough for body copy, where balance would be too costly. */
.body-copy {
  text-wrap: pretty;
}
View source on GitHub (opens in a new tab)

Popover attribute

A real actions menu wired by id alone — the platform hands you Esc-to-close, light (click-outside) dismiss, top-layer rendering, and aria-expanded toggling for free. A div-based dropdown reimplements all of that in JS; here it is two attributes, and items close it declaratively with popovertargetaction.

Accessibility payoff:Esc-to-close, light dismiss, and aria-expanded come with the attribute — the keyboard traps of hand-rolled dropdowns never exist.

Baseline 2025 · Newly availableChrome: supported since version 116Edge: supported since version 116Firefox: supported since version 125Safari: supported since version 17

Focus the demo — Popover attribute

A real actions menu built on the popover attribute. The trigger and panel are wired by id alone — and the platform hands you Esc-to-close, click-outside (light) dismiss, top-layer rendering (never clipped by overflow), and aria-expanded toggling on the button. A <div> dropdown reimplements all of that in JS; here it's two attributes. Each item closes the menu declaratively with popovertargetaction="hide".

No action chosen yet.

Show the codeHide the code
HTML
<!-- Wired by id alone. The platform gives Esc-to-close, click-outside
     dismiss, top-layer rendering, and aria-expanded on the button — no JS. -->
<button type="button" popovertarget="menu">Actions ▾</button>

<div id="menu" popover role="menu">
  <!-- Each item closes the menu declaratively. -->
  <button type="button" role="menuitem" popovertarget="menu" popovertargetaction="hide">
    Rename
  </button>
</div>
View source on GitHub (opens in a new tab)

Native error squiggles

text-decoration-line: spelling-error and grammar-error draw the platform’s own squiggle — the marking every visitor already knows from their spell checker, rendered by the engine on real text. The classic hack paints a gradient onto an inline-block box instead: it marks the box, not the text, and wrapping tears it apart.

Accessibility payoff:Error marking in the OS’s own convention — it tracks wrapped lines, survives zoom, and stays visible in forced colors where painted backgrounds are erased.

Baseline 2025 · Newly availableChrome: supported since version 121Edge: supported since version 121Firefox: supported since version 137Safari: supported since version 26.2

Focus the demo — Native error squiggles

Drag the handle in the frame's bottom corner and watch the prose rewrap. The native squiggles follow every line they mark; the painted one stays pinned to the bottom of its box and lets wrapped lines go bare. Browser zoom (⌘+ / Ctrl++) rewraps the text the same way — worth trying too.

text-decoration-line: spelling-error — the platform's own squiggle, the same marking the browser's spell checker draws. Where unsupported it falls back to a dotted underline: meaning preserved, convention lost.

We definately plan to keep the drafts in two seperate folders. That way the notes and the finished copy is never mixed up, even when the deadline is close.

The hack — a repeating-linear-gradient painted along the bottom of an inline-block box. It marks the box, not the text: convincing on one line, and it comes apart as soon as the words wrap.

We definately plan to keep the drafts in two seperate folders. That way the notes and the finished copy is never mixed up, even when the deadline is close.

One last comparison: emulate forced-colors from the DevTools Rendering panel. The native decoration survives the recolouring; painted backgrounds are erased, and the hack vanishes entirely.

Show the codeHide the code
CSS
/* The platform already has a squiggle convention — let the browser
   draw it. Right shape, right colour, on every wrapped line, at any
   zoom, and it survives forced colours. */
.typo {
  /* Fallback for browsers without it: meaning preserved,
     convention lost. */
  text-decoration-line: underline;
  text-decoration-style: dotted;
  text-decoration-color: var(--color-error);
}

.grammar-slip {
  text-decoration-line: underline;
  text-decoration-style: dotted;
  text-decoration-color: var(--color-success);
}

@supports (text-decoration-line: spelling-error) {
  .typo {
    /* The browser ignores your decoration colour and style here
       and paints its native marking instead. */
    text-decoration-line: spelling-error;
  }

  .grammar-slip {
    text-decoration-line: grammar-error;
  }
}

/* The hack: paint the squiggle yourself. Backgrounds mark boxes,
   not text — the span goes inline-block so the gradient has a box
   to sit against, and the prose stops wrapping like prose. */
.typo-hack {
  display: inline-block;
  background-image: repeating-linear-gradient(
    -45deg,
    var(--color-error) 0 2px,
    transparent 2px 5px
  );
  background-repeat: no-repeat;
  background-position: bottom;
  background-size: 100% 4px;
  /* Why it drifts: on wrap the squiggle holds only the last line
     of the box; it cuts through descenders instead of skipping
     ink; and forced colours erase painted backgrounds outright. */
}
View source on GitHub (opens in a new tab)

contrast-color()

The browser picks a contrasting text color for any background — user-generated colors, theming APIs, data-viz palettes stay readable without contrast math. Interop 2026 focus area; about as on-brand for this repo as it gets.

Accessibility payoff:The browser computes the readable label color for any background — text contrast becomes a guarantee, not a design-review catch.

Baseline 2026 · Newly availableChrome: supported since version 147Edge: supported since version 147Firefox: supported since version 146Safari: supported since version 26

Focus the demo — contrast-color()

The browser picks this text color for contrast.

Show the codeHide the code
CSS
.swatch {
  background: var(--bg);
  /* Fallback: a fixed light label with a shadow — readable on most colours
     but not guaranteed. That's the gap contrast-color() closes. */
  color: #fff;
  text-shadow: 0 1px 2px rgb(0 0 0 / 60%);
}

@supports (color: contrast-color(red)) {
  .swatch {
    /* The browser guarantees a contrasting label for ANY background. */
    color: contrast-color(var(--bg));
    text-shadow: none;
  }
}
View source on GitHub (opens in a new tab)

Contrast-safe theming

One engine, many themes. Each theme is just two seed colors; the full accessible palette — surfaces, borders, text, focus ring — is derived with color-mix(), relative color syntax, and contrast-color(). Adding a theme is data, not code, and contrast stays safe by construction.

Accessibility payoff:Every derived palette flips text to black or white against its seed — a theme cannot accidentally ship unreadable text (1.4.3 by construction).

Baseline 2024 · Newly availableChrome: supported since version 125Edge: supported since version 125Firefox: supported since version 128Safari: supported since version 18

Focus the demo — Contrast-safe theming

One engine, many themes. Each card sets just two seed colors; the full accessible palette — surfaces, borders, text, the focus ring — is derived with color-mix(), relative color syntax, and contrast-color(). Identical components, re-themed with zero component changes. The last three are tuned to stay legible under color-vision deficiency — recognizable hues with strong lightness contrast (hue is never the only signal).

Default

Two seeds in, a contrast-safe palette out.

Ocean

Two seeds in, a contrast-safe palette out.

Midnight

Two seeds in, a contrast-safe palette out.

Sunset

Two seeds in, a contrast-safe palette out.

Cobalt

Two seeds in, a contrast-safe palette out.

Teal

Two seeds in, a contrast-safe palette out.

Amber

Two seeds in, a contrast-safe palette out.

Show the codeHide the code
CSS
/* Two seeds per theme… */
.theme {
  --seed-surface: oklch(99% 0.004 255deg);
  --seed-accent: oklch(52% 0.16 260deg);
}

/* …and the whole accessible palette is DERIVED from them — no hand-picked
   greys. color-mix() blends each token toward the text colour. */
.theme {
  --color-surface: color-mix(in oklab, var(--seed-surface), var(--color-text) 5%);
  --color-border: color-mix(in oklab, var(--seed-surface), var(--color-text) 44%);
}

/* contrast-color() picks a guaranteed-readable text colour for each seed. */
@supports (color: contrast-color(red)) {
  .theme {
    --color-text: contrast-color(var(--seed-surface));
    --color-primary-text: contrast-color(var(--seed-accent));
  }
}
View source on GitHub (opens in a new tab)

Contrast-clamped theme picker

The same engine, made interactive: pick an accent and drag its lightness, and a pure-CSS clamp snaps it out of the “muddy middle” where no black/white label could reach WCAG AA. You can’t drag it into an inaccessible state — the math is relative-color and min()/max() in the cascade, not JS. CVD-robust presets included.

Accessibility payoff:User color choices are clamped into the contrast-safe range — personalization without letting anyone pick their way into failure.

Baseline 2024 · Newly availableChrome: supported since version 125Edge: supported since version 125Firefox: supported since version 128Safari: supported since version 18

Focus the demo — Contrast-clamped theme picker

Pick an accent and drag its lightness. The whole palette is derived from that one seed, and the button label is protected two ways: a pure-CSS clamp keeps even the plain black/white fallback at WCAG AA, and contrast-color() (where supported) picks the best label on top. Flip bypass to drop the clamp and watch the fallback fail — while contrast-color() quietly rescues the rendered label.

CVD-robust presets

Live preview

Body copy and a primary action, themed from one seed.

Applied
45% L
Fallback label
7.67:1 · AA ✓

Clamped into the safe range — even the black/white fallback meets WCAG AA at every hue, with no help from contrast-color().

Show the codeHide the code
CSS
/* A pure-CSS contrast clamp: snap a picked lightness OUT of the "muddy
   middle", where neither a black nor a white label can reach WCAG AA. No JS —
   just min()/max() and a step built from clamp(). */
.preview {
  --raw-l: calc(var(--pick-l) / 100);
  /* 1 when the accent sits on the dark side of the threshold, else 0. */
  --is-dark: clamp(0, (0.62 - var(--raw-l)) * 100000, 1);
  /* Dark accents cap low (white label safe); light accents floor high (black
     label safe); the unsafe middle is squeezed out. */
  --safe-l: calc(
    var(--is-dark) * min(var(--raw-l), 0.45) +
    (1 - var(--is-dark)) * max(var(--raw-l), 0.78)
  );
  --seed-accent: oklch(var(--safe-l) 0.15 var(--pick-h));
}
View source on GitHub (opens in a new tab)

Container style queries · non-color cues

Query a container’s custom-property value (not just its size) to restyle descendants with no prop drilling. Here it powers an a11y win: a single --cues toggle adds non-color shapes to every status inside, so meaning never rests on color alone. Interop 2026 focus area.

Accessibility payoff:Non-color cues switch on from one inherited flag — meaning stops resting on color alone (1.4.1) with no per-component code.

Baseline 2026 · Newly availableChrome: supported since version 111Edge: supported since version 111Firefox: supported since version 151Safari: supported since version 18

Focus the demo — Container style queries · non-color cues

These service statuses are shown by color alone — but ~1 in 12 men can't reliably tell red from green (deuteranopia / protanopia). Toggle non-color cues: a @container style(--cues: on) query adds a distinct shape to every status inside, with no per-status code. (Screen-reader users get the state from an aria-label regardless.)

  • API gateway
  • Database
  • CDN
  • Auth service
Show the codeHide the code
CSS
/* The toggle's state lives on the ancestor, set purely in CSS via :has(). */
.panel {
  --cues: off;
}
.panel:has(input:checked) {
  --cues: on;
}

/* A style query reads the inherited value and adds a non-colour SHAPE to each
   status — so meaning never rests on colour alone. The status elements never
   reference --cues; the query reaches them from their ancestor.

   Gotcha: keep pseudo-element rules OUT of the query — Safari doesn't apply
   them there. Let the query set a variable, and the pseudo render it. */
.status::before {
  content: var(--cue, none);
}

@container style(--cues: on) {
  .status--ok { --cue: '✓'; }
  .status--down { --cue: '✕'; }
}
View source on GitHub (opens in a new tab)

shape() responsive clipping

Responsive, keyword-based clip paths (lines, arcs, curves) that can use percentages and custom properties — unlike path(), which is frozen SVG coordinates. Resize the two cards to watch the path() clip detach while shape() reflows, then morph a third with a registered @property. Interop 2026 focus area.

Accessibility payoff:The decorative clip never touches the text flow — zoom, reflow, and screen readers meet ordinary paragraphs underneath.

Baseline 2026 · Newly availableChrome: supported since version 135Edge: supported since version 135Firefox: supported since version 148Safari: supported since version 18.4

Focus the demo — shape() responsive clipping

Percentages — scales

shape() draws its curve in %/units, so the edge stays glued to the corners at any width.

Frozen SVG coords — breaks

path()'s coordinates are absolute pixels tuned for one size. Narrow the card and the curve detaches from the edge.

Same curve, two functions. Drag the slider: the path() card's clip only lines up at its original width, while shape() reflows with the layout — the reason responsive clipping used to need JS or an SVG viewBox.

One curve, animated

The edge is a shape() whose depth is a registered @property. Because it's a real custom property, it interpolates — so the whole silhouette morphs on a transition. path() can do neither: it can't read a variable, and only tweens between identical command lists.

Two things unique to shape(): it reads custom properties, and it animates. Toggle the wave — under reduced-motion it simply snaps, no morph.

Show the codeHide the code
CSS
/* shape() draws in %/units, so the curve tracks the element at any width —
   the whole point of the demo. */
@supports (clip-path: shape(from 0 0, line to 100% 0)) {
  .banner-fluid {
    clip-path: shape(
      from 0 0,
      line to 100% 0,
      line to 100% 60%,
      curve to 0% 60% with 50% 100%,
      close
    );
  }
}

/* path()'s coordinates are absolute pixels, frozen at one size. The same
   curve only meets the edges at ~360px wide; resize and it detaches. This
   is what shape() fixes. */
.banner-frozen {
  clip-path: path('M0 0 L360 0 L360 58 C240 96 120 96 0 58 Z');
}

/* shape()'s two superpowers path() lacks: it reads custom properties, and it
   animates. Register the depth so it interpolates, feed it into the curve,
   and a class toggle morphs the whole silhouette on a transition. */
@property --wave {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 78%;
}

.banner-morph {
  clip-path: shape(
    from 0 0,
    line to 100% 0,
    line to 100% 55%,
    curve to 50% 55% with 75% var(--wave),
    curve to 0% 55% with 25% calc(110% - var(--wave)),
    close
  );
}

.banner-morph.is-deep {
  --wave: 100%;
}

@media (prefers-reduced-motion: no-preference) {
  .banner-morph {
    transition: --wave 400ms;
  }
}
View source on GitHub (opens in a new tab)

content-visibility

content-visibility: auto lets the browser skip rendering sections until they approach the viewport, so long pages paint fast with no virtual-scrolling JS. The accessibility story is what skipping keeps: unlike display:none, skipped content stays in find-in-page, the tab order, and the accessibility tree. Prove it with your own browser — search the pane for “cardamom” and the browser finds it inside a section it never rendered; search “saffron” and the display:none appendix stays invisible. contain-intrinsic-block-size keeps the scrollbar honest while sections are skipped.

Accessibility payoff:Fast first paint without hiding anything from anyone: skipped sections stay findable and announceable — and a lighter main thread is assistive-tech responsiveness, because the accessibility tree is walked on that same thread.

Baseline 2025 · Newly availableChrome: supported since version 108Edge: supported since version 108Firefox: supported since version 130Safari: supported since version 26

Focus the demo — content-visibility

Open find-in-page (⌘F / Ctrl+F). Search cardamom: the browser finds it inside a section it hasn't rendered and scrolls the pane to it. Now search saffron: nothing — it sits in a display: none appendix, gone from search, the tab order, and the accessibility tree alike.

content-visibility: auto

The slow bakery, chapter one. The ovens wake at four. Nothing on this shelf is rushed, and nothing below this paragraph has been painted yet — the browser is saving that work until you scroll toward it.

Chapter two. The rye starter is older than the shop. Every loaf that leaves the counter carries a little of the first one ever baked here.

Chapter three. Winter mornings mean spiced buns: cinnamon mostly, a little clove, and the secret the head baker will only confirm if you ask directly — cardamom, freshly cracked, never ground ahead of time.

Chapter four. Closing time is quiet. Whatever is left goes to the shelter around the corner, still warm if the timing is right.

display: none

The same article, different hiding. This pane keeps its appendix in the markup but removes it with display: none. It isn't rendered — and it also isn't searchable, focusable, or in the accessibility tree. As far as your browser is concerned, the spice named there does not exist on this page.

Appendix. The summer buns swap the winter spice for saffron, steeped in warm milk.

Show the codeHide the code
HTML
<article class="long-read">
  <section>
    <h2>Chapter one</h2>
    <p>Painted only when it approaches the viewport…</p>
  </section>
  <!-- …dozens more sections. Skipped, not hidden: still searchable
       with find-in-page, still in the tab order, still in the
       accessibility tree. display:none would take away all three. -->
</article>
CSS
/* Skip the rendering work for off-screen sections — a long page
   paints fast with no virtual-scrolling JS, and nothing is taken
   from anyone: skipped content stays findable and announceable. */
@supports (content-visibility: auto) {
  .long-read section {
    content-visibility: auto;

    /* Estimated size for skipped sections, so the scrollbar
       doesn't lie while they're unrendered. */
    contain-intrinsic-block-size: auto 12rem;
  }
}
View source on GitHub (opens in a new tab)

@starting-style

Animate an element in from display:none — entry transitions with no JS — and out via transition-behavior: allow-discrete. Composes with container queries too: responsive reveals that fade instead of pop. The transitions use motion tokens, so they respect reduced motion.

Accessibility payoff:Entry and exit animate without JS timers — and display:none keeps closed panels truly gone from the accessibility tree, not just faded.

Baseline 2024 · Newly availableChrome: supported since version 117Edge: supported since version 117Firefox: supported since version 129Safari: supported since version 17.5

Focus the demo — @starting-style

@starting-style animates an element in from display: none — entry transitions that used to need JS. Exit is handled by transition-behavior: allow-discrete. Both run on motion tokens, so they collapse to an instant change under reduced motion. Toggle the card:

Hello there 👋

I faded and slid in from display: none — no JS animation, just @starting-style plus a transition.

The same trick composes with breakpoints: put the block inside a container (or media) query and responsive state changes fade instead of popping. The motion tokens still guard it — resizing and device rotation trigger this, so reduced motion collapses it to an instant swap. Narrow the space until the details panel leaves, then widen it again:

Inbox

The content that's always there.

Show the codeHide the code
CSS
.card {
  /* Closed: out of layout and the accessibility tree. */
  display: none;
  opacity: 0;
  /* allow-discrete lets `display` participate, so the EXIT animates too. */
  transition:
    opacity 250ms,
    display 250ms allow-discrete;
}

.card.is-open {
  display: block;
  opacity: 1;

  /* The from-state for the ENTRY transition (display: none → shown). */
  @starting-style {
    opacity: 0;
  }
}

/* The same trick inside a breakpoint: crossing it FADES the panel
   in instead of popping it. */
.panel {
  display: none;
  opacity: 0;
  transition:
    opacity 250ms,
    display 250ms allow-discrete;
}

@container (inline-size >= 26rem) {
  .panel {
    display: grid;
    opacity: 1;

    @starting-style {
      opacity: 0;
    }
  }
}

/* Resize and device rotation fire this transition, so gate the
   MOTION — never the visibility. The panel must still appear for
   reduced-motion users; it just stops animating. */
@media (prefers-reduced-motion: reduce) {
  .panel {
    transition: none;
  }
}
View source on GitHub (opens in a new tab)

field-sizing: content

A textarea that grows and shrinks with its content — no scrollHeight measuring, no resize listeners, no JS. It tracks the text between a min and max height (in lh units); without support it falls back to a fixed, drag-to-resize box.

Accessibility payoff:Inputs grow with their content — screen-magnifier users see their whole answer instead of text scrolling out of a fixed box.

Baseline 2026 · Newly availableChrome: supported since version 123Edge: supported since version 123Firefox: supported since version 152Safari: supported since version 26.2

Focus the demo — field-sizing: content

field-sizing: content lets a textarea grow and shrink with what you type — no scrollHeight measuring, no resize listeners, no JS at all. Type a few lines: it tracks the content between a min and max height. Without support it falls back to a fixed rows box you can drag to resize.

0 characters · grows to a max of about 8 lines, then scrolls.

Show the codeHide the code
HTML
<textarea class="note" rows="2"></textarea>
CSS
.note {
  /* Fallback: a fixed box you can drag to resize (rows sets the start). */
  resize: vertical;
}

@supports (field-sizing: content) {
  .note {
    /* Grow and shrink with the content — no scrollHeight measuring, no resize
       listeners, no JS — bounded so it can't run away. */
    field-sizing: content;
    min-block-size: 2lh;
    max-block-size: 8lh;
    resize: none;
  }
}
View source on GitHub (opens in a new tab)

CSS zoom

The newly-interoperable zoom property magnifies an element AND reflows the layout around it — like the browser’s own zoom — where transform: scale() only repaints bigger and overlaps its neighbours. That reflow is the accessible behaviour (WCAG 1.4.10), so zoom is the right tool for a per-component “make this bigger” control. Pure-CSS radio control via :has().

Accessibility payoff:zoom scales a component the way real users magnify — a one-line testing lens for reflow and text-resize criteria.

Baseline 2024 · Newly availableChrome: supported since version 1Edge: supported since version 12Firefox: supported since version 126Safari: supported since version 3.1

Focus the demo — CSS zoom

zoom magnifies an element and reflows the layout around it — the way your browser's own zoom works. transform: scale() only repaints it larger, so it spills over its neighbours. Pick a magnification and watch the block below each card.

Magnify the card
zoom — reflows

Heading above

Magnified card

Content below — the layout makes room

transform: scale() — overlaps

Heading above

Magnified card

Content below — gets covered up

Why it matters: magnification that reflows is the accessible kind — it's what WCAG 1.4.10 (Reflow) and browser zoom depend on. zoom brings that to a single component (a “make this bigger” control), where transform would break the layout around it.

Show the codeHide the code
CSS
/* Magnify a component AND reflow the layout around it — the accessible
   kind of zoom (WCAG 1.4.10), unlike transform: scale(). */
@supports (zoom: 2) {
  .magnify {
    zoom: 2;
  }
}
View source on GitHub (opens in a new tab)

Custom Highlight API

Paint ranges of text from JS via ::highlight() — styled in CSS, with no wrapper elements inserted and the accessibility tree left untouched. Great for live search matches or annotations; because it’s presentation only, never let it be the sole carrier of meaning. Interop 2026 area.

Accessibility payoff:Highlights paint without wrapping spans — the text a screen reader reads is untouched by the visual layer.

Baseline 2026 · Newly availableChrome: supported since version 105Edge: supported since version 105Firefox: supported since version 149Safari: supported since version 17.2

Focus the demo — Custom Highlight API

The CSS Custom Highlight API paints ranges of text from JavaScript — ::highlight() styled in CSS, no wrapper <span>s injected. Type below to highlight live matches: the DOM and the accessibility tree stay untouched (so this is presentation only — never the sole way meaning is conveyed).

Accessibility is the practice of making content usable by the widest possible range of people. An accessible interface does not depend on one sense or one input device: it stays operable by keyboard, readable by assistive technology, and perceivable across contrast and motion preferences. Building accessibility in from the start is cheaper, more robust, and simply more accessible than bolting it on at the end.

Your browser doesn’t support the Custom Highlight API yet — the text above is unchanged and fully readable.

Show the codeHide the code
CSS
/* Style the named highlight — no wrapper <span>s injected, the accessibility
   tree stays untouched. ::highlight() is a global pseudo-element, so it isn't
   tied to any one element. */
::highlight(search) {
  background: royalblue;
  color: #fff;
}
JS
// Build Range objects over the matches, then register them under a name.
// The ::highlight(search) rule paints them — no DOM mutation.
const ranges = findMatchRanges(container, query) // your match-finding
CSS.highlights.set('search', new Highlight(...ranges))

// Presentation only: because it never touches the DOM or a11y tree, it must
// never be the sole way meaning is conveyed.
View source on GitHub (opens in a new tab)

Invoker commands

command and commandfor on a plain <button> open, close, and toggle dialogs and popovers declaratively — the last onclick glue for the top layer, gone. Focus handoff, Esc, backdrop, and light dismiss are platform behaviour, and the wiring lives in markup where it can’t drift from what actually happens. Baseline newly available.

Accessibility payoff:Dialog and popover wiring with zero script means zero chances to forget the focus handling — the button says what it does, and the browser does it.

Baseline 2025 · Newly availableChrome: supported since version 135Edge: supported since version 135Firefox: supported since version 144Safari: supported since version 26.2

Focus the demo — Invoker commands

Open the dialog with the first button and watch focus move inside it. Press Esc: it closes, and focus lands back on the button you pressed. The second button toggles a small popover card. No JavaScript is wired to any of this — each button declares a command and a commandfor, and the browser does the rest.

If nothing happens, your browser hasn't shipped invoker commands yet — the buttons below are wired with attributes alone.

All of this came free

Focus moved in here on its own. This dialog sits in the top layer, the page behind it is inert under the backdrop, and Esc already works. Close it and focus hands straight back to the trigger — none of that behaviour is scripted.

This card sits in the top layer too. Toggle it closed with the same button, press Esc, or click anywhere else — light dismiss is part of the deal.

Show the codeHide the code
HTML
<!-- The button names its target (commandfor) and the action (command).
     No JS anywhere: focus moves in, Esc closes, focus returns to the trigger. -->
<button type="button" commandfor="dlg" command="show-modal">Open the dialog</button>

<dialog id="dlg">
  <p>Top layer, backdrop, and focus handling — all platform-provided.</p>
  <!-- Closing is declarative too. -->
  <button type="button" commandfor="dlg" command="close">Close</button>
</dialog>

<!-- The same attribute pair drives popovers. -->
<button type="button" commandfor="card" command="toggle-popover">Toggle the popover</button>
<div id="card" popover>Esc and light dismiss included.</div>

<!-- Escape hatch: command="request-close" fires a cancelable close request
     first, for the rare dialog that needs to confirm before closing. -->
View source on GitHub (opens in a new tab)

View Transitions

Animate between two DOM states by giving shared elements a view-transition-name and wrapping the change in startViewTransition() — the browser tweens position, size, and cross-fade with no FLIP math or animation library. Switch the layout to see the cards morph; it’s skipped under reduced motion, so the swap stays instant and correct.

Accessibility payoff:Cross-page continuity as pure enhancement — reduced-motion users get an ordinary navigation, and no JS router is required at all.

Baseline 2025 · Newly availableChrome: supported since version 111Edge: supported since version 111Firefox: supported since version 144Safari: supported since version 18

Focus the demo — View Transitions

Switch the layout and the cards morph between arrangements instead of snapping — the browser tweens each shared element across the DOM change. One startViewTransition() call and a view-transition-name per card; no FLIP math, no animation library. It’s skipped entirely under reduced motion (the swap is still instant and correct) and where the API isn’t supported.

  • Design tokens
  • Cascade layers
  • Preferences
  • Motion
Show the codeHide the code
CSS
/* Each shared element gets a unique, stable name, so the browser can match it
   across the DOM change and tween its position + size. */
.item-tokens { view-transition-name: vt-tokens; }
.item-layers { view-transition-name: vt-layers; }
JS
// Wrap the DOM change; the browser captures before/after and tweens the morph
// — no FLIP math, no animation library. Guard it so the swap stays instant
// under reduced motion and where the API isn't supported.
function setView(next) {
  const apply = () => render(next)
  if (document.startViewTransition && !prefersReducedMotion()) {
    document.startViewTransition(apply)
  } else {
    apply()
  }
}
View source on GitHub (opens in a new tab)

::details-content

A JS accordion re-implements what <details> gives away free: button semantics, the announced expanded state, keyboard support. The missing piece was always the animation — ::details-content styles the browser’s own content region, and interpolate-size lets it glide to its real auto height. Fades where only the pseudo is supported, opens instantly where neither is — never broken, always native.

Accessibility payoff:The disclosure stays a real <details> — expanded state announced free of charge — while motion is layered on as pure enhancement, gone under reduced motion.

Baseline 2025 · Newly availableChrome: supported since version 131Edge: supported since version 131Firefox: supported since version 143Safari: supported since version 18.4

Focus the demo — ::details-content

A native <details> accordion with the one thing it always lacked: a smooth open. ::details-content styles the browser's own content region; interpolate-size lets it glide to its real auto height. Open one — the answers explain themselves.

Does an accordion need JavaScript?

Not anymore. This is a native <details> element: the summary is a real disclosure button, screen readers announce expanded and collapsed, and the keyboard works — all before a single line of script.

How does the smooth opening work?

::details-content targets the content region the browser already renders, so it can transition opacity and block-size — with allow-discrete holding content-visibility until the close finishes. interpolate-size: allow-keywords makes auto a real animation target. Browsers without it fade; browsers without either simply open instantly. Never broken.

And under reduced motion?

No animation at all — the transition is removed behind the preference's media query, and the disclosure works exactly the same. Motion is the enhancement here, never the mechanism.

Why it matters: a JS accordion has to re-implement what <details> gives away free — button semantics, the announced expanded state, keyboard support. The animation was the last excuse to rebuild it, and this removes the excuse.

Show the codeHide the code
HTML
<details>
  <summary>Does an accordion need JavaScript?</summary>
  <p>Not anymore — this is a native details element.</p>
</details>
CSS
/* Style the browser's own content region; allow-discrete keeps the
   content rendered until the closing fade finishes. */
details::details-content {
  opacity: 0;
  block-size: 0;
  overflow-y: clip;
  transition:
    content-visibility 0.25s allow-discrete,
    opacity 0.25s,
    block-size 0.25s;
}

details[open]::details-content {
  opacity: 1;
  block-size: auto;
}

/* Where supported, block-size animates to the real auto height;
   elsewhere the content still fades. */
details {
  interpolate-size: allow-keywords;
}

@media (prefers-reduced-motion: reduce) {
  details::details-content {
    transition: none;
  }
}
View source on GitHub (opens in a new tab)

Limited availability

Not yet in every engine. Demos only, always behind @supports — they degrade instead of breaking.

Anchor positioning

Tether a popover to the element that opened it, in pure CSS. Combined with the popover attribute (which gives keyboard dismissal and focus behavior for free), this replaces JS positioning libraries. Interop 2026 focus area. The badge reads limited because Baseline counts every part of anchor positioning, including two position-visibility values that only Safari 27 ships so far. The parts this demo uses run in all three engines.

Accessibility payoff:Tethered UI repositions instead of clipping at viewport edges — magnified and small-screen users keep the attached panel on screen.

Limited availabilityChrome: not supportedEdge: not supportedFirefox: not supportedSafari: supported since version 27

Focus the demo — Anchor positioning

With anchor positioning, this panel is tethered to the button — and position-try-fallbacks flips it across any viewport edge it would otherwise overflow (try scrolling it to a corner). Without support, it falls back to the popover default (centered), still fully functional.

Show the codeHide the code
HTML
<!-- popover gives open/close, Esc + click-outside dismiss, and
     aria-expanded on the button — all from the platform, no JS. -->
<button type="button" class="trigger" popovertarget="panel">Toggle popover</button>
<div id="panel" popover class="panel">…</div>
CSS
.trigger {
  anchor-name: --panel-anchor;
}

/* Tether the panel to its trigger and flip it across any edge it would
   overflow — no positioning JS. Stays position: fixed (the popover default)
   so it resolves against the viewport and the flips actually fire. */
@supports (anchor-name: --a) {
  .panel {
    position: fixed;
    position-anchor: --panel-anchor;
    position-area: block-end span-inline-end;
    inset: auto;
    position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
  }
}
View source on GitHub (opens in a new tab)

Anchor-positioned tooltip

A hover/focus hint tethered to its trigger with anchor positioning, flipped below it by position-try-fallbacks when the top of the viewport would cut it off, with no positioning JS. It opens on keyboard focus, not only hover, and meets the two 1.4.13 conditions most tooltips miss: hoverable and persistent. The one leg CSS can’t reach (Esc-to-dismiss) is called out honestly. Without anchor support it falls back to a fixed above-the-trigger placement. The badge reads limited for the same reason as the anchor positioning entry; the anchoring itself runs in all three engines.

Accessibility payoff:Position fallbacks flip the tooltip below its trigger when the top of the viewport would clip it; it opens on keyboard focus as well as hover (2.1.1) and stays hoverable and persistent (1.4.13).

Limited availabilityChrome: not supportedEdge: not supportedFirefox: not supportedSafari: supported since version 27

Focus the demo — Anchor-positioned tooltip

Each hint is anchored to its ? button with anchor-name and position-anchor: no measuring, no positioning JS. position-try-fallbacks: flip-block moves it below the button when the top of the viewport would cut it off (focus a ? with Tab and scroll it to the top edge to see it flip). The area is block-start span-all on purpose: a center column is only as wide as the button, so a wider hint overflows every option and never flips. It opens on hover and keyboard focus, and it stays open while you move the pointer onto it: the hoverable and persistent parts of 1.4.13 most tooltips get wrong.

  • Display name Shown publicly beside your comments. Change it anytime.
  • Backup codes One-time codes to sign in if you lose your authenticator. Store them somewhere safe — each works once.
  • API token Grants programmatic access to your account. Treat it like a password and rotate it if it leaks.

The honest limit: 1.4.13 also asks that a hint be dismissible without moving the pointer (an Esc press) — the one leg pure CSS can’t reach. The no-JS answer is the interestfor attribute — now shipping in Chrome and demoed in its own showcase below; until it lands widely, full compliance wants either that or a few lines of script. CSS gets you the rest.

Show the codeHide the code
HTML
<!-- aria-describedby ties the hint to its trigger; role="tooltip" makes
     assistive tech announce it as one. The "?" glyph is decorative. -->
<span class="hint">
  <button type="button" aria-describedby="hint-name" aria-label="About display name">
    <span aria-hidden="true">?</span>
  </button>
  <span id="hint-name" role="tooltip" class="hint-bubble">
    Shown publicly beside your comments.
  </span>
</span>
CSS
/* Hidden until hover or keyboard focus. */
.hint-bubble {
  display: none;
  opacity: 0;
  transition:
    opacity 150ms,
    display 150ms allow-discrete;
}

/* Reveal on hover, on keyboard focus, and while the bubble itself is hovered,
   so it stays put when you move onto it (the hoverable part of 1.4.13). */
.hint button:hover ~ .hint-bubble,
.hint button:focus-visible ~ .hint-bubble,
.hint-bubble:hover {
  display: block;
  opacity: 1;

  @starting-style {
    opacity: 0;
  }
}

/* No fade under reduced motion. A near-instant display exit never finishes
   on an anchored element in WebKit (measured in 26.5), so a page with a
   0.01ms reduced-motion reset would leave the hint open. */
@media (prefers-reduced-motion: reduce) {
  .hint-bubble {
    transition-property: none;
  }
}

/* The enhancement: anchor the bubble to its trigger and let it flip to stay
   on-screen. fixed means it follows on scroll and isn't clipped by overflow. */
@supports (anchor-name: --a) {
  .hint button {
    anchor-name: --hint;
  }

  .hint-bubble {
    position: fixed;
    position-anchor: --hint;
    position-area: block-start span-all;
    margin-block-end: 8px;
    position-try-fallbacks: flip-block;
  }

  /* A bridge over the 8px gap on both sides, so the pointer can cross to a
     flipped hint as well as to one above. */
  .hint-bubble::before {
    content: '';
    position: absolute;
    inset-block: -8px;
    inset-inline: 0;
    z-index: -1;
  }
}
View source on GitHub (opens in a new tab)

Scroll-driven animations

Animations driven by scroll position instead of time: no scroll listeners, off the main thread. Two cases, two rules. A thin progress bar tracks the reader’s own gesture one to one, like the scrollbar thumb, and this site leaves it running under reduced motion as a judgement call. A reveal moves content, and a timeline ignores animation-duration, so the usual global duration reset never reaches it: the reveal carries its own prefers-reduced-motion gate. Interop 2026 focus area.

Accessibility payoff:A global duration reset cannot stop a scroll timeline, so each reveal is gated on prefers-reduced-motion: no-preference, and people with vestibular disorders get stillness that actually holds (2.3.3, AAA).

Limited availabilityChrome: supported since version 115Edge: supported since version 115Firefox: not supportedSafari: supported since version 26

Focus the demo — Scroll-driven animations
Progress mirrors the gesture

Scroll this box: the progress bar at the top is driven by the scroll position of this container, in pure CSS. No scroll listeners, no layout thrash, and it runs off the main thread.

Scroll this box: the progress bar at the top is driven by the scroll position of this container, in pure CSS. No scroll listeners, no layout thrash, and it runs off the main thread.

Scroll this box: the progress bar at the top is driven by the scroll position of this container, in pure CSS. No scroll listeners, no layout thrash, and it runs off the main thread.

Scroll this box: the progress bar at the top is driven by the scroll position of this container, in pure CSS. No scroll listeners, no layout thrash, and it runs off the main thread.

A reveal moves content, so it carries its own gate
  • A scroll timeline ignores animation-duration
  • So a global 0.01ms reset never reaches it
  • The reveal sits inside its own motion gate
  • Fill mode none: the resting state is the visible one
  • The range ends once the row is fully in view
  • Without support, the rows are simply there
  • With the preference set, nothing slides and nothing fades
  • Progress follows you, a reveal moves content

A timeline-driven animation ignores animation-duration, so the usual global reset, every duration forced to 0.01ms, never reaches it. The progress bar tracks your own gesture one to one, like the scrollbar thumb, so this site leaves it running: a judgement call, not an exemption. A reveal moves content, so it sits inside prefers-reduced-motion: no-preference. With the preference set, or in a browser without scroll-driven animations, the rows are simply there.

Show the codeHide the code
CSS
.progress {
  /* Frozen, this would read as "0% progress" — worse than nothing — so it's
     hidden unless the browser can actually drive it from scroll position. */
  display: none;
}

@supports (animation-timeline: scroll()) {
  .progress {
    display: block;
    transform-origin: 0 50%;
    animation: grow linear both;
    /* Driven by the nearest scroll container, not by time. Runs off the main
       thread. It tracks the user's gesture one to one, like the scrollbar
       thumb, so it is left running under reduced motion: a judgement call,
       not an exemption. */
    animation-timeline: scroll(nearest);
  }
}

@keyframes grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

/* A reveal is different: it moves content. A timeline ignores
   animation-duration, so a global "every duration to 0.01ms" reset never
   reaches it. The reveal therefore carries its own motion gate. Fill mode
   none keeps the resting state visible, and the range ends once the row is
   fully in view. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .row {
      animation: reveal linear none;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }
  }
}

@keyframes reveal {
  from { opacity: 0; translate: 0 1.5rem; }
}
View source on GitHub (opens in a new tab)

Rounded polygon()

An arrow tag used to be a rotated-box hack or an exported image — and the export took real text with it. The round keyword makes the shape one declaration on ordinary markup, so the text comes back: it reflows under zoom, translates, re-inks with themes, reads aloud. The craft is where the clip goes: clip a child, keep the focusable element unclipped, and the focus ring can even follow the shape — drop-shadows on the parent trace the clipped silhouette, which outline never can. Break it to see why: clip the link itself and every ring is cut away with the rest of what it paints. And when the shape is really a corner treatment, corner-shape now cuts it with no clip at all — arbitrary geometry stays polygon() and shape() territory.

Accessibility payoff:Labels that used to ship as images of text stay real text (1.4.5) — zoom reflows it, translation translates it, themes re-ink it, screen readers just read it.

Focus the demo — Rounded polygon()
Detail A — a label exported as an image
The old way: an exported image. Switch the theme — it stays lit. Zoom — the text can't reflow. A translator can't touch it.

Detail A

Real text under a rounded clip. It re-inks with every theme, wraps under zoom, translates, and reads aloud.
Read the spec

Tab to the link: the ring follows the arrow because it is drawn on the unclipped <a> — drop-shadows tracing the clipped child's silhouette, something outline can never do. Break it and the clip, now on the link itself, cuts the ring away with everything else the element paints. The clip is the hit area too — a pointed tag is pointed for the pointer.

Show the codeHide the code
HTML
<!-- The ring lives on the <a>; the clip lives on the child. -->
<a class="spec-link" href="/spec">
  <span class="spec-tag">Read the spec</span>
</a>
CSS
/* round softens every corner with one radius —
   per-corner control is shape()'s job. */
.spec-tag {
  display: inline-block;
  padding: 4px 26px 4px 12px;
  background: #dbe7ff;
  clip-path: polygon(
    round 8px,
    0 0,
    calc(100% - 14px) 0,
    100% 50%,
    calc(100% - 14px) 100%,
    0 100%
  );
}

/* Clip the child, never the focusable element: clip-path cuts away
   everything the element paints — a focus outline included. */
.spec-link:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

/* Better: a ring that follows the shape. Drop-shadows on the UNclipped
   parent trace the clipped child's silhouette — outline never can, and
   on a clipped link the clip runs after filters and cuts the ring away. */
@supports (clip-path: polygon(round 1px, 0 0, 1% 0, 0 1%)) {
  .spec-link:focus-visible {
    outline: none;
    filter: drop-shadow(2px 0 0 currentColor) drop-shadow(-2px 0 0 currentColor)
      drop-shadow(0 2px 0 currentColor) drop-shadow(0 -2px 0 currentColor);
  }
}

/* No support? A plain rounded chip, not a broken shape. */
@supports not (clip-path: polygon(round 1px, 0 0, 1% 0, 0 1%)) {
  .spec-tag {
    border-radius: 8px;
    padding-inline-end: 12px;
  }
}
View source on GitHub (opens in a new tab)

corner-shape

Squircles, scoops, and notches as real border geometry — corner-shape reshapes the corners border-radius rounds, so the border, the shadow, and the default focus ring all trace the exotic outline exactly. Per-corner longhands mix treatments on one box: two bevels meeting in a point cut this site’s pointer tag with borders intact. They even animate — the keywords are superellipse() curves, so a transition sweeps squircle through bevel to scoop. The mirror lesson of the rounded polygon() demo: nothing is clipped, so no focus ring needs rescuing. Chrome and Edge 139+ for now.

Accessibility payoff:Fancy corners without clip-path means the keyboard user’s focus ring survives intact — geometry the ring can trace instead of a cut that slices it off.

Limited availabilityChrome: supported since version 139Edge: supported since version 139Firefox: not supportedSafari: not supported

Focus the demo — corner-shape

Press Tab and walk the row. The first three buttons take their corners from corner-shape — real border geometry, so the border, the shadow, and the default focus ring all trace the squircle, the scoop, and the notch exactly. The fourth mixes per-corner longhands: two full-height bevels on one side meet in a point, and the pointer tag this site cuts elsewhere with polygon() appears with its border and ring intact. The fifth animates: hover or focus it and the corners ease from squircle to scoop — the keywords are superellipse() curves underneath, so the shape sweeps through bevel on its way and the ring rides along the whole time. The last fakes its notches with clip-path: clipping runs after the outline paints, and the ring arrives sliced off at every cut corner.

corner-shape ships in Chrome and Edge 139+ only for now. Without support the shaped buttons are plain rounded ones — fully focusable, and the lesson still holds. The split with the rounded polygon() showcase: when the shape is really a corner treatment, corner-shape cuts it with nothing clipped and nothing to rescue; arbitrary geometry — and every engine today — is still polygon() and shape() territory, with the drop-shadow rescue for the ring.

Show the codeHide the code
CSS
/* corner-shape reshapes the corner your border-radius already reaches:
   the radius sets how far in the corner starts, corner-shape sets the
   path it takes — squircle, scoop, notch, bevel. */
.corner-button {
  border: 1px solid #c9c9c9;
  border-radius: 16px;
}

/* Chrome/Edge 139+ for now. Guard it: everyone else simply keeps the
   plain rounded corner — a fallback, not a failure. */
@supports (corner-shape: squircle) {
  .corner-button.squircle { corner-shape: squircle; }
  .corner-button.scoop    { corner-shape: scoop; }
  .corner-button.notch    { corner-shape: notch; }
}

/* Per-corner longhands mix treatments on one box. Two full-height
   bevels on the same side meet in a point: the pointer-tag silhouette,
   cut with borders intact — no clip anywhere near it. */
.tag-button {
  border-radius: 0 1.5rem 1.5rem 0 / 0 50% 50% 0;
}

@supports (corner-shape: squircle) {
  .tag-button {
    corner-top-right-shape: bevel;
    corner-bottom-right-shape: bevel;
  }
}

/* The keywords are superellipse() curves underneath, so corner-shape
   interpolates: this hover morph sweeps squircle → bevel → scoop as one
   eased transition. Portable code carries its own motion guard — with
   reduced motion the shape still changes, it just stops animating. */
.morph-button {
  corner-shape: squircle;
  transition: corner-shape 0.4s ease;
}

.morph-button:hover,
.morph-button:focus-visible {
  corner-shape: scoop;
}

@media (prefers-reduced-motion: reduce) {
  .morph-button {
    transition: none;
  }
}

/* Real border geometry, so everything drawn along the box follows it —
   background, border, box-shadow, and the focus ring. Nothing bespoke
   here: any outline, the browser default included, hugs the new corner. */
.corner-button:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

/* The anti-pattern: faking the notch by clipping. clip-path applies
   AFTER the element paints — outline included — so the ring is thrown
   away wherever it falls outside the polygon. Clipped exactly to the
   box like this, an offset ring vanishes altogether; the live demo
   hangs its polygon 8px past each edge so you can watch the slice. */
.clipped-button {
  clip-path: polygon(
    0 16px, 16px 16px, 16px 0,
    calc(100% - 16px) 0, calc(100% - 16px) 16px, 100% 16px,
    100% calc(100% - 16px), calc(100% - 16px) calc(100% - 16px),
    calc(100% - 16px) 100%,
    16px 100%, 16px calc(100% - 16px), 0 calc(100% - 16px)
  );
}
View source on GitHub (opens in a new tab)

Typed attr()

attr() with types — read data attributes as lengths, colors, or numbers in any property, not just content. Here it drives bar widths straight from data-value; the percentages stay as real text. Interop 2026 focus area; not widely shipped yet, so a CSS-var fallback stands in.

Accessibility payoff:The styling reads the same attribute assistive tech could expose — one source of truth for the visual and the announced value.

Limited availabilityChrome: supported since version 133Edge: supported since version 133Firefox: supported since version 155Safari: not supported

Focus the demo — Typed attr()

Each bar's width comes straight from a data-value attribute via typed attr() — no JS, no inline width. It's not shipped yet, so today the demo falls back to a CSS variable mirroring the same value; when attr() lands, the attribute alone drives it. The percentages are real text, so the data stays accessible regardless.

  • Performance92%
  • Accessibility100%
  • Best practices43%
  • SEO85%
Show the codeHide the code
HTML
<!-- The value lives in the attribute — real text, so it stays accessible. -->
<span class="bar" data-value="92"></span>
CSS
.bar {
  /* Fallback: a custom property mirrors the value until typed attr() ships. */
  inline-size: calc(var(--v, 0) * 1%);
}

/* The feature: read the attribute as a typed number, straight into the
   property — no inline style, no JS. */
@supports (width: calc(attr(data-value type(<number>), 0) * 1%)) {
  .bar {
    inline-size: calc(attr(data-value type(<number>), 0) * 1%);
  }
}
View source on GitHub (opens in a new tab)

Customizable <select>

A native <select> opted into full CSS styling with appearance: base-select — the control, the dropdown, and rich option content (status dots, descriptions, optgroups, a disabled option), plus a trigger that tints with the selection — all without leaving the platform. Keyboard, type-ahead, the screen-reader combobox, and form submission stay native, so it replaces the hand-rolled ARIA combobox. Without support it falls back to a plain native select. Chrome and Edge 135+, Safari 27+; Firefox not yet.

Accessibility payoff:Styled options on a real <select> — the keyboard model, mobile pickers, and screen-reader semantics stay native instead of ARIA-rebuilt.

Limited availabilityChrome: supported since version 135Edge: supported since version 135Firefox: not supportedSafari: supported since version 27

Focus the demo — Customizable <select>

This is a real native <select> — appearance: base-select just unlocks its styling, including the dropdown and rich option content. Keyboard arrows, type-ahead, the screen-reader “combobox”, and form submission all keep working with zero JavaScript. The usual alternative — a <div role="combobox"> wired up with hundreds of lines of ARIA and key handling — is exactly what this lets you stop writing. Without support, you get a plain native select: styled less, just as usable.

The coloured dot is decorative (aria-hidden); the status name carries the meaning, so nothing depends on colour alone. One honest detail: keyboard and screen-reader navigation skip disabled options, so their text can't be the only home of anything important — which is why the thing “Archived” wants you to know lives out here instead: archived tasks are read-only after 90 days.

Show the codeHide the code
HTML
<!-- A real native <select>: keyboard, type-ahead, the screen-reader
     combobox and form submission all keep working — no JS. -->
<select name="status" class="status-select">
  <button type="button">
    <selectedcontent></selectedcontent>
  </button>

  <optgroup label="Open">
    <option value="backlog">Backlog</option>
    <option value="in-progress" selected>In progress</option>
  </optgroup>
  <optgroup label="Closed">
    <option value="done">Done</option>
    <option value="archived" disabled>Archived</option>
  </optgroup>
</select>
CSS
/* Opt the control and its dropdown into full styling. Where unsupported,
   the markup stays an ordinary, fully-usable native <select>. */
@supports (appearance: base-select) {
  .status-select,
  .status-select::picker(select) {
    appearance: base-select;
  }

  /* The popup enters with a small rise — no JS, just transitions. */
  .status-select::picker(select) {
    padding: 4px;
    border: 1px solid #d4d4d8;
    border-radius: 8px;
    background: #fff;
    opacity: 0;
    translate: 0 -4px;
    transition: opacity 0.15s, translate 0.15s, display 0.15s allow-discrete;
  }

  .status-select:open::picker(select) {
    opacity: 1;
    translate: 0 0;
  }

  @starting-style {
    .status-select:open::picker(select) {
      opacity: 0;
      translate: 0 -4px;
    }

    /* The built-in ✓ pops in when the choice changes. */
    .status-select option:checked::checkmark {
      opacity: 0;
      scale: 0.4;
    }
  }

  .status-select option {
    padding: 8px 12px;
  }

  /* The ✓ marks the current value too, so this isn't colour alone. */
  .status-select option:checked {
    background: color-mix(in oklch, royalblue 14%, transparent);
  }

  .status-select option::checkmark {
    color: royalblue;
    transition: opacity 0.15s, scale 0.15s;
  }

  /* The closed control reflects the selection — reinforcement only;
     <selectedcontent> mirrors the option's own text. */
  .status-select:has(option[value='done']:checked) button {
    border-color: seagreen;
  }
}
View source on GitHub (opens in a new tab)

Scroll-state queries

Container queries that react to how an element sits in a scroller: scroll-state(snapped), scroll-state(stuck) and scroll-state(scrollable). A card knows when it’s the snapped one, a header knows when it’s pinned, and a strip shades its edges only while there is more to scroll that way, with no scroll listeners and no JS. A newer query, scroll-state(scrolled), can hide a bar while the reader scrolls down, as this site’s mobile chapter bar does (Chrome and Edge 144+). Such a bar owes a :focus-within exit, because its links stay in the tab order and keyboard focus would otherwise land off screen (2.4.7). The three states in the demo are Chromium-only (Chrome and Edge 133+).

Accessibility payoff:Edge hints that show only while there is more to scroll give overlay-scrollbar and zoomed-in readers a cue that never lies, with no scroll listener holding up assistive tech.

Limited availabilityChrome: supported since version 133Edge: supported since version 133Firefox: not supportedSafari: not supported

Focus the demo — Scroll-state queries

scroll-state() container queries let an element restyle itself based on how it sits in a scroller: no scroll listeners, no JS. Here a card knows when it’s the snapped one, a header knows when it’s stuck, and a strip knows which way it is still scrollable, so its edges shade only while there is more. Without support, all three stay fully usable and just skip the extra cue.

Snapped
  • 01Scroll me sideways
  • 02Watch the centre
  • 03It knows it’s snapped
  • 04Pure CSS state
  • 05No scroll listeners
Stuck
Pinned header
  • Row 1
  • Row 2
  • Row 3
  • Row 4
  • Row 5
  • Row 6
  • Row 7
  • Row 8
  • Row 9
  • Row 10
  • Row 11
  • Row 12
Scrollable
  • Automated scan
  • Keyboard walk
  • Screen reader pass
  • 400% zoom
  • Text spacing
  • Forced colors
  • Reduced motion
  • Touch targets

The scrollbar is the platform’s own scroll hint, but overlay scrollbars stay invisible until touched. The shade says “more this way” only while it is true, which a static shadow cannot: at the end it would still claim more.

Show the codeHide the code
CSS
/* The card becomes a scroll-state query container… */
.card {
  container-type: scroll-state;
  scroll-snap-align: center;
}

/* …so its contents restyle when the card is the snapped one — no scroll
   listeners, no JS. scroll-state(stuck: top) works the same for a pinned
   sticky header. */
@supports (container-type: scroll-state) {
  @container scroll-state(snapped: inline) {
    .card-inner {
      border-color: royalblue;
      scale: 1.03;
    }
  }
}

/* The scroller is a container too; two sticky markers inside it carry
   the edge shades, and the query switches each one on only while there
   is more to scroll that way: a hint that never lies at the end. */
.strip {
  display: flex;
  overflow-x: auto;
  container-type: scroll-state;
}

.hint {
  position: sticky;
  flex: 0 0 0;
  opacity: 0;
}

.hint-end {
  inset-inline-end: 0;
}

@supports (container-type: scroll-state) {
  @container scroll-state(scrollable: inline-end) {
    .hint-end { opacity: 1; }
  }
}

/* scroll-state(scrolled: …), Chrome 144+, can slide a bar away while the
   reader scrolls down. The page scroller is the container here. The bar's
   links stay in the tab order, so give it a way back: without the last
   rule, keyboard focus lands on a link that is off screen. */
html {
  container-type: scroll-state;
}

@supports (container-type: scroll-state) {
  @container scroll-state(scrolled: bottom) {
    .bar { translate: 0 100%; }
  }

  .bar:focus-within { translate: 0 0; }
}
View source on GitHub (opens in a new tab)

Dialog & popover dismissal

Newer overlay ergonomics: a popover="hint" toggletip (show + dismiss for free) and a <dialog closedby="none"> takeover that Esc and outside-clicks can’t dismiss, for the rare must-decide moment. Styling keys off the new :open pseudo-class. Interop 2026 focus area.

Accessibility payoff:showModal() brings focus trapping, Esc, and an inert background from the platform — the three things hand-rolled modals get wrong.

Limited availabilityChrome: supported since version 134Edge: supported since version 134Firefox: supported since version 141Safari: not supported

Focus the demo — Dialog & popover dismissal

Two newer platform niceties for overlays. A popover="hint"toggletip — click the “?”, and showing, dismissing, and top-layer rendering are free. And a <dialog closedby="none">takeover that Esc and outside-clicks can’t close, for the rare must-decide moment (use sparingly — trapping people is normally a trap to avoid). Styling reacts to the new :open pseudo-class.

closedby sets how a dialog can be dismissed: any (click-outside + Esc), closerequest (Esc only), or none (neither — code must close it).

One quick decision

This dialog uses closedby="none", so Esc and clicking the backdrop do nothing — you have to choose. Reserve this for moments where a choice is genuinely required; normally let people out.

Show the codeHide the code
HTML
<!-- Toggletip: showing, dismissing, and top-layer rendering all come free. -->
<button type="button" popovertarget="tip" aria-label="What does closedby do?">?</button>
<div id="tip" popover="hint" role="status">
  closedby sets how a dialog can be dismissed.
</div>

<!-- Takeover: Esc and outside-clicks can't close it. Use sparingly —
     trapping people is normally a trap to avoid. -->
<dialog closedby="none">…</dialog>
CSS
/* Style the dialog only while open, via the new :open pseudo-class. */
dialog:open {
  opacity: 1;
  scale: 1;
  transition:
    opacity 250ms,
    scale 250ms;

  /* Entry from display: none. Motion tokens would zero this under
     reduced motion; here it's a literal duration for portability. */
  @starting-style {
    opacity: 0;
    scale: 0.96;
  }
}
View source on GitHub (opens in a new tab)

Interest invokers (interestfor)

Hover, keyboard focus, or touch long-press invokes a popover from one HTML attribute — link previews and rich tooltips with the delays, Esc dismissal, and accessibility mapping supplied by the browser, no tooltip library. This closes the gap the anchor-tooltip demo calls out: hover content that meets all of WCAG 1.4.13, including dismissible. Chrome 142+; everywhere else the links are simply links.

Accessibility payoff:Hover previews with built-in delays, keyboard parity, and Esc — the hover-only tooltip failures of 1.4.13, handled by an attribute.

Limited availabilityChrome: supported since version 142Edge: supported since version 142Firefox: not supportedSafari: not supported

Focus the demo — Interest invokers (interestfor)

One HTML attribute — interestfor — wires “showing interest” (hover, keyboard focus, or touch long-press) to a popover. The delays, the Esc dismissal, and the accessibility mapping all come from the browser, not a tooltip library. Without support the links below are simply links — nothing breaks.

Link previews

This foundation is measured against WCAG 2.2 (opens in a new tab) and tracks the Interop 2026 (opens in a new tab) focus areas — show interest in either link for a preview.

WCAG 2.2 The W3C accessibility standard this site is tested against — level AA, with the 2.1/2.2 criteria demoed in “Guidelines, alive”.
Interop 2026 The cross-engine effort deciding which platform features Chrome, Firefox, and Safari all ship next.
Toolbar hints, delay-tuned
Copies a share-ready link to this section.
Opens your device’s share sheet.
Downloads the demo as a standalone file.

The first hint waits a polite beat; once one is open, interest-delay-start: 0s inside the bar makes its neighbours instant — browse the toolbar without re-waiting.

Show the codeHide the code
HTML
<!-- One attribute wires hover / keyboard focus / touch long-press
     to the popover — delays, Esc dismissal, and a11y mapping included. -->
<a href="/spec" interestfor="preview">Read the spec</a>

<div id="preview" popover="hint">
  <strong>The spec</strong>
  A short preview of where this link goes.
</div>
CSS
/* Politeness dial: wait a beat before showing, linger before hiding. */
[interestfor] {
  interest-delay: 0.4s 0.2s;
}

/* Once one hint in a toolbar is open, its neighbours open instantly. */
.toolbar:has(:interest-source) [interestfor] {
  interest-delay-start: 0s;
}

/* Style either side of the relationship while interest is shown. */
a:interest-source {
  background-color: color-mix(in oklab, currentcolor 12%, transparent);
}
View source on GitHub (opens in a new tab)

reading-flow: flex-visual

order, row-reverse, and grid placement move things visually while keyboard focus and screen-reader order stay stuck on the DOM — the classic focus-order trap. reading-flow re-syncs sequential focus and reading order with the visual arrangement in one declaration, pure CSS; reading-order handles per-item exceptions. Tab through the gallery with the fix off, then on.

Accessibility payoff:Focus and screen-reader order follow the layout users actually see — visual reordering stops breaking Focus Order (2.4.3) and Meaningful Sequence (1.3.2).

Limited availabilityChrome: supported since version 137Edge: supported since version 137Firefox: not supportedSafari: not supported

Focus the demo — reading-flow: flex-visual

The gallery is chronological in the DOM, but order: -1 promotes the featured card to the front — the classic focus-order trap. Tab through the cards: without the fix, focus walks the DOM (1, 2, 3, then a jump back to 4). Toggle it and focus follows the layout you actually see.

Why it matters: visual reordering with order, row-reverse, or grid placement leaves keyboard focus and screen-reader order stuck on the DOM — a Focus Order (2.4.3) and Meaningful Sequence (1.3.2) failure that used to mean restructuring your markup. reading-flow re-syncs them in one declaration; reading-order handles per-item exceptions.

Show the codeHide the code
HTML
<ul class="gallery">
  <li><button>Update 1</button></li>
  <li><button>Update 2</button></li>
  <li><button>Update 3</button></li>
  <li class="featured"><button>Update 4</button></li>
</ul>
CSS
.gallery {
  display: flex;
  flex-wrap: wrap;
}

/* Visual promotion — the DOM stays chronological. */
.featured {
  order: -1;
}

/* Without this, Tab follows the DOM: 1 → 2 → 3 → jump back to 4.
   With it, sequential focus (and assistive-tech reading order)
   follows the visual arrangement: 4 → 1 → 2 → 3. */
@supports (reading-flow: flex-visual) {
  .gallery {
    reading-flow: flex-visual;
  }
}
View source on GitHub (opens in a new tab)