Entry 01

One focus ring with :focus-visible

Use it as the focus style for every interactive element. Never write outline: none.

Open
Press Tab through the row. Each ring takes the color and size of its element. A mouse click on a button shows no ring, and a click into the input does.
Code CSS · 4 lines
:focus-visible {
  outline: max(2px, 0.08em) solid currentColor;
  outline-offset: 0.25em;
}
Why it works, the rules and the credits 3 reasons · 2 rules · support · credits

Why it works

  1. The browser decides when a ring helps. Keyboard focus gets one, a mouse click on a button does not, and text inputs always do.
  2. currentColor and em make the ring follow each component's color and size.
  3. outline takes no layout space and survives forced-colors mode, which removes box-shadow.

Rules

  1. On a filled button currentColor can fail contrast. Set the outline to the button's background color there.
  2. A component that draws its ring with box-shadow keeps outline-color: transparent, never outline: none, so forced-colors mode can repaint it.

Support

Chrome
86
Firefox
85
Safari
15.4

Credits

Borrowed from: Stephanie Eckles, Modern CSS Upgrades To Improve Accessibility, 2021-04-09, and Smol Focus Styles · Kevin Powell, 3 super small changes to improve your CSS, 2023-03-07

Entry 02

Hover styles only where hover exists

Use it on every :hover rule, so a tap on a touch screen does not leave the hover state stuck.

Open
With a mouse the button changes on hover. On a touch screen, or with a stylus, the readout says the query does not match and a tap leaves nothing stuck.
Code CSS · 3 lines
@media (hover: hover) and (pointer: fine) {
  .button:hover { background: var(--primary-hover); }
}
Why it works, the rules and the credits 2 reasons · 2 rules · support · credits

Why it works

  1. Touch browsers apply :hover on tap and keep it until the next tap elsewhere. hover: hover asks whether the primary input can hover.
  2. pointer: fine asks whether it is precise, like a mouse. It rules out styluses and the Android devices that claim hover support.

Rules

  1. Tailwind v4's hover: variant wraps itself in (hover: hover) only. Write the full query by hand everywhere else.
  2. Touch users still need feedback on press. Give it with :active, which works for every input.

Support

Chrome
38
Firefox
64
Safari
9

Credits

Borrowed from: Emil Kowalski, mobile-native skill and emil-design-eng skill · Rauno Freiberg, Web Interface Guidelines

Entry 03

Press feedback

Use it on every button and anything else that can be pressed.

Open
Press and hold a button. It shrinks the moment you press, label and all, and eases back when you let go. With Reduce Motion on it still shrinks, with no easing.
Code CSS · 5 lines
.button:active { transform: scale(0.97); }

@media (prefers-reduced-motion: no-preference) {
  .button { transition: transform 160ms var(--ease-out, ease-out); }
}
Why it works, the rules and the credits 2 reasons · 3 rules · support · credits

Why it works

  1. A native button responds the instant a finger lands. :active does the same on every kind of input. Feedback that waits for click arrives when the finger leaves, and that reads as lag.
  2. scale() shrinks the label and icon along with the button, so the whole control reads as pressed.

Rules

  1. Keep the scale between 0.95 and 0.98.
  2. The reset removes the browser's tap highlight. Without this entry a tap gives no feedback at all.
  3. When feedback needs a script, listen for pointerdown, not click.

Support

Every browser.

Credits

Borrowed from: Emil Kowalski, emil-design-eng skill and mobile-native skill

Entry 04

Hit area larger than the visual

Use it on icon buttons, close buttons and any target that looks smaller than 44px.

Open
Click inside the dashed square but outside the small button. The count goes up. The square is 44px, and the large button has none.
Code CSS · 7 lines
.icon-button { position: relative; }

.icon-button::after {
  content: "";
  position: absolute;
  inset: min(0px, (100% - 44px) / 2);
}
Why it works, the rules and the credits 2 reasons · 2 rules · support · credits

Why it works

  1. The pseudo-element belongs to the button, so a click on it hits the button.
  2. The negative inset grows the area to exactly 44px. min(0px, …) leaves larger targets alone.

Rules

  1. overflow: hidden or clip on the button cuts the area off.
  2. It does not work on <input>, which has no pseudo-elements. Wrap it in a <label>.

Support

Chrome
87
Firefox
66
Safari
14.1

Credits

Borrowed from: Emil Kowalski, Agents with Taste, for the rule · Vercel Web Interface Guidelines. The inset formula was written for this list.

Entry 05

Whole card clickable from one link

Use it for cards, list rows and tiles where the whole area should navigate. It replaces an <a> wrapped around the card, which makes a screen reader read every word as the link text, and it replaces a click handler on a div.

Open
Click anywhere on the card, the corners and the paragraph included: the link count goes up. Click Save: only the button count goes up. Tab to the link: the ring is drawn round the whole card.
Code HTML · CSS · 2 blocks · 24 lines
<article class="card">
  <h3><a class="card-link" href="/reports/q3">Quarterly report</a></h3>
  <p>Revenue grew in every region.</p>
  <button type="button">Save</button>
</article>
.card { position: relative; }

.card-link::after {
  content: "";
  position: absolute;
  inset: 0;
}

.card-link:focus-visible { outline: 2px solid transparent; }

