WebCraft

One element at a time

Every snippet works. The caveat is what nobody writes down.

Small interface elements, rendered live on the page rather than screenshotted, with the source you can take and the one thing that will break it three weeks from now.

Save changes
Drag to turn it.

Latest elements

44 published
animation-delay: -43200s motion

An analog clock wound by one line of JavaScript

CaveatCSS cannot ask what time it is. The clock is three infinite rotations, and a negative animation-delay winds it: minus four hours starts an animation four hours in, instantly. The only JavaScript writes seconds-since-midnight into a custom property — and that is the whole script. The dial itself is laid out by cos() and sin(), not twelve hand-tuned offsets.

FallbackWith scripts off, the delay falls back to zero and you get a working clock set to midnight. Under prefers-reduced-motion the hands pause — a paused animation still honours its negative delay, so it freezes at the right time instead of springing back to twelve.

CSS + JS
.hand {
  transform-origin: 50% 100%;
  animation: turn 43200s linear infinite;
  /* minus = start four hours in. */
  /* this IS how the clock is set */
  animation-delay: calc(var(--now) * -1s);
}
@keyframes turn { to { rotate: 360deg; } }

/* the dial, by trigonometry */
i { translate:
  calc(cos(var(--i) * 30deg) * 70px)
  calc(sin(var(--i) * 30deg) * 70px); }

// the entire script
clock.style.setProperty('--now', seconds);
animation + cqw motion

The logo that never hits the corner

CaveatTwo animations cannot share a property. Put the horizontal and the vertical drift on one element and the second translate wins outright instead of the two combining — which is why this needs one element per axis. And 100% in a translate is the element’s own size, so the travel is 100cqw - 100%: the box, less the logo.

FallbackIt does hit the corner. The wait is the lowest common multiple of the two durations, so 7.3s and 4.7s put it 5 minutes 43 seconds away. Under prefers-reduced-motion it parks in the middle instead of looping forever.

CSS
.box { container-type: size; }

/* one element per axis, or they overwrite */
.drift {
  animation: x 7.3s linear infinite alternate;
}
.logo {
  animation: y 4.7s linear infinite alternate;
}

/* 100% is the logo, cqw is the box */
@keyframes x {
  to { translate: calc(100cqw - 100%) 0; }
}
@keyframes y {
  to { translate: 0 calc(100cqh - 100%); }
}
resize + max-width layout
CAPTION DRIFTS

drag me wide

CAPTION HOLDS

drag me wide

resize keeps writing after max-width stops it

CaveatDragging writes an inline width and does not stop at max-width. The box paints clamped, but a fit-content parent measures the stored value instead, so anything centred in that parent drifts off the box it belongs to.

FallbackGive the element and its caption a column with a width of its own. Then the centre is a property of the layout rather than of whatever the reader last dragged.

CSS
/* drifts: fit-content asks the element, */
/* and resize answers with the stored width */
.wrap { width: fit-content; }

/* holds: the column decides, not the drag */
.wrap { width: min(100%, 210px); }
background + padding surface

Studio

Unlimited projects, shared components, and a staging domain per branch.

$24 / month

A gradient border that keeps its corners

Caveatborder-image ignores border-radius outright — square corners, every time, and no amount of radius fixes it. So the border is not a border here: it is padding over a gradient background.

FallbackGive the inner radius the outer value minus the padding. Match them and the corners sit visibly wrong, one curve inside another.

