srcdev-nuxt-components 9.6.14 → 9.6.15

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.
Files changed (23) hide show
  1. package/.claude/skills/components/sliding-toggle.md +108 -0
  2. package/.claude/skills/components/tabbed-content.md +8 -3
  3. package/.claude/skills/composable-sliding-indicator.md +60 -0
  4. package/.claude/skills/index.md +2 -0
  5. package/.claude/skills/storybook-add-story.md +17 -0
  6. package/.claude/skills/testing-add-unit-test.md +25 -0
  7. package/.claude/skills/theming-component-token-pattern.md +18 -0
  8. package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
  9. package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
  10. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
  11. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
  12. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
  13. package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
  14. package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
  15. package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
  16. package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
  17. package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
  18. package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
  19. package/app/composables/useSlidingIndicator.ts +56 -0
  20. package/app/composables/useTabs.ts +4 -31
  21. package/app/types/components/index.ts +1 -0
  22. package/app/types/components/sliding-toggle.d.ts +5 -0
  23. package/package.json +1 -1
@@ -0,0 +1,108 @@
1
+ ---
2
+ name: SlidingToggle
3
+ description: SlidingToggle single-choice segmented radiogroup with text labels of any width, measured sliding indicator (stretch-then-catch-up) plus hover indicator, corner-shape token, CSS token API
4
+ type: reference
5
+ ---
6
+
7
+ # SlidingToggle
8
+
9
+ ## Overview
10
+
11
+ A single-choice segmented control for short text choices such as a billing period or a site tier.
12
+ The selected option's background slides between options of different widths, with the same
13
+ stretch-then-catch-up motion as `TabbedContent`'s indicator (both use `useSlidingIndicator`), and a
14
+ second indicator follows the pointer. It's a WAI-ARIA radio group of `<button role="radio">`s, not
15
+ a form control: there are no native inputs and nothing is submitted.
16
+
17
+ Lives in `app/components/02.molecules/sliding-toggle/`.
18
+
19
+ **Which one to use:**
20
+
21
+ - `SlidingToggle` — text labels of any width, page UI state (pricing toggles, view switches).
22
+ - `SegmentedControl` (`05.forms`) — icon-only, equal-width options, native radio inputs that submit
23
+ with a form.
24
+ - `TabbedContent` — the choice switches between panels of content.
25
+
26
+ ## Props
27
+
28
+ | Prop (template form) | Type | Default | Notes |
29
+ |---|---|---|---|
30
+ | `v-model` | `T` (`string \| number`) | (required) | The selected option's `value`. A value matching no option leaves nothing selected. |
31
+ | `options` | `SlidingToggleOption<T>[]` | (required) | `{ value, label, disabled? }`. Labels are plain text. |
32
+ | `aria-label` | `string` | `""` | Names the radiogroup. Set this or `aria-labelledby`. |
33
+ | `aria-labelledby` | `string` | `""` | Id of a visible heading naming the group. |
34
+ | `disabled` | `boolean` | `false` | Disables every option. |
35
+ | `track-hover` | `boolean` | `true` | Show the hover indicator. |
36
+ | `transition-duration` | `number` (ms) | `200` | Length of each edge's slide. |
37
+ | `style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root. |
38
+
39
+ `SlidingToggleOption` is exported from `~/types/components`.
40
+
41
+ ## Usage
42
+
43
+ ```vue
44
+ <script setup lang="ts">
45
+ const billing = ref<"monthly" | "yearly">("monthly");
46
+ const billingOptions = [
47
+ { value: "monthly", label: "Monthly" },
48
+ { value: "yearly", label: "Yearly (save 20%)" },
49
+ ] as const;
50
+ </script>
51
+
52
+ <template>
53
+ <SlidingToggle v-model="billing" :options="[...billingOptions]" aria-label="Billing period" />
54
+ </template>
55
+ ```
56
+
57
+ Equal widths across a full-width switch:
58
+
59
+ ```css
60
+ .billing-switch {
61
+ --sliding-toggle-option-flex: 1 1 0;
62
+ }
63
+
64
+ .billing-switch .sliding-toggle {
65
+ inline-size: 100%;
66
+ }
67
+ ```
68
+
69
+ ## Behaviour
70
+
71
+ - The root is `role="radiogroup"`, named by `aria-label` or `aria-labelledby`. Each option is a
72
+ `<button type="button" role="radio">` with `aria-checked`.
73
+ - Clicking an option sets `v-model`; changing `v-model` from outside moves the selection.
74
+ - Only the selected option is a tab stop. With no match, the first enabled option is.
75
+ - Arrow keys move focus and select, wrapping round and skipping disabled options. Right and Down go
76
+ to the next option, Left and Up to the previous; inside `dir="rtl"` Left and Right swap. Home and
77
+ End go to the first and last enabled option.
78
+ - `disabled` on the group or on one option blocks clicks and keys; disabled options are skipped and
79
+ never get the hover indicator.
80
+ - The selected indicator slides to the new option, its leading edge first and the trailing edge one
81
+ `transition-duration` later. The hover indicator follows the pointer and returns to the selected
82
+ option when it leaves; `track-hover="false"` turns it off. `--sliding-toggle-indicator-stretch: 0`
83
+ makes both indicators slide their edges together instead of stretching, mirroring TabbedContent's
84
+ `--tabs-indicator-stretch`.
85
+ - Under `prefers-reduced-motion` both indicators snap.
86
+ - Before it's mounted and measured (SSR, first paint) the indicators are hidden and the selected
87
+ option paints its own background and marker, so there's no flash of an unselected group.
88
+ - It re-measures when the track or any single option resizes (a late web font changes label widths
89
+ without resizing the track) and when options are added, removed or re-classed.
90
+ - Long, unbroken and many labels wrap and shrink inside the track instead of overflowing.
91
+
92
+ ## CSS custom properties
93
+
94
+ See `CONSUMER-STYLING.md` for the full `--sliding-toggle-*` token API: track surface, padding, gap,
95
+ radii, `--sliding-toggle-corner-shape`, indicator and hover-indicator surfaces, marker, option
96
+ padding/flex/typography/colours and the focus ring. Colours default from `--theme-*` tokens.
97
+
98
+ `--sliding-toggle-corner-shape` takes any `corner-shape` value (`squircle`, `superellipse(n)`,
99
+ `bevel`, `scoop`, `notch`). It's a progressive enhancement: Chromium 139+ only, and other browsers
100
+ keep the `border-radius` corners.
101
+
102
+ ## Notes
103
+
104
+ - Auto-imported in Nuxt.
105
+ - The motion comes from the `useSlidingIndicator` composable, shared with `TabbedContent` (via
106
+ `useTabs`). It writes `--_sliding-toggle-active-*` / `--_sliding-toggle-hover-*` on the root.
107
+ - Added 2026-10-19, generalised from a consumer app's pricing-page toggle (which used `aria-pressed`
108
+ buttons; this version uses radio semantics).
@@ -137,6 +137,10 @@ source order when the list changes.
137
137
 
138
138
  ## Indicator motion: edge insets (2026-10-08)
139
139
 
140
+ > **Changed 2026-10-19**: the edge maths moved into `useSlidingIndicator` (`composable-sliding-indicator.md`),
141
+ > shared with `SlidingToggle`. `useTabs.placeIndicator` now calls it and keeps the label-coverage
142
+ > tracking. Under `prefers-reduced-motion` the indicators now snap instead of sliding.
143
+
140
144
  `useTabs.placeIndicator(kind, animate)` writes each indicator's two edges as insets from the
141
145
  bar's start/end along the axis (`--_{kind}-start` / `--_{kind}-end`), and the CSS transitions
142
146
  `left`/`right` (or `top`/`bottom`) separately. Moving forward, the start edge gets a delay of one
@@ -145,9 +149,10 @@ and the trailing edge catches up: the same stretch effect as before, symmetric b
145
149
  settle timer. CSS retargets each edge from its current position, so a quick second move can't
146
150
  pull an edge backwards. Hover and active have separate duration/delay variables.
147
151
 
148
- `--tabs-hover-indicator-stretch` (default `1`) multiplies the hover pair's two edge delays in CSS,
149
- so `0` turns the hover stretch into a plain slide (both edges move together over the same
150
- duration). The active indicator and underline always stretch.
152
+ `--tabs-indicator-stretch` (default `1`) multiplies every indicator's two edge delays in CSS, so `0`
153
+ turns the stretch into a plain slide (both edges move together over the same duration) for the
154
+ hover, active and underline indicators alike. Renamed 2026-10-19 from `--tabs-hover-indicator-stretch`,
155
+ which covered the hover pair only.
151
156
 
152
157
  This replaced a `translate` + `scale` model (a bar-wide box translated to the tab's left and
153
158
  scaled to its width, plus a timeout to settle after a stretch, and one shared
@@ -0,0 +1,60 @@
1
+ # useSlidingIndicator
2
+
3
+ ## Overview
4
+
5
+ Positions one or more sliding indicators (a highlight or underline that moves between items) inside
6
+ a container, with a stretch-then-catch-up motion: the edge in the direction of travel moves first
7
+ and the trailing edge follows one duration later. Used by `useTabs` (TabbedContent) and
8
+ `SlidingToggle`. Lives in `app/composables/useSlidingIndicator.ts`, auto-imported.
9
+
10
+ ## API
11
+
12
+ ```ts
13
+ const { place, reset } = useSlidingIndicator(containerRef, { axis?: "x" | "y", duration });
14
+ ```
15
+
16
+ | Param | Type | Notes |
17
+ |---|---|---|
18
+ | `containerRef` | `Ref<HTMLElement \| null>` | The positioned element the indicators sit in. The CSS variables are written on it. |
19
+ | `axis` | `MaybeRefOrGetter<"x" \| "y">` | Default `"x"`. |
20
+ | `duration` | `MaybeRefOrGetter<number>` | Slide length per edge, in ms. Invalid or negative values count as `0`. |
21
+
22
+ `place(name, target, animate)` measures `target` (its `offsetLeft`/`offsetTop` and size against the
23
+ container's client size) and writes, on the container:
24
+
25
+ - `--_{name}-start` / `--_{name}-end` — insets from the container's start and end edges, in px;
26
+ - `--_{name}-duration`, `--_{name}-start-delay`, `--_{name}-end-delay`.
27
+
28
+ It returns the transition length in ms, or `null` when the container or target is missing (nothing
29
+ written). The first placement of a name, `animate: false`, and `prefers-reduced-motion` all snap
30
+ (duration `0ms`).
31
+
32
+ `reset(name)` forgets the last position, so the next placement snaps instead of sliding in from
33
+ wherever the indicator last was (use it when an indicator is hidden).
34
+
35
+ ## CSS side
36
+
37
+ ```css
38
+ .my-indicator {
39
+ position: absolute;
40
+ left: var(--_my-thing-active-start, 0px);
41
+ right: var(--_my-thing-active-end, 100%);
42
+ transition:
43
+ left var(--_my-thing-active-duration, 0ms) ease var(--_my-thing-active-start-delay, 0ms),
44
+ right var(--_my-thing-active-duration, 0ms) ease var(--_my-thing-active-end-delay, 0ms);
45
+ }
46
+ ```
47
+
48
+ Use physical `left`/`right` (and `top`/`bottom` for `"y"`): the measurements are physical, so this
49
+ also works in right-to-left layouts.
50
+
51
+ ## Rules
52
+
53
+ - Prefix the `name` with the component (`"sliding-toggle-active"`), so a component nested inside
54
+ another that uses the composable can't inherit its values (pitfall #26). `useTabs` keeps the
55
+ historic `"active"`/`"hovered"` names that TabbedContent's CSS reads.
56
+ - Re-place with `animate: false` when the container or any measured item resizes, and when items
57
+ are added or removed (`measured-layout-observers.md`). Don't observe `style` changes: the
58
+ composable writes inline styles on the container.
59
+ - Keep the indicators hidden until the first placement (SlidingToggle's `data-ready`), and give the
60
+ selected item its own fallback background for SSR and first paint.
@@ -75,6 +75,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
75
75
  ├── composable-cookie-consent.md — useCookieConsent: unset/granted/denied state, cookie persistence, wraps @nuxt/scripts' useScriptTriggerConsent
76
76
  ├── composable-analytics.md — useAnalytics: provider-agnostic trackEvent/page-view tracking (google-analytics only implemented), consent-gated, single call site for setup + firing events
77
77
  ├── composable-anchored-popover.md — useAnchoredPopover: popover + anchor positioning with JS fallbacks for Safari 16 (no Popover API) and 17–18 (no anchor positioning); used by ActionMenu/SelectMenu/DeepExpandingMenu/DisplayTooltip/PopOver
78
+ ├── composable-sliding-indicator.md — useSlidingIndicator: stretch-then-catch-up sliding indicator edges written as --_{name}-* vars, reduced-motion snap; used by useTabs/TabbedContent and SlidingToggle
78
79
  └── components/
79
80
  ├── alert-content.md — AlertContent + AlertContentInner: themed alert panel (icon/title/content/actions/dismiss) under DisplayToast, DisplayPrompt and AlertMaskedContent; showIcon, #actions row, --alert-content-* tokens, app.config icon map
80
81
  ├── alert-mask.md — AlertMask (renamed from AlertMaskCore): SVG border/fill sized to slotted content via ResizeObserver; config-prop geometry, --alert-mask-* colour tokens
@@ -164,6 +165,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
164
165
  ├── input-radio.md — MultipleRadiobuttons: fieldset radiogroup (data-driven, button or labelled), shared-name grouping bug fixed, dead label/placeholder/equalCols props removed, data-options-layout hook, spacing token API, useId button ids, wraps hostile content
165
166
  ├── toggle-switch.md — ToggleSwitch (bare control), ToggleSwitchField (labelled, layout="stacked"|"inline", replaces ToggleSwitchWithLabelInline): renamed from Core/WithLabel, role="switch", prefixed inner classes + data-checked, single-toggle click handling, reduced motion, round prop + border-radius token, track width from geometry, CSS token API, stress stories
166
167
  ├── segmented-control.md — SegmentedControl (renamed from TripleToggleSwitch): icon radio pill for any number of options with sliding marker, radiogroup + ariaLabel, system/light/dark marker-gradient tokens, reduced-motion, CSS token API
168
+ ├── sliding-toggle.md — SlidingToggle: single-choice segmented radiogroup with text labels of any width, measured sliding + hover indicators (useSlidingIndicator), roving tabindex/arrow keys, corner-shape token, CSS token API
167
169
  ├── entry-animation.md — EntryAnimation: scroll-driven entry animation wrapper (slide-in/zoom-reveal/exit-blur utility classes), skipAnimation for above-the-fold loop items, reduced-motion handled at the CSS layer
168
170
  ├── column-flow-grid.md — ColumnFlowGrid (renamed from MasonryGrid): CSS multi-column text-flow layout, named dynamic slots (no count/data prop), itemMinWidth/gap/unit sizing, CSS token API; not a true masonry, see "which one do I want?"
169
171
  ├── masonry-grid.md — MasonryGrid: real measured-height masonry (greedy shortest-column packing, animated resize) absorbed from the now-retired MasonryGridOrdered; named dynamic slots, fixedWidth/justify, CSS token API
@@ -172,6 +172,23 @@ const Template: StoryFn<StoryArgs> = (args) => ({
172
172
 
173
173
  Don't name the inline component the same as an exported story (`TS2451: Cannot redeclare`).
174
174
 
175
+ ### Generic components (`<script setup generic="T">`)
176
+
177
+ A generic component's type doesn't fit `Meta`'s `component` field (`ConcreteComponent<Args>`), so
178
+ `npx nuxi typecheck` fails on `component: MyGeneric`. Cast it, with a one-line comment; the
179
+ autodocs props table still generates:
180
+
181
+ ```ts
182
+ import type { ConcreteComponent } from "vue";
183
+
184
+ const meta: Meta<StoryArgs> = {
185
+ // Generic components don't fit Meta's ConcreteComponent<Args>; the props table still generates.
186
+ component: SlidingToggle as unknown as ConcreteComponent<StoryArgs>,
187
+ };
188
+ ```
189
+
190
+ `SlidingToggle.stories.ts` is the reference.
191
+
175
192
  ## Title format
176
193
 
177
194
  `"Category/Subcategory/ComponentName"` — this becomes the Storybook sidebar path and the
@@ -396,6 +396,31 @@ afterEach(() => {
396
396
  The `as unknown as Record<string, unknown>` double-cast is required because TypeScript's
397
397
  `HTMLElement` type has no index signature — cast through `unknown` first.
398
398
 
399
+ ## A mount helper that keeps `v-model` in sync
400
+
401
+ To test a `v-model` component through clicks and keys, feed `update:modelValue` back into the
402
+ props. The handler needs the wrapper it's creating, so hold it in a `const` object: a self-referencing
403
+ `const wrapper` is an implicit `any` under strict mode, and `let wrapper` assigned once fails
404
+ ESLint's `prefer-const`.
405
+
406
+ ```ts
407
+ const mountToggle = async (props: Record<string, unknown> = {}) => {
408
+ const holder: { current?: Awaited<ReturnType<typeof mountSuspended>> } = {};
409
+ const onUpdate = (value: string | number) => {
410
+ void holder.current?.setProps({ modelValue: value });
411
+ };
412
+ const wrapper = await mountSuspended(SlidingToggle, {
413
+ props: { modelValue: "monthly", "onUpdate:modelValue": onUpdate, ...props },
414
+ });
415
+ holder.current = wrapper;
416
+ mounted.push(wrapper);
417
+ return wrapper;
418
+ };
419
+ ```
420
+
421
+ Type the handler's `value` as the model's full type (`string | number` for a generic model), not
422
+ the narrower one a test happens to use.
423
+
399
424
  ## Describe section conventions
400
425
 
401
426
  Use these section names consistently so tests are easy to scan:
@@ -129,6 +129,24 @@ Prefer this over a prop: line count is presentation, and CSS handles it without
129
129
  layout around it.
130
130
  - Document it in `CONSUMER-STYLING.md` with its default and the `1`/`none` options.
131
131
 
132
+ ## Corner shape tokens
133
+
134
+ `corner-shape` (CSS Borders 4: `squircle`, `superellipse(n)`, `bevel`, `scoop`, `notch`, default
135
+ `round`) reshapes the curve that `border-radius` draws. Give a component that wants it one public
136
+ token, `--<component>-corner-shape`, defaulting to `round`, and read it on every element that has a
137
+ radius so they stay in step:
138
+
139
+ ```css
140
+ .sliding-toggle,
141
+ .sliding-toggle-option {
142
+ corner-shape: var(--sliding-toggle-corner-shape, round);
143
+ }
144
+ ```
145
+
146
+ It's a progressive enhancement (Chromium 139+ when added on 2026-10-19): other browsers drop the
147
+ declaration and keep the `border-radius` corners, so never rely on it for layout or meaning. Label
148
+ it as Chromium-only in `CONSUMER-STYLING.md`. `SlidingToggle` is the first user.
149
+
132
150
  ## Text over an image: scrim tokens
133
151
 
134
152
  Any component that puts text over a photo (a header in a picture, a card title on its image) gets
@@ -0,0 +1,37 @@
1
+ {
2
+ "SRCDEV SlidingToggle": {
3
+ "description": "SlidingToggle — single-choice segmented radiogroup with a sliding indicator",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<SlidingToggle",
7
+ " v-model=\"${1:selected}\"",
8
+ " :options=\"[",
9
+ " { value: '${2:monthly}', label: '${3:Monthly}' },",
10
+ " { value: '${4:yearly}', label: '${5:Yearly}' },",
11
+ " ]\"",
12
+ " aria-label=\"${6:Billing period}\"",
13
+ "/>"
14
+ ]
15
+ },
16
+ "SRCDEV SlidingToggle Options Variable": {
17
+ "description": "SlidingToggle bound to an options array, without hover tracking",
18
+ "scope": "vue,html",
19
+ "body": [
20
+ "<SlidingToggle v-model=\"${1:selected}\" :options=\"${2:options}\" aria-label=\"${3:Label}\"${4: :track-hover=\"false\"} />"
21
+ ]
22
+ },
23
+ "SRCDEV SlidingToggle CSS Override": {
24
+ "description": "Unlayered CSS override scaffold for SlidingToggle tokens",
25
+ "scope": "css,vue",
26
+ "body": [
27
+ ".${1:my-section} {",
28
+ " --sliding-toggle-surface: ${2:var(--theme-surface-subtle)};",
29
+ " --sliding-toggle-indicator-surface: ${3:var(--theme-on-surface)};",
30
+ " --sliding-toggle-marker-colour: ${4:var(--theme-accent)};",
31
+ " --sliding-toggle-option-flex: ${5|0 1 auto,1 1 0|};",
32
+ " --sliding-toggle-corner-shape: ${6|round,squircle,superellipse(3),bevel,scoop,notch|};",
33
+ " --sliding-toggle-indicator-stretch: ${7|1,0|};$0",
34
+ "}"
35
+ ]
36
+ }
37
+ }
@@ -58,7 +58,7 @@
58
58
  " --tabs-underline-indicator-height: ;",
59
59
  " --tabs-hover-underline-indicator-colour: ;",
60
60
  " --tabs-hover-underline-indicator-height: ;",
61
- " --tabs-hover-indicator-stretch: ;",
61
+ " --tabs-indicator-stretch: ;",
62
62
  " --tabs-list-item-opacity: ;",
63
63
  " --tabs-list-item-colour: ;",
64
64
  " --tabs-list-item-colour-selected: ;",
@@ -58,7 +58,7 @@ box.
58
58
  | `--tabs-underline-indicator-height` | `0.4rem` | Thickness of the underline (`axis="x"`), also its width on `axis="y"` |
59
59
  | `--tabs-hover-underline-indicator-colour` | `--tabs-underline-indicator-colour` | Colour of the thin underline/sideline that follows the hovered tab (`trackHover` and `trackIndicator`) |
60
60
  | `--tabs-hover-underline-indicator-height` | `0.1rem` | Its thickness; `0` hides it. Sits under the active underline where they overlap |
61
- | `--tabs-hover-indicator-stretch` | `1` | Stretch of the hover highlight and hover underline as they move: `1` stretches (leading edge first, trailing edge catches up), `0` slides both edges together. Values in between shorten the stretch |
61
+ | `--tabs-indicator-stretch` | `1` | Stretch of every indicator (hover, active and both underlines) as it moves: `1` stretches (leading edge first, trailing edge catches up), `0` slides both edges together. Values in between shorten the stretch |
62
62
 
63
63
  ### More menu (`overflowMode="menu"`, `axis="x"`)
64
64
 
@@ -100,7 +100,7 @@ insets from the bar's start and end along the axis), `--_{kind}-duration`,
100
100
  `--_{kind}-start-delay` / `--_{kind}-end-delay`; each indicator reads them through its own
101
101
  `--_start` / `--_end` / `--_duration` / `--_start-delay` / `--_end-delay`. Not public API; use the
102
102
  `transitionDuration` prop for indicator speed (one edge moves for that long, then the other), and
103
- `--tabs-hover-indicator-stretch` to turn the hover stretch off. The More menu also uses
103
+ `--tabs-indicator-stretch` to turn the stretch off. The More menu also uses
104
104
  `--_anchor-name` (a per-instance anchor name) and the `--_anchor-*` position values
105
105
  `useAnchoredPopover` writes for browsers without CSS anchor positioning.
106
106
 
@@ -169,6 +169,19 @@ before.
169
169
 
170
170
  ---
171
171
 
172
+ ## Indicator motion and reduced motion
173
+
174
+ The indicators slide with `useSlidingIndicator` (shared with `SlidingToggle`): the leading edge
175
+ moves first and the trailing edge catches up. Under `prefers-reduced-motion` they snap.
176
+
177
+ > **Changed 2026-10-19**: the indicators used to slide even under `prefers-reduced-motion`.
178
+ >
179
+ > **Changed 2026-10-19**: `--tabs-hover-indicator-stretch` (hover only) is now
180
+ > `--tabs-indicator-stretch` and covers the active indicator and underline too. Rename it in your
181
+ > CSS; `0` now turns the stretch off for every indicator.
182
+
183
+ ---
184
+
172
185
  ## Indicator visibility is prop-driven
173
186
 
174
187
  Whether each indicator renders is set by `trackHover` / `trackActive` / `trackIndicator`, not
@@ -354,8 +354,8 @@ watch(
354
354
  --_start: var(--_hovered-start, 0px);
355
355
  --_end: var(--_hovered-end, 100%);
356
356
  --_duration: var(--_hovered-duration, 0ms);
357
- --_start-delay: calc(var(--_hovered-start-delay, 0ms) * var(--tabs-hover-indicator-stretch, 1));
358
- --_end-delay: calc(var(--_hovered-end-delay, 0ms) * var(--tabs-hover-indicator-stretch, 1));
357
+ --_start-delay: calc(var(--_hovered-start-delay, 0ms) * var(--tabs-indicator-stretch, 1));
358
+ --_end-delay: calc(var(--_hovered-end-delay, 0ms) * var(--tabs-indicator-stretch, 1));
359
359
  }
360
360
 
361
361
  .tabbed-content-indicator-active,
@@ -363,8 +363,8 @@ watch(
363
363
  --_start: var(--_active-start, 0px);
364
364
  --_end: var(--_active-end, 100%);
365
365
  --_duration: var(--_active-duration, 0ms);
366
- --_start-delay: var(--_active-start-delay, 0ms);
367
- --_end-delay: var(--_active-end-delay, 0ms);
366
+ --_start-delay: calc(var(--_active-start-delay, 0ms) * var(--tabs-indicator-stretch, 1));
367
+ --_end-delay: calc(var(--_active-end-delay, 0ms) * var(--tabs-indicator-stretch, 1));
368
368
  }
369
369
 
370
370
  .tabbed-content-indicator-hover,
@@ -219,14 +219,14 @@ export const IndicatorColours: Story = {
219
219
  }),
220
220
  };
221
221
 
222
- export const HoverWithoutStretch: Story = {
223
- name: "Hover Without Stretch",
222
+ export const WithoutStretch: Story = {
223
+ name: "Without Stretch",
224
224
  args: { itemCount: 5 },
225
225
  parameters: {
226
226
  docs: {
227
227
  description: {
228
228
  story:
229
- "--tabs-hover-indicator-stretch: 0 makes the hover highlight slide between tabs instead of stretching across and catching up. Clicking a tab still stretches the active indicator.",
229
+ "--tabs-indicator-stretch: 0 makes every indicator (hover, active and the underlines) slide between tabs instead of stretching across and catching up.",
230
230
  },
231
231
  },
232
232
  },
@@ -236,7 +236,7 @@ export const HoverWithoutStretch: Story = {
236
236
  return { args };
237
237
  },
238
238
  template: `
