@daydreamui/good-css 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -2
- package/dist/index.js +2 -0
- package/icon.svg +8 -0
- package/knowledge/INDEX.md +67 -0
- package/knowledge/compatibility.md +33 -0
- package/knowledge/examples/container-queries-with-container-units.md +150 -0
- package/knowledge/examples/content-grid-with-breakouts.md +161 -0
- package/knowledge/examples/intrinsic-grid.md +150 -0
- package/knowledge/examples/sidebar-that-wraps-on-its-own.md +156 -0
- package/knowledge/examples/subgrid-rows-shared-across-cards.md +154 -0
- package/knowledge/procedures.md +37 -0
- package/knowledge/recipes/accordion-that-animates-its-height.md +59 -0
- package/knowledge/recipes/anchor-targets-that-clear-a-sticky-header.md +45 -0
- package/knowledge/recipes/carousel-on-native-scroll.md +87 -0
- package/knowledge/recipes/concentric-nested-radius.md +45 -0
- package/knowledge/recipes/container-queries-with-container-units.md +52 -0
- package/knowledge/recipes/content-clear-of-the-notch.md +45 -0
- package/knowledge/recipes/content-grid-with-breakouts.md +69 -0
- package/knowledge/recipes/cross-document-view-transitions.md +39 -0
- package/knowledge/recipes/enter-and-exit-transitions-from-display-none.md +87 -0
- package/knowledge/recipes/fluid-sizes-with-clamp.md +46 -0
- package/knowledge/recipes/form-feedback-with-user-invalid.md +45 -0
- package/knowledge/recipes/has-for-parent-and-page-state.md +46 -0
- package/knowledge/recipes/hit-area-larger-than-the-visual.md +47 -0
- package/knowledge/recipes/hover-styles-only-where-hover-exists.md +43 -0
- package/knowledge/recipes/icon-sized-by-the-text-beside-it.md +69 -0
- package/knowledge/recipes/image-box-that-holds-any-upload.md +67 -0
- package/knowledge/recipes/indicator-that-slides-to-the-active-item.md +80 -0
- package/knowledge/recipes/intrinsic-grid.md +41 -0
- package/knowledge/recipes/label-centered-on-its-letters-with-text-box.md +59 -0
- package/knowledge/recipes/logical-properties.md +48 -0
- package/knowledge/recipes/long-text-that-wraps-truncates-or-clamps.md +62 -0
- package/knowledge/recipes/motion-tokens.md +46 -0
- package/knowledge/recipes/no-rubber-band-bounce-on-desktop.md +47 -0
- package/knowledge/recipes/oklch-color.md +41 -0
- package/knowledge/recipes/one-fluid-scale-for-type-and-space.md +85 -0
- package/knowledge/recipes/one-focus-ring-with-focus-visible.md +44 -0
- package/knowledge/recipes/one-set-of-color-tokens-for-light-and-dark.md +53 -0
- package/knowledge/recipes/opt-in-motion.md +43 -0
- package/knowledge/recipes/overflow-clip-over-hidden.md +41 -0
- package/knowledge/recipes/popover-anchored-to-its-trigger.md +57 -0
- package/knowledge/recipes/press-feedback.md +43 -0
- package/knowledge/recipes/push-one-item-away-with-an-auto-margin.md +70 -0
- package/knowledge/recipes/reveal-with-clip-path.md +61 -0
- package/knowledge/recipes/safe-alignment.md +41 -0
- package/knowledge/recipes/scroll-area-between-a-fixed-header-and-footer.md +67 -0
- package/knowledge/recipes/section-spacing-that-depends-on-its-neighbors.md +54 -0
- package/knowledge/recipes/shadow-change-that-fades-and-does-not-repaint.md +80 -0
- package/knowledge/recipes/sidebar-that-wraps-on-its-own.md +56 -0
- package/knowledge/recipes/space-between-siblings-set-by-the-parent.md +59 -0
- package/knowledge/recipes/stack-layers-with-grid.md +43 -0
- package/knowledge/recipes/styles-that-apply-only-when-a-scroller-overflows.md +99 -0
- package/knowledge/recipes/subgrid-rows-shared-across-cards.md +51 -0
- package/knowledge/recipes/tabular-numbers.md +40 -0
- package/knowledge/recipes/textarea-that-grows-with-its-content.md +73 -0
- package/knowledge/recipes/the-reset.md +96 -0
- package/knowledge/recipes/transition-a-custom-property-with-property.md +70 -0
- package/knowledge/recipes/whole-card-clickable-from-one-link.md +71 -0
- package/knowledge/upstream-guidance.md +72 -0
- package/manifest.json +12 -0
- package/package.json +15 -4
- package/playgrounds/base.css +57 -0
- package/playgrounds/container-queries-with-container-units.css +10 -0
- package/playgrounds/container-queries-with-container-units.html +13 -0
- package/playgrounds/content-grid-with-breakouts.css +21 -0
- package/playgrounds/content-grid-with-breakouts.html +13 -0
- package/playgrounds/fixtures.json +32 -0
- package/playgrounds/intrinsic-grid.css +5 -0
- package/playgrounds/intrinsic-grid.html +19 -0
- package/playgrounds/sidebar-that-wraps-on-its-own.css +16 -0
- package/playgrounds/sidebar-that-wraps-on-its-own.html +13 -0
- package/playgrounds/subgrid-rows-shared-across-cards.css +12 -0
- package/playgrounds/subgrid-rows-shared-across-cards.html +13 -0
- package/upstream/LICENSE +21 -0
- package/upstream/lock.json +7 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-content-grid-with-breakouts"
|
|
3
|
+
title: "Content grid with breakouts"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "layout"
|
|
8
|
+
- "content"
|
|
9
|
+
- "grid"
|
|
10
|
+
- "breakouts"
|
|
11
|
+
summary: "Use it wherever you would reach for a centered max-width container. One grid on the section replaces the `section > .container` wrapper pair, and any child can be content width, wider, or edge to edge. Put it on `main` for a page of flowing content, or on each section. It suits CMS content well, because an editor widens a block with one class and no extra wrapper."
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
15
|
+
# Content grid with breakouts
|
|
16
|
+
|
|
17
|
+
Use it wherever you would reach for a centered max-width container. One grid on the section replaces the `section > .container` wrapper pair, and any child can be content width, wider, or edge to edge. Put it on `main` for a page of flowing content, or on each section. It suits CMS content well, because an editor widens a block with one class and no extra wrapper.
|
|
18
|
+
|
|
19
|
+
The container defines three nested widths with named grid lines:
|
|
20
|
+
|
|
21
|
+
```css
|
|
22
|
+
.content-grid {
|
|
23
|
+
--gutter: 1.5rem;
|
|
24
|
+
--content: 64rem;
|
|
25
|
+
--breakout: 80rem;
|
|
26
|
+
|
|
27
|
+
display: grid;
|
|
28
|
+
grid-template-columns:
|
|
29
|
+
[full-width-start] minmax(var(--gutter), 1fr)
|
|
30
|
+
[breakout-start] minmax(0, calc((var(--breakout) - var(--content)) / 2))
|
|
31
|
+
[content-start] min(100% - var(--gutter) * 2, var(--content)) [content-end]
|
|
32
|
+
minmax(0, calc((var(--breakout) - var(--content)) / 2)) [breakout-end]
|
|
33
|
+
minmax(var(--gutter), 1fr) [full-width-end];
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Each child picks a width with `grid-column`. Content is the default:
|
|
38
|
+
|
|
39
|
+
```css
|
|
40
|
+
:is(.content-grid, .full-width) > * { grid-column: content; }
|
|
41
|
+
:is(.content-grid, .full-width) > .breakout { grid-column: breakout; }
|
|
42
|
+
:is(.content-grid, .full-width) > .full-width {
|
|
43
|
+
grid-column: full-width;
|
|
44
|
+
display: grid;
|
|
45
|
+
grid-template-columns: inherit;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Why it works:
|
|
50
|
+
|
|
51
|
+
- Two lines named `content-start` and `content-end` create a named area, `content`. That is why `grid-column: content` works with no line numbers.
|
|
52
|
+
- `min(100% - 2 × gutter, max)` shrinks the content column on small screens with no media query.
|
|
53
|
+
- `minmax(0, …)` lets the breakout tracks collapse, so a breakout falls back to content width when there is no room. With a fixed breakout size the page overflows on small screens.
|
|
54
|
+
- `grid-template-columns: inherit` gives a full-width band the same grid, so content inside it lines up with the page column without another wrapper. This is Kevin Powell's addition. In Ryan Mulligan's original the full-width child is a plain box.
|
|
55
|
+
|
|
56
|
+
Rules:
|
|
57
|
+
|
|
58
|
+
- Every child must be placed. An unplaced child of the grid lands in the gutter track.
|
|
59
|
+
- Every direct child is a grid item. Wrap a run of inline elements in one element.
|
|
60
|
+
- Change a width by overriding `--content`, not by writing a new column template.
|
|
61
|
+
|
|
62
|
+
In other systems: the column template goes wherever the project defines reusable styles. Where child selectors are not available, as in StyleX or Tailwind without a custom variant, each child sets its own `grid-column` to `content`, `breakout` or `full-width`.
|
|
63
|
+
|
|
64
|
+
- Borrowed from: Kevin Powell, [A new approach to container and wrapper classes](https://www.youtube.com/watch?v=c13gpBrnGEw), 2023-11-09 · Ryan Mulligan, [Layout Breakouts with CSS Grid](https://ryanmulligan.dev/blog/layout-breakouts/) · Stephanie Eckles, [SmolCSS breakout grid](https://smolcss.dev/#smol-breakout-grid)
|
|
65
|
+
- Docs: [MDN: named grid lines](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Named_grid_lines) · [MDN: min()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/min)
|
|
66
|
+
|
|
67
|
+
Source: [Good CSS](https://good-css.com/specimen/content-grid-with-breakouts), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#content-grid-with-breakouts).
|
|
68
|
+
|
|
69
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-cross-document-view-transitions"
|
|
3
|
+
title: "Cross-document view transitions"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "motion"
|
|
8
|
+
- "cross"
|
|
9
|
+
- "document"
|
|
10
|
+
- "view"
|
|
11
|
+
- "transitions"
|
|
12
|
+
summary: "Use it on any multi-page site to crossfade between pages. It replaces a client-side router or a page-transition script."
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
16
|
+
# Cross-document view transitions
|
|
17
|
+
|
|
18
|
+
Use it on any multi-page site to crossfade between pages. It replaces a client-side router or a page-transition script.
|
|
19
|
+
|
|
20
|
+
```css
|
|
21
|
+
@view-transition { navigation: auto; }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Why it works:
|
|
25
|
+
|
|
26
|
+
- On a same-origin navigation the browser snapshots the old and new page and crossfades them. Parts that do not change, such as the header, appear to stay in place.
|
|
27
|
+
|
|
28
|
+
Rules:
|
|
29
|
+
|
|
30
|
+
- Both pages must include the rule.
|
|
31
|
+
- The default crossfade adds no movement and needs no reduced-motion guard. If you add slides, put the rule inside `@media (prefers-reduced-motion: no-preference)`.
|
|
32
|
+
|
|
33
|
+
Support: Chrome 126, Safari 18.2. Firefox navigates as normal.
|
|
34
|
+
|
|
35
|
+
- Borrowed from: Jen Simmons and James Craig, [Two lines of Cross-Document View Transitions code you can use on every website today](https://webkit.org/blog/16967/two-lines-of-cross-document-view-transitions-code-you-can-use-on-every-website-today/), 2025-05-21 · Bramus Van Damme, [Cross-document view transitions for multi-page applications](https://developer.chrome.com/docs/web-platform/view-transitions/cross-document)
|
|
36
|
+
|
|
37
|
+
Source: [Good CSS](https://good-css.com/specimen/cross-document-view-transitions), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#cross-document-view-transitions).
|
|
38
|
+
|
|
39
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-enter-and-exit-transitions-from-display-none"
|
|
3
|
+
title: "Enter and exit transitions from display: none"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "show"
|
|
8
|
+
- "hide"
|
|
9
|
+
- "enter"
|
|
10
|
+
- "exit"
|
|
11
|
+
- "transitions"
|
|
12
|
+
- "display"
|
|
13
|
+
- "none"
|
|
14
|
+
summary: "Use it for dialogs, popovers and anything else toggled with `display`. It replaces a script that adds classes and waits for `transitionend`."
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
18
|
+
# Enter and exit transitions from `display: none`
|
|
19
|
+
|
|
20
|
+
Use it for dialogs, popovers and anything else toggled with `display`. It replaces a script that adds classes and waits for `transitionend`.
|
|
21
|
+
|
|
22
|
+
```css
|
|
23
|
+
dialog,
|
|
24
|
+
[popover] {
|
|
25
|
+
opacity: 0;
|
|
26
|
+
transition:
|
|
27
|
+
opacity 0.2s var(--ease-out, ease-out),
|
|
28
|
+
translate 0.2s var(--ease-out, ease-out),
|
|
29
|
+
display 0.2s allow-discrete,
|
|
30
|
+
overlay 0.2s allow-discrete;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
dialog::backdrop {
|
|
34
|
+
opacity: 0;
|
|
35
|
+
transition:
|
|
36
|
+
opacity 0.2s var(--ease-out, ease-out),
|
|
37
|
+
display 0.2s allow-discrete,
|
|
38
|
+
overlay 0.2s allow-discrete;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
dialog[open],
|
|
42
|
+
dialog[open]::backdrop { opacity: 1; }
|
|
43
|
+
|
|
44
|
+
[popover]:popover-open { opacity: 1; }
|
|
45
|
+
|
|
46
|
+
@starting-style {
|
|
47
|
+
dialog[open],
|
|
48
|
+
[popover]:popover-open,
|
|
49
|
+
dialog[open]::backdrop { opacity: 0; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
53
|
+
dialog,
|
|
54
|
+
[popover] { translate: 0 0.5rem; }
|
|
55
|
+
|
|
56
|
+
dialog[open] { translate: 0 0; }
|
|
57
|
+
|
|
58
|
+
[popover]:popover-open { translate: 0 0; }
|
|
59
|
+
|
|
60
|
+
@starting-style {
|
|
61
|
+
dialog[open],
|
|
62
|
+
[popover]:popover-open { translate: 0 0.5rem; }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Why it works:
|
|
68
|
+
|
|
69
|
+
- `@starting-style` gives the browser a "before open" state to transition from when the element leaves `display: none`. That is the entry.
|
|
70
|
+
- `allow-discrete` on `display` delays the switch to `display: none` until the other transitions finish. That is the exit. `overlay` keeps the element in the top layer meanwhile.
|
|
71
|
+
- The fade is not motion, so it runs for everyone. Only the slide sits inside the reduced-motion query.
|
|
72
|
+
|
|
73
|
+
Rules:
|
|
74
|
+
|
|
75
|
+
- Put `@starting-style` after the open-state rule. It has no extra weight in the cascade.
|
|
76
|
+
- Never start from `scale(0)`. If the element scales in, start at `scale(0.95)` with `opacity: 0`.
|
|
77
|
+
- The dialog's `::backdrop` needs the same three states and its own transition, as in the block. Without them it snaps while the dialog fades.
|
|
78
|
+
- Write no fallback. Where the exit is not supported the element closes at once.
|
|
79
|
+
- Keep `[popover]:popover-open` out of the selector list that opens the dialog. A browser that does not know one selector in a list drops the whole rule, and the dialog would open at `opacity: 0`. Inside `@starting-style` the shared list is safe, because every browser that reads the block knows the selector.
|
|
80
|
+
|
|
81
|
+
Support: both directions in Chrome 117. In Safari 27 the entry animates and `<dialog>` and popovers close at once, tested in real Safari on 2026-10-01. An element toggled with a class animates both ways there. Firefox 129 animates the entry only.
|
|
82
|
+
|
|
83
|
+
- Borrowed from: Una Kravets and Joey Arhar, [Four new CSS features for smooth entry and exit animations](https://developer.chrome.com/blog/entry-exit-animations) · Kevin Powell, [Transition to and from display: none](https://www.youtube.com/watch?v=KD3_l3S_D6M), 2026-07-22 · Chris Coyier, [In-N-Out Animations: Dialogs](https://blog.master.dev/in-n-out-animations-dialogs-part-1-3/), 2026-06-01 · Adam Argyle, [Steal this popover code](https://nerdy.dev/steal-this-popover-starter-kit), 2024-03-15 · Ahmad Shadeed, [Grouping vendor selectors](https://defensivecss.dev/tip/grouping-selectors/), Defensive CSS, for the split rules
|
|
84
|
+
|
|
85
|
+
Source: [Good CSS](https://good-css.com/specimen/enter-and-exit-transitions-from-display-none), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#enter-and-exit-transitions-from-display-none).
|
|
86
|
+
|
|
87
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-fluid-sizes-with-clamp"
|
|
3
|
+
title: "Fluid sizes with clamp()"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "foundations"
|
|
8
|
+
- "fluid"
|
|
9
|
+
- "sizes"
|
|
10
|
+
- "clamp"
|
|
11
|
+
summary: "Use it for anything that should grow with the screen, starting with font sizes and section spacing. One declaration replaces a ladder of breakpoint overrides."
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
15
|
+
# Fluid sizes with `clamp()`
|
|
16
|
+
|
|
17
|
+
Use it for anything that should grow with the screen, starting with font sizes and section spacing. One declaration replaces a ladder of breakpoint overrides.
|
|
18
|
+
|
|
19
|
+
```css
|
|
20
|
+
:root {
|
|
21
|
+
--text-body: clamp(1rem, 0.75rem + 1vw, 1.75rem);
|
|
22
|
+
--space-section: clamp(3rem, 1rem + 6vw, 8rem);
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Why it works:
|
|
27
|
+
|
|
28
|
+
- `clamp(min, preferred, max)` uses the preferred value and stops at the two bounds.
|
|
29
|
+
- The `vw` part of the preferred value grows with the viewport. The `rem` part sets where the growth starts.
|
|
30
|
+
- `--text-body` above is 16px up to a 400px viewport, 28px from 1600px, and a straight line between.
|
|
31
|
+
|
|
32
|
+
Rules:
|
|
33
|
+
|
|
34
|
+
- Write the preferred value as `rem + vw`, never `vw` alone. `clamp(1rem, 1vw, 1.75rem)` stays at 16px on every screen narrower than 1600px, because 1vw is smaller than 1rem until then. A bare `vw` value also ignores the reader's font size setting.
|
|
35
|
+
- Work the preferred value out from two points. The slope is `(max - min) / (wide - narrow)`, in px over px, and times 100 it is the `vw` number. The `rem` part is `min - slope × narrow`.
|
|
36
|
+
- Keep both bounds in `rem` so they follow the reader's font size.
|
|
37
|
+
- For a font size, keep the maximum at or below 2.5 times the minimum. The `vw` part does not grow when the reader zooms, so fluid text grows less than the page. Past 2.5 times, 500% zoom cannot double the text at some widths, and that fails WCAG 1.4.4.
|
|
38
|
+
- Put fluid values in tokens. Components use the token and never repeat the math.
|
|
39
|
+
- For more than two or three sizes, derive a whole scale as [One fluid scale for type and space](https://good-css.com/#one-fluid-scale-for-type-and-space) does.
|
|
40
|
+
|
|
41
|
+
- Borrowed from: Kyrylo Silin, @kyrylo on X, 2026-05-10 · James Gilyead and Trys Mudford, [Utopia](https://utopia.fyi/) · Adrian Roselli, [Responsive Type and Zoom](https://adrianroselli.com/2019/12/responsive-type-and-zoom.html) · Maxwell Barvian, [Addressing Accessibility Concerns With Using Fluid Type](https://www.smashingmagazine.com/2023/11/addressing-accessibility-concerns-fluid-type/), 2023-11-07, for the 2.5 limit
|
|
42
|
+
- Docs: [MDN: clamp()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/clamp) · Adrian Bece, [Modern Fluid Typography Using CSS Clamp](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/)
|
|
43
|
+
|
|
44
|
+
Source: [Good CSS](https://good-css.com/specimen/fluid-sizes-with-clamp), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#fluid-sizes-with-clamp).
|
|
45
|
+
|
|
46
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-form-feedback-with-user-invalid"
|
|
3
|
+
title: "Form feedback with :user-invalid"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "interaction"
|
|
8
|
+
- "form"
|
|
9
|
+
- "feedback"
|
|
10
|
+
- "user"
|
|
11
|
+
- "invalid"
|
|
12
|
+
summary: 'Use it for inline form validation, in place of blur listeners and a "touched" class.'
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
16
|
+
# Form feedback with `:user-invalid`
|
|
17
|
+
|
|
18
|
+
Use it for inline form validation, in place of blur listeners and a "touched" class.
|
|
19
|
+
|
|
20
|
+
```css
|
|
21
|
+
input:user-invalid { border-color: var(--danger); }
|
|
22
|
+
input:user-valid { border-color: var(--success); }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```html
|
|
26
|
+
<input type="password" required minlength="8">
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Why it works:
|
|
30
|
+
|
|
31
|
+
- `: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.
|
|
32
|
+
- Validity comes from HTML attributes: `required`, `minlength`, `type`, `pattern`.
|
|
33
|
+
|
|
34
|
+
Rules:
|
|
35
|
+
|
|
36
|
+
- Color alone is not enough feedback. Pair it with text or an icon.
|
|
37
|
+
- The server still validates.
|
|
38
|
+
|
|
39
|
+
Support: Chrome 119, Firefox 88, Safari 16.5.
|
|
40
|
+
|
|
41
|
+
- Borrowed from: Kevin Powell, [Improve forms with :user-valid and :user-invalid](https://www.youtube.com/watch?v=NmSifR3dS7o), 2025-02-20, and [Use these CSS features instead of JavaScript](https://www.youtube.com/watch?v=qu1jE41O_8o), 2026-09-23
|
|
42
|
+
|
|
43
|
+
Source: [Good CSS](https://good-css.com/specimen/form-feedback-with-user-invalid), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#form-feedback-with-user-invalid).
|
|
44
|
+
|
|
45
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-has-for-parent-and-page-state"
|
|
3
|
+
title: ":has() for parent and page state"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "interaction"
|
|
8
|
+
- "has"
|
|
9
|
+
- "parent"
|
|
10
|
+
- "page"
|
|
11
|
+
- "state"
|
|
12
|
+
summary: "Use it wherever a script adds a class to a parent because of what it contains or what state a child is in."
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
16
|
+
# `:has()` for parent and page state
|
|
17
|
+
|
|
18
|
+
Use it wherever a script adds a class to a parent because of what it contains or what state a child is in.
|
|
19
|
+
|
|
20
|
+
```css
|
|
21
|
+
article:has(img) { grid-column: span 2; }
|
|
22
|
+
h2:has(+ p) { margin-block-end: 0; }
|
|
23
|
+
form:has(:focus-visible) { background: var(--surface-raised); }
|
|
24
|
+
|
|
25
|
+
html { scrollbar-gutter: stable; }
|
|
26
|
+
html:has(dialog:modal) { overflow: hidden; }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Why it works:
|
|
30
|
+
|
|
31
|
+
- `:has()` matches an element by its descendants or following siblings, and it accepts state pseudo-classes. Content and form state can style any ancestor.
|
|
32
|
+
- 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.
|
|
33
|
+
|
|
34
|
+
Rules:
|
|
35
|
+
|
|
36
|
+
- `:modal` matches `showModal()` only, so a non-modal dialog does not lock the page.
|
|
37
|
+
- Keep `scrollbar-gutter: stable` with the scroll lock. Without it the page shifts sideways when the scrollbar disappears. [The reset](https://good-css.com/#the-reset) sets it.
|
|
38
|
+
- `:has()` cannot be nested inside `:has()`.
|
|
39
|
+
|
|
40
|
+
Support: Chrome 105, Firefox 121, Safari 15.4.
|
|
41
|
+
|
|
42
|
+
- Borrowed from: Jen Simmons, [Using :has() as a CSS Parent Selector and much more](https://webkit.org/blog/13096/css-has-pseudo-class/), 2022-08-18 · Josh W. Comeau, [The Undeniable Utility Of CSS :has](https://www.joshwcomeau.com/css/has/), 2024-09-09 · Chris Coyier, [Scroll-Locked Dialogs](https://blog.master.dev/scroll-locked-dialogs/), 2024-02-19
|
|
43
|
+
|
|
44
|
+
Source: [Good CSS](https://good-css.com/specimen/has-for-parent-and-page-state), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#has-for-parent-and-page-state).
|
|
45
|
+
|
|
46
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-hit-area-larger-than-the-visual"
|
|
3
|
+
title: "Hit area larger than the visual"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "interaction"
|
|
8
|
+
- "hit"
|
|
9
|
+
- "area"
|
|
10
|
+
- "larger"
|
|
11
|
+
- "than"
|
|
12
|
+
- "visual"
|
|
13
|
+
summary: "Use it on icon buttons, close buttons and any target that looks smaller than 44px."
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
17
|
+
# Hit area larger than the visual
|
|
18
|
+
|
|
19
|
+
Use it on icon buttons, close buttons and any target that looks smaller than 44px.
|
|
20
|
+
|
|
21
|
+
```css
|
|
22
|
+
.icon-button { position: relative; }
|
|
23
|
+
|
|
24
|
+
.icon-button::after {
|
|
25
|
+
content: "";
|
|
26
|
+
position: absolute;
|
|
27
|
+
inset: min(0px, (100% - 44px) / 2);
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Why it works:
|
|
32
|
+
|
|
33
|
+
- The pseudo-element belongs to the button, so a click on it hits the button.
|
|
34
|
+
- The negative inset grows the area to exactly 44px. `min(0px, …)` leaves larger targets alone.
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
|
|
38
|
+
- `overflow: hidden` or `clip` on the button cuts the area off.
|
|
39
|
+
- It does not work on `<input>`, which has no pseudo-elements. Wrap it in a `<label>`.
|
|
40
|
+
|
|
41
|
+
Support: Chrome 87, Firefox 66, Safari 14.1.
|
|
42
|
+
|
|
43
|
+
- Borrowed from: Emil Kowalski, [Agents with Taste](https://emilkowal.ski/ui/agents-with-taste), for the rule · [Vercel Web Interface Guidelines](https://vercel.com/design/guidelines). The `inset` formula was written for this list.
|
|
44
|
+
|
|
45
|
+
Source: [Good CSS](https://good-css.com/specimen/hit-area-larger-than-the-visual), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#hit-area-larger-than-the-visual).
|
|
46
|
+
|
|
47
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-hover-styles-only-where-hover-exists"
|
|
3
|
+
title: "Hover styles only where hover exists"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "interaction"
|
|
8
|
+
- "hover"
|
|
9
|
+
- "styles"
|
|
10
|
+
- "only"
|
|
11
|
+
- "where"
|
|
12
|
+
- "exists"
|
|
13
|
+
summary: "Use it on every `:hover` rule, so a tap on a touch screen does not leave the hover state stuck."
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
17
|
+
# Hover styles only where hover exists
|
|
18
|
+
|
|
19
|
+
Use it on every `:hover` rule, so a tap on a touch screen does not leave the hover state stuck.
|
|
20
|
+
|
|
21
|
+
```css
|
|
22
|
+
@media (hover: hover) and (pointer: fine) {
|
|
23
|
+
.button:hover { background: var(--primary-hover); }
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Why it works:
|
|
28
|
+
|
|
29
|
+
- Touch browsers apply `:hover` on tap and keep it until the next tap elsewhere. `hover: hover` asks whether the primary input can hover.
|
|
30
|
+
- `pointer: fine` asks whether it is precise, like a mouse. It rules out styluses and the Android devices that claim hover support.
|
|
31
|
+
|
|
32
|
+
Rules:
|
|
33
|
+
|
|
34
|
+
- Tailwind v4's `hover:` variant wraps itself in `(hover: hover)` only. Write the full query by hand everywhere else.
|
|
35
|
+
- Touch users still need feedback on press. Give it with `:active`, which works for every input.
|
|
36
|
+
|
|
37
|
+
Support: Chrome 38, Firefox 64, Safari 9.
|
|
38
|
+
|
|
39
|
+
- Borrowed from: Emil Kowalski, [mobile-native skill](https://github.com/emilkowalski/skills/blob/main/skills/mobile-native/SKILL.md) and [emil-design-eng skill](https://github.com/emilkowalski/skills/blob/main/skills/emil-design-eng/SKILL.md) · Rauno Freiberg, [Web Interface Guidelines](https://interfaces.rauno.me/)
|
|
40
|
+
|
|
41
|
+
Source: [Good CSS](https://good-css.com/specimen/hover-styles-only-where-hover-exists), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#hover-styles-only-where-hover-exists).
|
|
42
|
+
|
|
43
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-icon-sized-by-the-text-beside-it"
|
|
3
|
+
title: "Icon sized by the text beside it"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "text"
|
|
8
|
+
- "media"
|
|
9
|
+
- "icon"
|
|
10
|
+
- "sized"
|
|
11
|
+
- "beside"
|
|
12
|
+
summary: "Use it on every icon that sits next to a label, in buttons, links, list rows and notices. The icon takes its size from the font, so it follows the text and never needs a size per variant."
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
16
|
+
# Icon sized by the text beside it
|
|
17
|
+
|
|
18
|
+
Use it on every icon that sits next to a label, in buttons, links, list rows and notices. The icon takes its size from the font, so it follows the text and never needs a size per variant.
|
|
19
|
+
|
|
20
|
+
```css
|
|
21
|
+
.with-icon {
|
|
22
|
+
display: inline-flex;
|
|
23
|
+
align-items: baseline;
|
|
24
|
+
gap: 0.5em;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.with-icon > svg {
|
|
28
|
+
flex: none;
|
|
29
|
+
block-size: 1cap;
|
|
30
|
+
inline-size: auto;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.notice {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: start;
|
|
36
|
+
gap: 0.5em;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.notice > svg {
|
|
40
|
+
flex: none;
|
|
41
|
+
inline-size: 1em;
|
|
42
|
+
block-size: 1lh;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Why it works:
|
|
47
|
+
|
|
48
|
+
- `1cap` is the height of the font's capital letters. An icon that tall reads as one more capital.
|
|
49
|
+
- An `svg` has no baseline of its own. `align-items: baseline` rests its bottom edge on the baseline of the text, so the icon stands where a capital stands.
|
|
50
|
+
- `1lh` is the height of one line. The second rule makes the icon's box as tall as the first line of the label, and the drawing centers itself in that box. The icon stays beside the first line when the label wraps.
|
|
51
|
+
- A `gap` in `em` keeps the space in proportion.
|
|
52
|
+
|
|
53
|
+
Rules:
|
|
54
|
+
|
|
55
|
+
- Never size an icon in `px`. A 16px icon that matches 16px text is 5px to 7px shorter than the capitals of 32px text.
|
|
56
|
+
- Keep `flex: none`. Without it a long label squeezes the icon.
|
|
57
|
+
- The `svg` needs a `viewBox`. `inline-size: auto` then follows the drawing's ratio.
|
|
58
|
+
- Many icon sets leave padding inside the drawing, and `1cap` looks small on those. Raise the number and keep the unit, as in `1.2cap`.
|
|
59
|
+
- Do not use `align-items: center` on a label that can wrap. The icon then sits beside the middle line.
|
|
60
|
+
- With [Label centered on its letters with `text-box`](https://good-css.com/#label-centered-on-its-letters-with-text-box) the trimmed label is `1cap` tall, so `align-items: center` lines the icon up with it at both edges.
|
|
61
|
+
|
|
62
|
+
Support: `cap` in Chrome 118, Firefox 97, Safari 17.2. `lh` in Chrome 109, Firefox 120, Safari 16.4. Tested in Chrome 150 and real Safari 27 on 2026-10-01, where the icon came out within 0.01px of the capital height at 16px and at 32px.
|
|
63
|
+
|
|
64
|
+
- Borrowed from: Andy Bell, [How I build a button component](https://piccalil.li/blog/how-i-build-a-button-component/), 2024-09-18, for `cap` and `flex: none`, and [LH units are cool](https://piccalil.li/blog/lh-units-are-cool/), 2023-12-13 · Heydon Pickering and Andy Bell, [The Icon](https://every-layout.dev/layouts/icon/), Every Layout
|
|
65
|
+
- Docs: [MDN: length units](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length)
|
|
66
|
+
|
|
67
|
+
Source: [Good CSS](https://good-css.com/specimen/icon-sized-by-the-text-beside-it), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#icon-sized-by-the-text-beside-it).
|
|
68
|
+
|
|
69
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-image-box-that-holds-any-upload"
|
|
3
|
+
title: "Image box that holds any upload"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "text"
|
|
8
|
+
- "media"
|
|
9
|
+
- "image"
|
|
10
|
+
- "box"
|
|
11
|
+
- "holds"
|
|
12
|
+
- "any"
|
|
13
|
+
- "upload"
|
|
14
|
+
summary: "Use it on every image, video or embed whose file you do not control, such as thumbnails, cover photos and avatars. The box keeps its shape whatever the file's ratio, and it has that shape before the file loads."
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
18
|
+
# Image box that holds any upload
|
|
19
|
+
|
|
20
|
+
Use it on every image, video or embed whose file you do not control, such as thumbnails, cover photos and avatars. The box keeps its shape whatever the file's ratio, and it has that shape before the file loads.
|
|
21
|
+
|
|
22
|
+
```css
|
|
23
|
+
.thumb {
|
|
24
|
+
inline-size: 100%;
|
|
25
|
+
block-size: auto;
|
|
26
|
+
aspect-ratio: 16 / 9;
|
|
27
|
+
object-fit: cover;
|
|
28
|
+
background-color: var(--surface-muted, oklch(0.9 0 none));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.avatar {
|
|
32
|
+
flex: none;
|
|
33
|
+
inline-size: 3.5rem;
|
|
34
|
+
block-size: 3.5rem;
|
|
35
|
+
border-radius: 50%;
|
|
36
|
+
object-fit: cover;
|
|
37
|
+
outline: 1px solid light-dark(oklch(0 0 none / 0.1), oklch(1 0 none / 0.1));
|
|
38
|
+
outline-offset: -1px;
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Why it works:
|
|
43
|
+
|
|
44
|
+
- `aspect-ratio` works out the height from the width, so the space is there before the file loads.
|
|
45
|
+
- `object-fit: cover` scales the picture until it fills the box and crops the rest. The default stretches it.
|
|
46
|
+
- The background color shows until the image paints, and in its place when the load fails.
|
|
47
|
+
- An outline with a negative offset draws inside the image's edge. It takes no space and follows the `border-radius`, so a white avatar on a white card still has an edge.
|
|
48
|
+
- `flex: none` stops the text beside the avatar from squeezing it.
|
|
49
|
+
|
|
50
|
+
Rules:
|
|
51
|
+
|
|
52
|
+
- One axis must be `auto`. A `height` attribute or a fixed height wins over the ratio. The reset sets `height: auto` on `img`, `svg` and `video`, and `block-size: auto` here does the same for an `iframe`.
|
|
53
|
+
- `object-fit` does nothing until the box has both sizes or a ratio.
|
|
54
|
+
- `cover` crops. Use `contain` for logos, product shots and anything that must stay whole. Move the crop with `object-position`.
|
|
55
|
+
- Leave the background color off an image that has transparent areas. It shows through them.
|
|
56
|
+
- A fixed-size image in a flex row needs both sizes, as the avatar has. With only a width it stretches to the height of the row.
|
|
57
|
+
- In Safari a failed image ignores `aspect-ratio` and draws a square box. Where a failed load must not move the layout, put the ratio on a wrapper and give the image `inline-size: 100%` and `block-size: 100%`.
|
|
58
|
+
- `light-dark()` needs the `color-scheme` from [One set of color tokens for light and dark](https://good-css.com/#one-set-of-color-tokens-for-light-and-dark). Without it the outline stays black on a dark page.
|
|
59
|
+
|
|
60
|
+
Support: `aspect-ratio` in Chrome 88, Firefox 89, Safari 15. An outline that follows the radius in Chrome 94, Firefox 88, Safari 16.4. `light-dark()` in Chrome 123, Firefox 120, Safari 17.5. The square box of a failed image is from real Safari 27, tested on 2026-10-01. Chrome 150 keeps the ratio.
|
|
61
|
+
|
|
62
|
+
- Borrowed from: Ahmad Shadeed, [Defensive CSS](https://defensivecss.dev/), the tips [Image distortion](https://defensivecss.dev/tip/image-compressed/), [Image inner border](https://defensivecss.dev/tip/inner-shadow/) and [Default flexbox stretching](https://defensivecss.dev/tip/default-flexbox-stretching/). This version draws the inner edge with an outline where his adds an element · Heydon Pickering and Andy Bell, [The Frame](https://every-layout.dev/layouts/frame/), Every Layout, which wraps the media. On `img`, `video` and `iframe` the rule goes on the element.
|
|
63
|
+
- Docs: [MDN: aspect-ratio](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/aspect-ratio) · [MDN: object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/object-fit) · [MDN: outline-offset](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/outline-offset)
|
|
64
|
+
|
|
65
|
+
Source: [Good CSS](https://good-css.com/specimen/image-box-that-holds-any-upload), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#image-box-that-holds-any-upload).
|
|
66
|
+
|
|
67
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
topic: "good-css-indicator-that-slides-to-the-active-item"
|
|
3
|
+
title: "Indicator that slides to the active item"
|
|
4
|
+
tier: "reference"
|
|
5
|
+
tags:
|
|
6
|
+
- "good-css"
|
|
7
|
+
- "motion"
|
|
8
|
+
- "indicator"
|
|
9
|
+
- "slides"
|
|
10
|
+
- "active"
|
|
11
|
+
- "item"
|
|
12
|
+
summary: "Use it for the underline on a tab row or the bar beside a side nav. It replaces a script that measures the active item and writes a `transform` and a width."
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<!-- Generated by pnpm good-css:sync. Local notes belong in compatibility.md. -->
|
|
16
|
+
# Indicator that slides to the active item
|
|
17
|
+
|
|
18
|
+
Use it for the underline on a tab row or the bar beside a side nav. It replaces a script that measures the active item and writes a `transform` and a width.
|
|
19
|
+
|
|
20
|
+
```html
|
|
21
|
+
<nav class="tabs">
|
|
22
|
+
<ul>
|
|
23
|
+
<li><a href="/overview" aria-current="page">Overview</a></li>
|
|
24
|
+
<li><a href="/pricing">Pricing and billing</a></li>
|
|
25
|
+
<li><a href="/docs">Docs</a></li>
|
|
26
|
+
</ul>
|
|
27
|
+
</nav>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```css
|
|
31
|
+
.tabs { anchor-scope: --active; }
|
|
32
|
+
|
|
33
|
+
.tabs ul {
|
|
34
|
+
position: relative;
|
|
35
|
+
display: flex;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.tabs [aria-current="page"] { anchor-name: --active; }
|
|
39
|
+
|
|
40
|
+
.tabs ul::after {
|
|
41
|
+
content: "";
|
|
42
|
+
position: absolute;
|
|
43
|
+
position-anchor: --active;
|
|
44
|
+
inset-block-end: 0;
|
|
45
|
+
inset-inline-start: anchor(start);
|
|
46
|
+
inline-size: anchor-size(inline);
|
|
47
|
+
block-size: 2px;
|
|
48
|
+
background: currentColor;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
52
|
+
.tabs ul::after {
|
|
53
|
+
transition:
|
|
54
|
+
inset-inline-start 0.25s var(--ease-out, ease-out),
|
|
55
|
+
inline-size 0.25s var(--ease-out, ease-out);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Why it works:
|
|
61
|
+
|
|
62
|
+
- The anchor name sits on whichever item carries `aria-current`. Move the attribute and the anchor moves with it. The same attribute tells a screen reader which item is current, so there is no separate "active" class to keep in sync.
|
|
63
|
+
- `anchor()` and `anchor-size()` resolve to the item's edge and size. A longer label, a larger font or a wrapped line needs no new measurement.
|
|
64
|
+
- The bar is one element whose inset and size change, so a plain transition slides it.
|
|
65
|
+
|
|
66
|
+
Rules:
|
|
67
|
+
|
|
68
|
+
- Keep `anchor-scope` on the component root. Without it an anchor name is global, and two navs on one page both follow the last active item.
|
|
69
|
+
- The list needs `position: relative`, so the bar is positioned against the box that holds the items.
|
|
70
|
+
- For a vertical list swap the axes. Use `inset-block-start: anchor(top)` and `block-size: anchor-size(height)`, and transition those two.
|
|
71
|
+
- Give the active item a second cue such as weight or color. A browser without anchor positioning drops the `anchor()` declarations and the bar has no width.
|
|
72
|
+
|
|
73
|
+
Support: Chrome 131, Firefox 147, Safari 26. Firefox places the bar and does not slide it.
|
|
74
|
+
|
|
75
|
+
- Borrowed from: Stripe, which ships the vertical form for a side nav in its home page stylesheet. The research found the rules and no page that renders the component, so this is from the stylesheet alone.
|
|
76
|
+
- Docs: [MDN: anchor-name](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/anchor-name) · [MDN: anchor-scope](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/anchor-scope) · [MDN: anchor-size()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/anchor-size)
|
|
77
|
+
|
|
78
|
+
Source: [Good CSS](https://good-css.com/specimen/indicator-that-slides-to-the-active-item), [pinned source](https://github.com/vojtaholik/good-css/blob/6d16d2fd27f4892e2aea4b5c5c2b016f45be7eef/PRACTICES.md#indicator-that-slides-to-the-active-item).
|
|
79
|
+
|
|
80
|
+
Copyright (c) 2026 Vojta Holik. License: upstream/LICENSE in this plugin.
|