CSS
.frame {
  /* the padding IS the border width */
  padding: 1px;
  border-radius: 14px;
  background: linear-gradient(
    135deg, #6fa8dc, #93c47d, #f6b26b);
}

.frame > * {
  border-radius: 13px; /* 14 - 1 */
  background: #131822;
}
radial-gradient at var() hover

Move the cursor here

The light follows the pointer. Two custom properties change; nothing re-renders.

A spotlight that follows the cursor

CaveatGate it behind @media (hover: hover) and (pointer: fine). On a touchscreen :hover latches after a tap, so the light switches on and stays on with nothing hovering it.

FallbackPut the glow on a pseudo-element and animate opacity. Animating the background itself repaints the whole card on every pointer move.

CSS + JS
.card::before {
  background: radial-gradient(220px circle
    at var(--x) var(--y),
    #6fa8dc44, transparent 70%);
  opacity: 0;
  transition: opacity .25s;
}
.card:hover::before { opacity: 1; }

// x and y as percentages of the box
el.style.setProperty('--x', x + '%');
animation-timeline: scroll() motion

Scroll this panel. The bar at the top is driven by scroll position, not by a timer and not by a scroll listener.

Nothing measures anything. There is no JavaScript on this demo at all — the animation's timeline is the scroller itself.

Which also means it cannot drift, cannot fire late, and costs nothing on the main thread while you scroll.

Keep going.

Almost there.

That is the end of the panel, and the bar is full.

A progress bar tied to scroll, with no JavaScript

CaveatAn animation whose timeline is unsupported does not simply switch off — it still applies its fill state. Ship this unguarded and browsers without scroll timelines show a permanently full progress bar.

FallbackHide the bar by default and reveal it inside @supports (animation-timeline: scroll()). No bar reads as a design decision; a stuck full one reads as a broken page.

CSS
.bar {
  transform-origin: 0 50%;
  animation: fill linear both;
  animation-timeline: scroll(nearest);
}
@keyframes fill {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.track { display: none; }
@supports (animation-timeline: scroll()) {
  .track { display: block; }
}
position: sticky table
RegionSessions
Auckland4 812
Bergen3 190
Cádiz2 774
Dakar2 015
Esbjerg1 640
Faro1 288
Galway1 004

A table header that stays put

CaveatAny ancestor carrying overflow: hidden switches sticky off silently — no warning, no error, the header just scrolls away. That ancestor is usually a card wrapper somebody added months later.

FallbackWhere sticky is unsupported the header scrolls with the table, which is the plain table you started with. Nothing to polyfill.

CSS
/* sticky goes on th, not thead */
.scroller { overflow: auto; }

.scroller th {
  position: sticky;
  top: 0;
  /* or rows show through */
  background: #1a212d;
}
-webkit-line-clamp text

The clamp counts line boxes, not characters, so it survives any font size you throw at it and never cuts a word in half. What it will not survive is a change of display type: the moment something sets this element to flex or grid, every line comes back at once and the layout below it moves.

Cut a paragraph to three lines

CaveatAll four declarations are load-bearing. Bottom padding is the trap: the clipped line stays visible inside the padding box, so pad the parent, never the clamped element.

FallbackUnsupported engines show the full paragraph, so it has to be allowed to be long rather than overlap what follows.

CSS
.excerpt {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  /* without this, nothing clips */
  overflow: hidden;
}
field-sizing: content form

A textarea that grows as you type

CaveatGive it a max-height, or a pasted wall of text pushes the submit button off screen — worse than the scrollbar you were removing. lh units keep the bounds in lines, not guessed pixels.

FallbackChromium has had this since 123. Where it is ignored you get a plain textarea at whatever rows says, so set rows to the height you would have shipped anyway.

HTML + CSS
<textarea rows="2"></textarea>

textarea {
  field-sizing: content;
  min-height: 3lh;
  /* or it eats the page */
  max-height: 9lh;
  resize: none;
}
<details name> disclosure
Shipping

Opening any panel closes the others, because they share a name.

Returns

No click handler, no state, no library. The browser owns it.

Warranty

Keyboard and screen readers work without any ARIA from you.

An accordion with no JavaScript

CaveatHiding the marker takes two declarations, not one. Miss either and one browser keeps a triangle you never designed for.

FallbackWithout name support every panel opens independently — all the content rather than none, which is the right way round to fail.

HTML + CSS
/* same name = only one open at a time */
<details name="faq">
  <summary>Shipping</summary>…
</details>

summary { list-style: none; }
summary::-webkit-details-marker {
  display: none;
}
:focus-visible a11y
click one, then Tab to it

A focus ring for keyboards only

Caveat:focus { outline: none } is the most common accessibility bug on the web, and it looks like a tidy-up. It takes the ring from keyboard users, the only people who needed it.

FallbackEngines that do not know the selector skip the rule and keep the default ring on every focus. Noisier, still usable.

CSS
/* never: button:focus { outline: none } */
button:focus:not(:focus-visible) {
  outline: none;
}

button:focus-visible {
  outline: 2px solid #6fa8dc;
  outline-offset: 2px;
}
position: sticky (inline) table
CitySessionsBouncePages
Auckland4 81261%2.4
Bergen3 19058%2.1
Cádiz2 77464%2.9
Dakar2 01549%1.8
Esbjerg1 64052%2.0
Faro1 28847%1.6
Galway1 00444%1.4

A wide table that keeps its first column

CaveatThe corner cell is sticky in two directions at once, so it has to outrank both the header row and the pinned column. Give it the highest z-index of the three or the scrolling cells paint straight over it.

FallbackEvery sticky cell needs its own opaque background. Sticky does not create a new layer, so without one the rows underneath show through as you scroll.

CSS
/* the frozen column */
.pin { position: sticky; left: 0; z-index: 1; }
thead th { position: sticky; top: 0; z-index: 2; }

/* both at once, so it beats both */
thead .pin { z-index: 3; }
background-attachment: local scroll
displaypositiongrid-template-columnsaspect-ratiocontainer-typeanchor-name

Shadows that show there is more to scroll

CaveatThe two cover gradients have to match the container background exactly. Put this on a transparent or patterned surface and the covers read as grey smears sitting on top of your design.

FallbackNo JavaScript, no scroll listener. The local layers move with the content and the scroll layers stay put — the overlap is what makes a shadow appear only when it should.

CSS
background:
  /* covers, must match the surface */
  linear-gradient(90deg, #131822 30%, transparent)
    left / 42px 100% no-repeat local,
  /* the shadows underneath them */
  radial-gradient(farthest-side at 0,
    rgba(0,0,0,.55), transparent)
    left / 18px 100% no-repeat scroll,
  #131822;
:has() + translate control

A segmented control with a sliding indicator

CaveatNever size the indicator in pixels. Hardcode a width and a shift and the pill lines up until someone renames an option — then it sits half over the next label, with no error to tell you. Equal columns and a shift of 100% of itself need no measuring.

FallbackKeep real radios underneath: rebuilt from divs it loses arrow-key navigation and the announced group. Without :has() the pill just stays on the first option and the radios still work.

CSS
.seg {
  display: inline-grid;
  grid-auto-flow: column;
  /* equal, whatever the label says */
  grid-auto-columns: 1fr;
}
.seg::after {
  width: calc((100% - 8px) / 3);
  transition: translate .28s;
}
/* one column, measured by the browser */
.seg:has(input:nth-of-type(2):checked)::after {
  translate: 100% 0;
}
@keyframes + gradient loading

A skeleton that does not lie about the layout

CaveatA skeleton whose blocks are not the size of the real content is worse than a spinner: the page visibly rearranges the moment data lands. Match the line heights and widths you will actually render.

FallbackWrap the sweep in prefers-reduced-motion. A looping shimmer is exactly the kind of endless movement that setting exists to stop.

CSS
.line {
  background: linear-gradient(90deg,
    #1a212d 25%, #26303f 50%, #1a212d 75%)
    0 0 / 300% 100%;
  animation: sweep 1.4s linear infinite;
}
@keyframes sweep {
  to { background-position: -300% 0; }
}
aria-expanded menu

A menu button that turns into a close button

CaveatIt is a button with aria-expanded, not a checkbox. A checkbox announces itself as a checkbox, and a menu toggle that reads as "checkbox, not checked" is worse than no label at all.

FallbackAnimate rotate and translate, never top. The transform pair runs on the compositor; moving top relayouts the button on every frame.

HTML + CSS
<button aria-expanded="false">
  <span></span><span></span><span></span>
</button>

[aria-expanded="true"] span:nth-child(1) {
  translate: 0 8px; rotate: 45deg;
}
appearance: none form

A switch that is still a checkbox

CaveatStyle the input itself rather than hiding it behind a div. appearance: none strips the paint and keeps everything else — focus, the space key, the label association, the announced role.

FallbackWhere the styling is unsupported you get the platform checkbox. It is not the design, but it is a working control, which a styled div would not be.

CSS
input[type="checkbox"] {
  /* paint gone, semantics kept */
  appearance: none;
  width: 44px; height: 25px;
  border-radius: 999px;
}
input:checked::after { translate: 19px 0; }
translateX(-50%) motion
FigmaAstroVitePlaywright

A marquee that never jumps

CaveatThe track holds the list twice and travels exactly -50%. Any other distance, or a single copy, and the loop visibly snaps back at the seam.

FallbackMark the second copy aria-hidden — it is decoration, and a screen reader should not read the same list twice. Stop the animation under prefers-reduced-motion.

CSS
.track {
  display: flex;
  width: max-content;  /* the list, twice */
  animation: slide 14s linear infinite;
}
@keyframes slide {
  to { transform: translateX(-50%); }
}
backdrop-filter surface
backdrop-filter

Frosted glass over anything

CaveatThe element needs its own translucent background. Fully opaque and there is nothing to see through; fully transparent and most engines skip the filter entirely. Somewhere near 40% is where it reads as glass.

FallbackIt blurs whatever is painted behind it, so it is only worth the cost over something worth blurring. Over a flat colour it is an expensive way to draw that same colour.

CSS
.glass {
  backdrop-filter: blur(11px);
  /* without this there is nothing to see */
  background: rgba(11, 14, 20, .42);
  border: 1px solid rgba(255,255,255,.16);
}
background-clip: text type
Ship it

Type painted with a gradient

Caveatcolor: transparent is doing the work, so if the background fails to paint for any reason the text is invisible rather than merely unstyled. Never put a whole paragraph in it, only display type you can afford to lose.

FallbackKeep the -webkit- prefixed property alongside the standard one. This is one of the few places where the prefix is still load-bearing rather than historical.

CSS
.headline {
  background: linear-gradient(100deg,
    #6fa8dc, #f6b26b);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent; /* the whole trick */
}
::-webkit-slider-thumb form

A range slider you can actually style

CaveatThe WebKit and Gecko thumb selectors cannot share a rule. One unknown pseudo-element invalidates the entire selector list, so a combined rule silently styles nothing anywhere. Write them out separately, every time.

FallbackThe thumb needs appearance: none of its own, not just the input. And margin-top is what centres it on the track, since the thumb aligns to the track top by default.

CSS
/* never combine these two */
input::-webkit-slider-thumb {
  appearance: none;
  margin-top: -7px; /* centres on the track */
}
input::-moz-range-thumb { border: 0; }
perspective + rotate hover
perspective
lives on the parent

A card that tips towards the cursor

CaveatThe perspective belongs on the parent, not on the card. Put it on the transformed element and every card gets its own vanishing point, so a row of them tilts in visibly different directions.

FallbackText blurs slightly while a 3D transform is applied — the glyphs are being rasterised at a scale they were not hinted for. Keep the angle small and never tilt body copy.

CSS
.scene { perspective: 700px; } /* on the parent */

.scene:hover .card {
  rotate: x 9deg;
  box-shadow: 0 18px 30px rgba(0,0,0,.45);
}
@property motion
@property

A gradient angle that actually animates

CaveatCustom properties are strings until you register them. Animate an unregistered --angle and it jumps from start to end with nothing in between — no error, no warning, just a hard cut at 50%.

FallbackRegistering it declares a type, so the browser can interpolate. It also gives the property an initial value, which is what stops the first frame from rendering unstyled.

CSS
@property --spin {
  syntax: "<angle>";  /* now it can interpolate */
  inherits: false;
  initial-value: 0deg;
}
.ring {
  background: conic-gradient(from var(--spin), …);
}
@keyframes spin { to { --spin: 360deg; } }
@container layout
Deploy hook

Runs on every push to main.

drag the corner →

A card that reads its own container, not the window

CaveatAn element cannot query itself. The container-type goes on a wrapper and the query targets its children, so every queried component needs one box more than you expected.

FallbackDeclaring a container also applies layout containment, which makes that box the containing block for position: fixed descendants. A fixed overlay inside one anchors to the card, not the viewport.

CSS
.wrap { container-type: inline-size; }

@container (min-width: 210px) {
  .row { grid-template-columns: 62px 1fr; }
}
cqi layout

Fluid

drag the corner →

Type that scales with its container

CaveatA cqi unit resolves against the nearest ancestor that declared a container, not the nearest ancestor you had in mind. Nest two containers and the inner one silently wins.

FallbackAlways wrap it in clamp(). Unbounded container units produce four-pixel type in a sidebar and headline type in a hero, from the same rule.

CSS
.wrap { container-type: inline-size; }

h2 {
  /* floor and ceiling are not optional */
  font-size: clamp(14px, 9cqi, 30px);
}
aspect-ratio layout
1:1
3:4
16:9

Boxes that keep their proportions

CaveatAn explicit height beats aspect-ratio every time. The one that catches people is implicit: a flex or grid parent stretches its children by default, and that stretch is a height.

FallbackSet align-items: start on the parent, or height: auto on the child. Then the ratio has room to do its job.

CSS
.tile { aspect-ratio: 16 / 9; width: 96px; }

/* or the parent stretch overrides it */
.row { align-items: start; }
text-wrap: balance type
DEFAULT
Every snippet works, the caveat is the part nobody writes down
BALANCE
Every snippet works, the caveat is the part nobody writes down

A heading that breaks where a typesetter would

CaveatBrowsers stop balancing past roughly six lines and quietly do nothing beyond that. It is a headline tool: put it on body copy and you pay for the layout pass and get no result.

FallbackUnsupported engines wrap normally, so the heading must already read acceptably without it. Treat it as polish, never as the thing holding the layout together.

CSS
h1, h2, h3 {
  text-wrap: balance; /* headings only */
}
text-wrap: pretty type

A paragraph set with pretty will not leave a single short word stranded on its own line at the end, which is the thing that makes a column look unfinished.

Paragraphs with no orphan last line

CaveatThis is the one for body copy, and balance is not. Swapping them is the common mistake: pretty only fixes the last lines, balance evens out every line but gives up on long text.

FallbackIt costs more layout work than normal wrapping, which is why it is opt-in. Scope it to prose rather than dropping it on a universal selector.

CSS
p, li {
  /* no single word left alone */
  text-wrap: pretty;
}
hyphens: auto type

Justified narrow columns need hyphenation, otherwise the word spacing becomes uncomfortably irregular.

Hyphenation in a narrow column

CaveatIt does nothing without a lang attribute on the element or an ancestor. The browser needs to know which dictionary to hyphenate with, and silently declines rather than guessing.

FallbackNo dictionary for the language means no hyphens and ragged justification instead. Never rely on it to make a fixed-width layout fit.

HTML + CSS
<html lang="en"> /* without this, nothing */

.column {
  hyphens: auto;
  text-align: justify;
}
:user-invalid form
type something that is not an email, then leave the field

An error state that waits its turn

Caveat:invalid matches from the moment the page loads, so an empty required field is red before anyone has typed a character. :user-invalid waits until the field has been interacted with.

FallbackWhere it is unsupported nothing turns red at all, which is the safe direction. Keep the browser message as the real error and use colour only to point at it.

CSS
/* not :invalid, that fires on load */
input:user-invalid { border-color: #e06c6c; }
input:user-valid   { border-color: #93c47d; }
accent-color form

Native controls in your palette

CaveatOne property, one colour, no second opinion — the tick, the thumb and the fill are all derived from it. Need the check mark a different colour from the box and you are back to rebuilding the control.

FallbackIt inherits, so setting it once on a form covers everything inside. Where unsupported you get the platform blue, which is a working control either way.

CSS
form {
  /* inherits to every control inside */
  accent-color: #93c47d;
}
:placeholder-shown form

A label that floats out of the way

CaveatThe trick needs a real placeholder attribute — even a single space — because the selector matches the placeholder being shown, not the value being empty. Drop the attribute and the label never moves.

FallbackThe label has to come after the input in the markup for the sibling selector to reach it. Order it visually with CSS, never by moving it back in the DOM.

HTML + CSS
<input placeholder=" "><label>Project</label>

/* label follows input, so + can reach it */
input:not(:placeholder-shown) + label,
input:focus + label { translate: 0 -8px; }
scroll-snap-type scroll
one
two
three

A carousel the browser drives

Caveatscroll-snap-type goes on the scroller and scroll-snap-align on every child. Set only the first and nothing snaps, with no indication that half the recipe is missing.

FallbackAdd scroll-padding matching any sticky header, or the snapped item lands underneath it. Momentum, keyboard and touch all come free, with no library involved.

CSS
.track {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}
.track > * {
  /* the half everyone forgets */
  scroll-snap-align: start;
}
overscroll-behavior scroll

Scroll to the bottom of this panel, then keep going.

The page behind it does not take over.

That handoff is called scroll chaining, and contain switches it off.

It is one line, and it replaces every body-lock hack.

You have reached the end. The page stayed where it was.

Scrolling that stops at the edge

CaveatThis is the real fix for the page scrolling behind an open modal. Locking body with overflow: hidden throws away the reading position and jumps the layout by the scrollbar width.

FallbackReach the end of this panel and keep scrolling: the page underneath stays put. Unsupported engines chain as before, which is the old behaviour rather than a broken one.

CSS
.panel {
  overflow: auto;
  /* the scroll stops here */
  overscroll-behavior: contain;
}
scroll-padding-top scroll
Build

Compiles the site and writes it to the output directory.

Deploy

Uploads the result and swaps the alias once it is live.

Rollback

Points the alias back at the previous deployment.

Anchor links that clear a sticky header

CaveatJump to an anchor under a sticky header and the heading lands behind it. The offset belongs to the scroll container as scroll-padding-top, or to the target as scroll-margin-top, never to both.

FallbackIt applies to every scroll into view, including focus moves and browser find. Nothing to hook up, and no smooth-scroll script to fight with.

CSS
/* on the scroller, once */
html { scroll-padding-top: 34px; }

/* or per target, if the header varies */
:target { scroll-margin-top: 34px; }
:has() state

A wrapper that knows its input is wrong

CaveatSpecificity is taken from the heaviest selector inside the brackets, not from :has() itself. Put an id in there and the whole rule inherits an id-level weight you did not intend to spend.

FallbackPair it with :user-invalid, never :invalid, or the wrapper is red before anyone has typed. Without :has() the field simply stays neutral.

CSS
/* the parent selector we waited years for */
.field:has(input:user-invalid) {
  border-color: #e06c6c;
  background: rgba(224, 108, 108, .07);
}
:has() + :not() hover

Hovering one row quiets the others

CaveatThis re-evaluates on every pointer move across the list. On a few dozen rows it is free; on a table of several hundred it is a visible frame cost, and the fix is to move the rule to the row rather than the list.

FallbackGuard it with @media (hover: hover). On touch the hover state latches after a tap and leaves every other row dimmed with nothing hovered.

CSS
@media (hover: hover) {
  /* only while something is hovered */
  .list:has(a:hover) a:not(:hover) {
    opacity: .35;
  }
}
:has(:nth-child()) layout
  • item 1
  • item 2
  • item 3

A layout that changes when the list gets long

CaveatA quantity query counts children, so it re-runs whenever the list changes. Keep the threshold to one breakpoint: chaining several counts makes the layout jump twice while items are still loading in.

FallbackYou cannot nest :has() inside :has(), and it will not match pseudo-elements. Both are silent failures — the rule is simply dropped.

CSS
/* four or more, go two across */
.list:has(li:nth-child(4)) {
  grid-template-columns: 1fr 1fr;
}
showModal() dialog
Focus is trapped

Tab around: focus stays inside. Escape closes it. The backdrop behind is a real pseudo-element you can style.

A modal that traps focus for you

Caveatshow() and showModal() are different elements in practice. Only the modal one gets ::backdrop, makes the rest of the page inert, traps focus and closes on Escape. Call the wrong one and you have a floating box with none of it.

FallbackClose it with <form method="dialog"> and the button needs no script at all. Escape fires cancel, not close, so intercept that one if you need to confirm.

HTML + JS
<dialog>
  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

// not .show() — that skips all of it
dialog.showModal();
popovertarget dialog
Top layer, light dismiss, Escape to close. Two attributes, zero lines of script.

A popover with no JavaScript at all

CaveatIt lives in the top layer, so z-index on it or on anything around it is meaningless. Stacking is decided by the order things were promoted, and that trips up anyone debugging it the usual way.

FallbackLight dismiss, Escape and the trigger wiring all come from the two attributes. Where the API is missing the panel renders inline as ordinary content rather than disappearing.

HTML
<button popovertarget="tips">Show tips</button>

<!-- no script, no listener, no library -->
<div id="tips" popover>…</div>
inert a11y
Danger zone

A section the page cannot reach

Caveatinert removes the subtree from focus order and from the accessibility tree, which pointer-events: none and disabled do not. Do not add it around an open modal: showModal() already inerts the rest of the page, and doing both can strand focus.

FallbackIt carries no styling of its own, so dim it yourself with [inert]. Where unsupported the controls stay reachable — degraded, but never invisible to a screen reader while visible on screen.

HTML + CSS
<fieldset inert>…</fieldset>

/* inert paints nothing, say so yourself */
[inert] { opacity: .35; }
@starting-style motion
faded in from display: none

Animating something in from display: none

CaveatThere is nothing to transition from when an element appears: it had no previous style. @starting-style supplies that first frame. Without it the element simply snaps into place and the transition never runs.

FallbackThe rule has to match the same element in its shown state, and it must come after that rule in the sheet. Put it first and it loses on cascade order, silently.

CSS
.panel { opacity: 0; transition: opacity .3s; }
.open .panel { opacity: 1; }

/* the frame it starts from */
@starting-style {
  .open .panel { opacity: 0; }
}
allow-discrete motion
fades out before it goes

The exit animation, which is the hard half

Caveatdisplay is a discrete property: it flips at the halfway point, so the element vanishes mid-fade. allow-discrete makes the browser hold display: none back until the rest of the transition has finished.

FallbackIt only delays the property, never interpolates it. Nothing to fall back to either: without support the element disappears immediately, which is exactly today's behaviour.

CSS
.panel {
  transition: opacity .35s,
              display .35s allow-discrete;
}
/* now display: none waits for the fade */
startViewTransition() motion
  • Build
  • Test
  • Deploy

Reordering a list without it jumping

CaveatEvery element you want animated needs a view-transition-name, and each one has to be unique at the moment of the transition. Two matching names and the transition is skipped outright, with an error only in the console.

FallbackWhere the API is missing the callback still runs and the DOM still updates — you lose the animation and nothing else. That makes it safe to ship without a feature check around the update itself.

JS + CSS
// the DOM change goes inside the callback
document.startViewTransition?.(() => reorder())
  ?? reorder();

/* unique, or the transition is skipped */
#build { view-transition-name: build; }
#deploy { view-transition-name: deploy; }