The standard sets the bar; modern CSS and HTML clear most of it with no JavaScript at all. Not a component catalog — the deliberate, low-drama craft decisions this foundation is built on, and why each one is the accessible default. These decisions also ship as an agent skill — the same swaps, packaged for the coding agents that increasingly write this markup.
Validation that waits its turn
Validation leans on the platform: native constraints (required, type="email") drive the styling through :user-invalid / :user-valid — the craft detail is the user- prefix, which only matches after someone has interacted, so a pristine field never flags. The display name is required; the email is optional, so leaving it empty stays neutral. The email also uses a pattern to require a dotted domain (type="email" alone accepts a@b) — still pure HTML, still in the native pipeline. Hints and errors are linked via aria-describedby and never rely on color alone (the invalid border also thickens).
The same patience applies to the form as a whole. form:has(:invalid) button { opacity: .5 } reads like a form that knows itself, but an untouched required field already matches :invalid, so the button looks unavailable from first paint, before anyone has done anything: the premature judgement again, moved from the field to the form. A truly disabled submit is the harsher variant. It leaves the focus order, and it takes away the one action that makes the browser focus the first invalid field and say what is wrong with it. So the button here stays live: press Save with the name empty. The browser's message is a supplement, not the plan. It appears only on submit and for one field at a time, it does not rescale if the page is zoomed while it is up, and a screen reader hears that the field is invalid more reliably than it hears why. That is why the hint under each label stays on screen.
Common mistake
CSS
/* :invalid matches immediately — a required field is
"invalid" while empty, so it flags before any input. */
input:invalid {
border-color: red;
}
<!-- Disabled until every field is valid: the button leaves
the focus order, and nothing can trigger the browser's
report of what is wrong. Its CSS-only cousin,
form:has(:invalid) button { opacity: .5 }, looks dead
from first paint, because an untouched required field
already matches :invalid. -->
<button type="submit" disabled>Save</button>
The craft
HTML
<!-- Always live. Pressing it makes the browser focus the
first invalid field and say why, and :user-invalid marks
every field that needs work from then on. The hint stays
on screen because the native message appears only on
submit, one field at a time. -->
<form>
<label for="name">Display name</label>
<p id="name-hint">Required. Shown publicly.</p>
<input id="name" required aria-describedby="name-hint" />
<button type="submit">Save</button>
</form>
Theming is a place craft pays off quietly. Declaring each colour once with light-dark() keeps the light and dark values together so they can't drift apart — the alternative scatters them across @media (prefers-color-scheme) blocks that someone eventually edits half of. Same result, half the surface area for bugs.
Two ways to give one swatch a light and a dark value. They look identical at rest — until you flip the theme, right here. The light-dark() swatch follows your choice; the @media swatch doesn't budge. That's the trap: @media (prefers-color-scheme) only listens to the operating system, so it silently ignores any in-app theme switch, while light-dark() resolves against color-scheme — the thing a theme toggle actually changes.
light-dark() — follows the toggle
background-color: light-dark(#e6e6fa, #1a1a2e);
One line, both values together so they can’t drift — and because it resolves against color-scheme, it honours the user’s in-app theme choice and their OS preference. The foundation sets color-scheme once on :root.
The values are split across two declarations (easy to update one and forget the other) — and the query tracks the OS setting, so this swatch ignores the header toggle entirely. With a manual theme switcher, that’s not just verbose: it’s wrong.
light-dark() only returns colors — but a registered custom property resolves it to one, and a style query can read that answer back and switch any property on it: this swatch changes its border style and glyph per scheme, still zero JS. Flip the header toggle to watch it.
A native <dialog> with showModal() gives you focus trapping, Esc-to-close, and an inert background from the platform — the things hand-rolled modals get wrong. The entry animation uses motion tokens, so it disappears automatically under reduced motionReduced motionA system preference (prefers-reduced-motion) asking for less animation — for people with vestibular disorders, motion is physically sickening. Here, animation is an enhancement that bows out on request..
Common mistake
HTML
<!-- A div "dialog": now hand-write focus trapping,
Esc-to-close, an inert background, scroll locking… -->
<div class="modal" role="dialog" aria-modal="true">
…
</div>
The craft
HTML
<!-- dialog.showModal() gives focus trapping, Esc, an
inert background and top-layer stacking, all free. -->
<dialog class="modal">
…
</dialog>
Three independent animations, one preference. When the OS asks for reduced motion, all of them still — handled globally in preferences.css from a single source of truth, so no component has to remember to opt in. Flip the switch to simulate the preference (your real OS setting works too) and watch every one stop at once.
Spinner
Progress
Live pulse
Motion on. Toggle the preference (or set it in your OS) to still it.
Common mistake
CSS
/* Spins for everyone, including someone who asked
their OS for reduced motion. */
.spinner {
animation: spin 1s linear infinite;
}
The craft
CSS
/* Scale the duration by one variable that flips to 1
under prefers-reduced-motion (set once, globally),
so nothing here has to opt in. */
.spinner {
animation: spin calc(1s * (1 - var(--rm))) linear infinite;
}
Hover styles only apply on devices that can actually hover; touch devices get larger targets via touch-primary(). In forced-colors mode the border keeps the button visible when the background is replaced — a failure mode most buttons never account for.
Weeknight pasta
Dana Reeves · 12 min
Hit area: 24px — the AA floor (2.5.8) for a fine pointer. Simulate touch to see the tap targets grow.
Both look fine now. Preview forced colours to see which one disappears.
A simulated preview — real forced-colors mode does this to every element on the page, not just these two.
Common mistake
CSS
/* Background alone marks this button. In forced-colors
mode the fill is replaced and its edges vanish. */
.button {
border: none;
background-color: var(--color-primary);
}
The craft
CSS
/* A border does load-bearing work, so the button keeps
a visible edge when the background is stripped. */
.button {
border: 2px solid var(--color-primary-hover);
background-color: var(--color-primary);
}
Defensive CSS is the habit of assuming real content will be longer, wider, and weirder than the mockup. Designs are composed with tidy placeholder copy; CSS has to survive user names, tokens, and URLs. The classic trap: a grid item's automatic minimum size is its min-content size, not zero — the first unbreakable string forces its column wider than the container and pushes the layout past the viewport, a WCAG 1.4.10 Reflow failure that low-vision users at high zoom hit first. The guard is boring and load-bearing: min-inline-size: 0 at every grid hop, plus a scroll or wrap strategy for content that can't break. (This codebase learned it the hard way during QA, courtesy of an iOS WebKit quirk.)
Two phone-narrow frames, one layout, the same CSS. The left card holds the tidy placeholder copy designs are composed with; the right one holds production content — a deploy URL that can't wrap. Two guards keep it in check: min-inline-size: 0 on the grid column and a scroller on the line itself.
Guards on — both frames contain their content; the URL scrolls inside its card. ⚠ Guards off — the mockup still looks flawless, but the real content forces its column wider than the frame and gets cut off. A review done with placeholder copy would never catch it.
Common mistake
CSS
/* A grid item's automatic minimum is its min-content
size, so one unbreakable URL forces the column past
the viewport — a 1.4.10 Reflow failure. */
.card-body {
display: grid;
}
The craft
CSS
/* Two guards: let the column shrink below its content,
and give the unbreakable line a local scroll. */
.card-body {
display: grid;
min-inline-size: 0;
}
.url {
overflow-x: auto;
}
Layouts don't break in design reviews; they break the day the CMS delivers a title nobody planned for. The habit that catches it early is feeding a component hostile content on purpose: the longest plausible headline, German compound words that refuse to wrap, a right-to-left language. One card survives all four feeds below because the guards were built in — logical properties instead of left and right, min-inline-size: 0 where grids meet text, hyphens: auto riding on an honest lang attribute, and a button sized by its label instead of a designer's optimism.
DR
Recipe
Quick weeknight pasta
By Dana Reeves · 12 min read
The card the designer signed off — short, tidy, and deceptive. Real content never stays this polite.
Common mistake
CSS
/* Physical edges don't follow writing direction — in
an RTL layout the accent lands on the wrong side. */
.card {
border-left: 4px solid var(--color-primary);
padding-left: 1rem;
}
The craft
CSS
/* Logical properties flow with dir/lang, so one rule
mirrors correctly for LTR and RTL alike. */
.card {
border-inline-start: 4px solid var(--color-primary);
padding-inline-start: 1rem;
}
One more content-shaped trap: flattening a list with display: contents so its items can sit on the parent grid. For years that stripped the list's semantics from assistive tech — and Safari has relapsed more than once. Subgrid gets the same alignment without the sacrifice.
The tempting flatten
CSS
/* Flattening a list into the parent grid. For years this
stripped list semantics from assistive tech (Safari
still relapses) — the layout wins, the <ul> vanishes. */
ul.cards {
display: contents;
}
Subgrid instead
CSS
/* Subgrid aligns the items to the page grid while the
list keeps its box and its semantics. If you must use
display:contents, re-test with a screen reader. */
ul.cards {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
}
Skeleton screens are a perceived-performance trick for the eyes: grey shapes promise that content is on its way. But placeholders are semantic-free divs — a screen reader finds an empty region with no hint that anything is happening. The craft move costs two attributes: aria-busy="true" marks the region as loading (a state you clear once it settles), and one visually-hidden line says what's coming — the part a screen reader actually reads. A live region is usually the wrong tool here — on a fast connection the "loading" announcement lands after the content it was warning about. And size placeholders to the content they stand in for: a skeleton that shifts the page when real content lands trades one jank for another — that shift is what CLS (Cumulative Layout Shift) measures.
Visual-only — the trap
A screen reader finds: an empty region. Nothing says it’s busy.
Announced
Loading recent activity…
A screen reader finds: aria-busy="true" and “Loading recent activity…”
Common mistake
HTML
<!-- Skeleton divs only: a screen reader finds an empty
region with no hint that anything is loading. -->
<div class="card">
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
</div>
The craft
HTML
<!-- aria-busy flags the loading state (cleared when the
content lands); the skeleton is aria-hidden, so this
hidden line is what a screen reader actually reads. -->
<div class="card" aria-busy="true">
<span class="visually-hidden">Loading recent activity…</span>
<div class="skeleton-row" aria-hidden="true"></div>
</div>
line-clamp cuts a paragraph to a tidy three lines — and for a sighted visitor, everything past the clamp simply stops existing, because the property is visual-only and ships no control to open it. A screen reader still gets the full text, so the two audiences quietly read different documents. The craft move: make the clamp itself the collapsed state of a native <details> — ::details-content normally hides closed content, but overriding its content-visibility leaves a clamped preview showing. One element, expanded state announced for free, nothing duplicated. Browsers without the pseudo-element fall back to an ordinary closed disclosure. And when the content is essential, the honest fix is simpler: don't clamp it.
Clamped, with no way in
Version 4.2 rebuilds the export pipeline around streamed rendering, so a report that used to assemble in memory now starts downloading on the first row. Scheduled exports pick up the same engine, along with per-column formatting, a duplicate-header fix for merged sheets, and clearer error messages when a data source goes away mid-run. Anyone on the legacy formatter keeps it until the end of the quarter, and both engines write identical files for every format except the retired binary one.
Clamped, and the clamp is a disclosure
Full description
Version 4.2 rebuilds the export pipeline around streamed rendering, so a report that used to assemble in memory now starts downloading on the first row. Scheduled exports pick up the same engine, along with per-column formatting, a duplicate-header fix for merged sheets, and clearer error messages when a data source goes away mid-run. Anyone on the legacy formatter keeps it until the end of the quarter, and both engines write identical files for every format except the retired binary one.
Common mistake
CSS
/* Three lines survive; the rest of the content is gone
for sighted users, with nothing to open. (A screen
reader still gets all of it — the two audiences now
read different documents.) */
.teaser {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
The craft
CSS
/* Clamp the closed state of a real <details>: the preview
is the collapsed disclosure itself — expanded state
announced natively, nothing duplicated. */
details:not([open])::details-content {
content-visibility: visible;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
Scrollbars are OS territory the page only borrows, and overflow: auto alone ships more than most custom widgets: a thumb whose presence says "this scrolls" and whose size says how much, click-to-jump, the arrow keys, Home, End and Page Down, wheel, trackpad and touch, and a contrast the browser maintains. The craft is to add to that without taking any of it away, and two additions earn their place. On macOS the bar floats above content by default; on Windows, and for anyone who sets "always show", it claims a lane, and when it arrives mid-interaction every line re-wraps around it. scrollbar-gutter: stable reserves that lane up front. And scrollbar-color, standard in all three engines since Safari 26.2, tints the thumb from the page's own tokens instead of the OS grey and turns it to the accent while keyboard focus is inside the region, so the thumb now says which scroller the arrow keys will move. Tinting it comes with a bill: 1.4.11 and 2.5.8 exempt a control only while the browser draws it. A thumb the author colours must clear 3:1, so it is the ink at half strength here, not the frame tint. A thumb the author thins is a target shrunk below 24 pixels that people grab all day, so the width stays auto. The prefixed ::-webkit-scrollbar stays out: non-standard, and Chromium ignores it once the standard properties are set. What a scrollable region needs before any of this is tabindex="0" and a label, so keyboard users can reach it at all.
default
A settings page holds a scrollable list of integrations. On one OS the scrollbar floats above the content; on another it takes twelve pixels of layout. The same stylesheet serves both.
The moment the list grows past its box, a classic scrollbar claims its lane and every line of text re-wraps a few pixels narrower — unless the lane was reserved before it was needed.
scrollbar-gutter: stable
A settings page holds a scrollable list of integrations. On one OS the scrollbar floats above the content; on another it takes twelve pixels of layout. The same stylesheet serves both.
The moment the list grows past its box, a classic scrollbar claims its lane and every line of text re-wraps a few pixels narrower — unless the lane was reserved before it was needed.
What you see depends on your OS: classic scrollbars (Windows, or macOS with "always show") take layout space, so the left pane's text reflows the moment the scrollbar arrives, while the right pane reserved the space up front. Overlay scrollbars occupy no space, and no CSS can change which kind your visitor has. Both thumbs are tinted from the page's ink token at half strength, which clears 3:1 in both schemes. Tab into a pane and its thumb takes the accent, so a keyboard user can see which region the arrow keys will scroll. Both panes are keyboard-scrollable via tabindex="0" and a label.
Common mistake
CSS
/* Thinned to 4px and tinted #ddd: non-standard syntax
(Firefox never read it, Chromium ignores it once the
standard properties are set), a thumb at 1.3:1 against
white, and a target the author shrank below 24px. */
.panel::-webkit-scrollbar { width: 4px; }
.panel::-webkit-scrollbar-thumb { background: #ddd; }
The craft
CSS
/* Reserve the lane so content doesn't reflow when a classic
scrollbar appears. Tint the thumb from a system colour at
half strength (3:1, and it follows light and dark), and
switch it to the link colour while keyboard focus is inside,
so the thumb says which region the arrow keys move. Width
stays auto: people grab thumbs. (The region itself gets
tabindex="0" and a label for keyboard use.) */
.panel {
overflow-y: auto;
scrollbar-gutter: stable;
scrollbar-color: color-mix(in oklab, CanvasText 50%, transparent) transparent;
}
.panel:focus-within {
scrollbar-color: LinkText transparent;
}
"How do I hide this?" is the wrong question — the right one is from whom. display: none removes content for everyone: eyes, screen readers, the Tab key. The .visually-hidden pattern removes it for eyes only, which is how labels and hints reach a screen reader without cluttering the layout — but any control inside stays tabbable, so focus can land on nothing visible. aria-hidden is the mirror image: fully visible, silent to assistive tech, and it does not touch the tab order, which is why it must never wrap anything interactive. And inert switches a visible region off for everyone at once — the modern answer for the page behind a modal. Native <dialog> applies it to the whole background for free.
display: none
This paragraph and its button render nowhere.
✕ not visible
✕ not in the accessibility tree
✕ not in the tab order
For content that is gone for everyone: closed panels, inactive views.
.visually-hidden
A screen reader reads this sentence even though the box looks empty.
✕ not visible
✓ in the accessibility tree
✓ controls stay in the tab order — an invisible focus stop, unless you use a focusable variant that appears on focus (this site's skip link)
For screen-reader-only context: labels, hints, state announcements.
aria-hidden="true"
Fully visible, and a screen reader will never mention it.
✓ visible
✕ not in the accessibility tree
✓ controls stay in the tab order unless you also remove them (the button here carries tabindex="-1")
For decorative or duplicated visuals — never for anything interactive.
inert
Visible but switched off — try clicking or tabbing to the button.
✓ visible
✕ not in the accessibility tree
✕ not in the tab order, not clickable
For visible-but-inactive regions: the page behind a modal, a disabled step.
Common mistake
CSS
/* "Hidden" by eye only: invisible, but still announced
by screen readers and still a tab stop — keyboard
users land on nothing. */
.menu {
opacity: 0;
}
The craft
CSS
/* Pick the technique by audience, not by looks:
gone for everyone → display: none
screen readers only → .visually-hidden
visible but inactive → the inert attribute */
.menu[data-closed] {
display: none;
}
WCAG 1.4.12 grants readers the right to raise line height to 1.5, letter spacing to 0.12em, word spacing to 0.16em, and paragraph spacing to 2em — people with dyslexia or low vision do this through extensions and user stylesheets, and the page has to survive it with no loss of content or function. What breaks is never the text; it's the pixel-perfect box around it. The toggle below does exactly what a reader's override does: the flexible card breathes, the fixed-height one clips sentences mid-thought. The craft is one habit — heights on text containers are floors (min-block-size), never ceilings.
lets the text grow
Shipping update
Orders placed before noon leave the same day, and every parcel gets a tracking link the moment the label prints.
During launch weeks the cut-off moves an hour earlier, and the status page carries the current one.
Returns ride the same tracking link, and a refund starts the moment the carrier scans the parcel back in.
a fixed block-size, clipped
Shipping update
Orders placed before noon leave the same day, and every parcel gets a tracking link the moment the label prints.
During launch weeks the cut-off moves an hour earlier, and the status page carries the current one.
Returns ride the same tracking link, and a refund starts the moment the carrier scans the parcel back in.
The toggle applies what a reader's browser extension or user stylesheet applies for real: line height 1.5, letter spacing 0.12em, word spacing 0.16em, paragraph spacing 2em. The flexible card grows; the pixel-perfect one starts eating sentences.
Common mistake
CSS
/* A pixel-perfect text box. The moment a reader raises
line height or letter spacing (WCAG 1.4.12 says they
may), the last sentences are clipped away. */
.card {
height: 176px;
overflow: hidden;
}
The craft
CSS
/* Set a floor, never a ceiling — the box grows with the
reader's spacing instead of eating their text. */
.card {
min-block-size: 176px;
}