.card:has(.card-link:focus-visible) {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

.card :is(button, a:not(.card-link)) {
  position: relative;
  z-index: 1;
}
Why it works, the rules and the credits 4 reasons · 4 rules · support · credits

Why it works

  1. The pseudo-element belongs to the link and covers the card, so a click anywhere activates the link. It is a real link, so open in new tab and the status bar URL still work.
  2. The link text stays short. A screen reader announces "Quarterly report" and not the whole card.
  3. :has() moves the focus ring from the small link to the card it activates.
  4. Other controls sit above the overlay and keep their own clicks.

Rules

  1. No element between the card and the link may be positioned. The overlay would size itself to that element.
  2. The link's own ring is made transparent, never removed, as One focus ring with :focus-visible requires. Write the full 2px solid transparent. Safari ignores outline-color on its default ring.
  3. Text under the overlay cannot be selected. Accept it.
  4. Put hover styles on .card:hover, inside the query from Hover styles only where hover exists.

Support

Chrome
105
Firefox
121
Safari
15.4

Credits

Borrowed from: Heydon Pickering, Cards, for the stretched ::after · Stripe, for moving the focus ring to the card with :has()

Entry 06

:has() for parent and page state

Use it wherever a script adds a class to a parent because of what it contains or what state a child is in.

Open
The article with an image spans two columns. Only the heading before a paragraph loses its margin. The form lights up when you tab into its field. The page stops scrolling while the dialog is open, and does not shift sideways.
Code CSS · 6 lines
article:has(img) { grid-column: span 2; }
h2:has(+ p) { margin-block-end: 0; }
form:has(:focus-visible) { background: var(--surface-raised); }

html { scrollbar-gutter: stable; }
html:has(dialog:modal) { overflow: hidden; }
Why it works, the rules and the credits 2 reasons · 3 rules · support · credits

Why it works

  1. :has() matches an element by its descendants or following siblings, and it accepts state pseudo-classes. Content and form state can style any ancestor.
  2. On the root it works as a page-wide listener. The last rule locks page scroll while a modal dialog is open and lifts the lock when it closes, with nothing to clean up.

Rules

  1. :modal matches showModal() only, so a non-modal dialog does not lock the page.
  2. Keep scrollbar-gutter: stable with the scroll lock. Without it the page shifts sideways when the scrollbar disappears. The reset sets it.
  3. :has() cannot be nested inside :has().

Support

Chrome
105
Firefox
121
Safari
15.4

Credits

Borrowed from: Jen Simmons, Using :has() as a CSS Parent Selector and much more, 2022-08-18 · Josh W. Comeau, The Undeniable Utility Of CSS :has, 2024-09-09 · Chris Coyier, Scroll-Locked Dialogs, 2024-02-19

Entry 07

Form feedback with :user-invalid

Use it for inline form validation, in place of blur listeners and a "touched" class.

Open
The field has no color on load. Type three characters and leave it: the border takes the danger color. Eight or more and it takes the success color.
Code CSS · HTML · 2 blocks · 3 lines
input:user-invalid { border-color: var(--danger); }
input:user-valid { border-color: var(--success); }
<input type="password" required minlength="8">
Why it works, the rules and the credits 2 reasons · 2 rules · support · credits

Why it works

  1. :invalid matches an empty required field on page load. The :user- versions match only after the user has edited the field or tried to submit.
  2. Validity comes from HTML attributes: required, minlength, type, pattern.

Rules

  1. Color alone is not enough feedback. Pair it with text or an icon.
  2. The server still validates.

Support

Chrome
119
Firefox
88
Safari
16.5

Credits

Entry 08

Textarea that grows with its content

Use it for any multi-line input, such as a chat composer or a comment box. field-sizing: content sizes the textarea to its text, so there is no auto-grow script and no hidden mirror element.

Open
Type or paste several lines into each. Both start at three lines and stop at twelve. The second one eases to its new height.
Code CSS · HTML · JS · 4 blocks · 20 lines
textarea {
  field-sizing: content;
  min-height: 3lh;
  max-height: 12lh;
  resize: none;
}

The height snaps to each new line. To animate it, wrap the textarea and let a ResizeObserver copy its height onto the wrapper. The wrapper transitions.

<div class="field"><textarea></textarea></div>
.field {
  box-sizing: content-box;
  overflow: clip;
}

@media (prefers-reduced-motion: no-preference) {
  .field { transition: height 0.2s var(--ease-out, ease-out); }
}
const textarea = document.querySelector(".field textarea");

new ResizeObserver(([entry]) => {
  textarea.parentElement.style.height = `${entry.borderBoxSize[0].blockSize}px`;
}).observe(textarea);
Why it works, the rules and the credits 3 reasons · 4 rules · support · credits

Why it works

  1. The browser measures the text itself, so the size is right on first paint, on paste and on window resize.
  2. The textarea reaches its final height at once. Only the wrapper animates, and it clips the textarea until it catches up.
  3. lh is one line at the element's own line-height. min-height: 3lh means three lines at any font size.

Rules

  1. Bound it with min-height and max-height. A fixed height brings the fixed size back. Past max-height the textarea scrolls.
  2. Put the border and background on the wrapper and leave the textarea bare. The box the user sees must be the one that animates.
  3. Keep the wrapper content-box. The observer writes the textarea's full height, and a border-box wrapper would subtract its own border from it.
  4. Write no fallback. A browser without field-sizing shows a fixed textarea that scrolls.

Support

Chrome
123
Firefox
152
Safari
26.2

Baseline newly available since June 2026.

Credits

Borrowed from: Jhey Tompkins, @jh3yy on X, 2026-10-01

Docs: MDN: field-sizing · Adam Argyle, CSS field-sizing · MDN: ResizeObserver