239
- <div style="--tabs-hover-indicator-stretch: 0">
239
+ <div style="--tabs-indicator-stretch: 0">
240
240
  <TabbedContent v-bind="args">
241
241
  ${tabSlots}
242
242
  </TabbedContent>
@@ -0,0 +1,109 @@
1
+ # SlidingToggle — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ ### Track
6
+
7
+ | Token | Default | Controls |
8
+ |---|---|---|
9
+ | `--sliding-toggle-surface` | `var(--theme-surface-subtle)` | Track background |
10
+ | `--sliding-toggle-padding` | `0.4rem` | Space between the track edge and the options (the indicators sit inside it too) |
11
+ | `--sliding-toggle-gap` | `0.4rem` | Space between options |
12
+ | `--sliding-toggle-border-radius` | `0.8rem` | Track corner radius |
13
+ | `--sliding-toggle-corner-shape` | `round` | `corner-shape` of the track, both indicators and the options (`squircle`, `superellipse(n)`, `bevel`, `scoop`, `notch`). Chromium 139+ only; other browsers ignore it and keep `border-radius` |
14
+
15
+ ### Indicators
16
+
17
+ | Token | Default | Controls |
18
+ |---|---|---|
19
+ | `--sliding-toggle-indicator-surface` | `var(--theme-on-surface)` | Background of the selected option's sliding indicator |
20
+ | `--sliding-toggle-indicator-shadow` | `0 0.1rem 0.3rem rgb(0 0 0 / 0.1)` | Shadow on the selected indicator |
21
+ | `--sliding-toggle-hover-indicator-surface` | `color-mix(in oklch, var(--theme-surface-subtle), var(--theme-text) 6%)` | Background of the hover indicator, one step darker than the track |
22
+ | `--sliding-toggle-indicator-stretch` | `1` | Multiplies both indicators' edge delays: `1` stretches then catches up, `0` slides both edges together, values between shorten the stretch |
23
+ | `--sliding-toggle-marker-height` | `0.2rem` | Height of the accent line along the selected indicator's bottom edge (`0` hides it) |
24
+ | `--sliding-toggle-marker-colour` | `var(--theme-accent)` | Colour of that line |
25
+
26
+ ### Options
27
+
28
+ | Token | Default | Controls |
29
+ |---|---|---|
30
+ | `--sliding-toggle-option-padding` | `0.8rem 1.6rem` | Option padding |
31
+ | `--sliding-toggle-option-border-radius` | `0.6rem` | Corner radius of the options and both indicators |
32
+ | `--sliding-toggle-option-flex` | `0 1 auto` | Option `flex`. `1 1 0` gives equal widths (stretch the root too) |
33
+ | `--sliding-toggle-option-font-size` | `inherit` | Label font size |
34
+ | `--sliding-toggle-option-font-weight` | `500` | Label font weight |
35
+ | `--sliding-toggle-option-white-space` | `normal` | Label `white-space`; `nowrap` keeps each label on one line |
36
+ | `--sliding-toggle-option-colour` | `color-mix(in oklch, var(--theme-text) 70%, transparent)` | Label colour |
37
+ | `--sliding-toggle-option-colour-active` | `var(--theme-text)` | Selected option's label colour |
38
+ | `--sliding-toggle-transition-duration` | `200ms` | Label colour fade. The indicator slide is the `transitionDuration` prop |
39
+ | `--sliding-toggle-focus-ring-width` | `0.2rem` | Focus ring width |
40
+ | `--sliding-toggle-focus-ring-colour` | `var(--theme-border-focus)` | Focus ring colour |
41
+
42
+ Private (not public API): `--_sliding-toggle-active-*` and `--_sliding-toggle-hover-*` (`-start`,
43
+ `-end`, `-duration`, `-start-delay`, `-end-delay`), written on the root by `useSlidingIndicator`
44
+ from the measured option positions.
45
+
46
+ ---
47
+
48
+ ## State hooks
49
+
50
+ | Hook | Where | Meaning |
51
+ |---|---|---|
52
+ | `[data-ready]` | root | Mounted and measured. Before this (SSR, first paint) the indicators are hidden and the selected option paints its own background and marker |
53
+ | `[data-has-selection]` | root | `v-model` matches an option |
54
+ | `[data-hovering]` | root | The pointer is over an enabled option (with `trackHover`) |
55
+ | `[aria-disabled="true"]` | root | The `disabled` prop |
56
+ | `[aria-checked="true"]` | `.sliding-toggle-option` | The selected option |
57
+ | `:disabled` | `.sliding-toggle-option` | A disabled option, or the whole group |
58
+
59
+ Inner classes: `.sliding-toggle-option` (each `<button role="radio">`), `.sliding-toggle-indicator`
60
+ with `.sliding-toggle-indicator-active` and `.sliding-toggle-indicator-hover`. The marker is the
61
+ active indicator's `::after`.
62
+
63
+ ---
64
+
65
+ ## Motion
66
+
67
+ The indicator edges slide with `useSlidingIndicator`: on a move the edge in the direction of travel
68
+ goes first and the other follows one `transitionDuration` later. `--sliding-toggle-indicator-stretch: 0`
69
+ turns the stretch into a plain slide for both indicators (same as TabbedContent's
70
+ `--tabs-indicator-stretch`). Under `prefers-reduced-motion` both edges snap and the label colour
71
+ doesn't fade.
72
+
73
+ ---
74
+
75
+ ## Local overrides
76
+
77
+ Set the tokens above on an element you own (a page or section class, or a class added with
78
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
79
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
80
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
81
+ `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
82
+
83
+ ### Equal-width options
84
+
85
+ ```css
86
+ .billing-switch {
87
+ --sliding-toggle-option-flex: 1 1 0;
88
+ --sliding-toggle-corner-shape: squircle;
89
+ }
90
+
91
+ .billing-switch .sliding-toggle {
92
+ inline-size: 100%;
93
+ }
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Class passthrough
99
+
100
+ `style-class-passthrough` adds classes to the root `.sliding-toggle` (the radiogroup). Reactive
101
+ after mount.
102
+
103
+ ---
104
+
105
+ ## Notes
106
+
107
+ - The root is `inline-size: fit-content` capped at `max-inline-size: 100%`; labels shrink and wrap
108
+ (`overflow-wrap: anywhere`) rather than overflow, and the indicators re-measure as they do.
109
+ - No line-clamp token: labels name the choice and must stay readable.