Forms & inputs
StretchSwitch
Thumb stretches on press, then snaps
detent-stretch-switch
Port status
- Catalog status
- implemented
- Origin
- upstream
- Phase
- 1
- Wave
- phase-1-pilot
- Group
- Forms & inputs
5.9 KB gzip for the class module · inlines the shared spring
Variants
Recorded states
pressedrecorded state; no setterdark themerecorded state; no setter
Usage
Import
import '/components/stretch-switch/define.js';
// or register it yourself
import { DetentStretchSwitch } from '/components/stretch-switch/stretch-switch.js';
Attributes
checkeddefault-checkeddescriptiondisabled
Properties
onChange
Slots
label
Events
detent-change— {}
Example
Runnable example: site/examples/stretch-switch.ts. The snippet below is generated from the catalogued attributes.
<detent-stretch-switch
checked
default-checked
description
disabled
></detent-stretch-switch>
Property-only values
const element = document.querySelector('detent-stretch-switch');
if (element !== null) {
// onChange: (checked: boolean) => void
element.onChange = () => {};
}
Source
The pinned upstream source for this row:
src/components/ui/StretchSwitch.tsxsha256 3e3c141a1742…
Native files
The native module and its artifacts:
src/components/stretch-switch/stretch-switch.tssrc/components/stretch-switch/styles.csssrc/components/stretch-switch/define.tsdocs/components/stretch-switch.mdsite/examples/stretch-switch.tstests/browser/stretch-switch.spec.tsevidence/components/stretch-switch.md
API
Boolean attributes follow HTML presence semantics: present means true, absent means false, and disabled="false" still means disabled.
Attributes
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
checked | checked | boolean | undefined | Presence semantics: present means true, absent means false. |
default-checked | defaultChecked | boolean | false | Presence semantics: present means true, absent means false. |
description | description | string | undefined | Kebab-case attribute; camelCase property. |
disabled | disabled | boolean | false | Presence semantics: present means true, absent means false. |
Properties
| Property | Type | Default | Notes |
|---|---|---|---|
onChange | (checked: boolean) => void | undefined | Property-only: assign from script; not accepted as an attribute. |
Events
| Event | Upstream prop | Payload type | Notes |
|---|---|---|---|
detent-change | onChange | (checked: boolean) => void | Composed, bubbles; fires only for a user action. |
Slots
| Slot | Notes |
|---|---|
label | Consumer content; rendered through a slot, never re-created. |
CSS customisation
- React `children`/`ReactNode` props become DOM slots; caller content stays light DOM.
- React callback props become typed composed CustomEvents; programmatic state assignment emits none.
- Framer Motion springs/AnimatePresence ported to CSS transitions, WAAPI or the shared rAF spring helper.
- React state/hooks replaced by element-owned state with pre-upgrade property preservation.
- Native form surface added by the Phase 1 plan: required, name (reflects to the attribute because the submitted entry name is read from the content attribute), value (default "on"), static formAssociated with setFormValue/setValidity, checkValidity()/reportValidity(), formResetCallback restoring default-checked, formStateRestoreCallback, formDisabledCallback. Not recorded upstream.
- The widget is a native <input type=checkbox role=switch> instead of <button role=switch>; aria-checked is implicit from the native checked state and is not duplicated (accessibility correction).
- One composed input (native) and one detent-change ({ detail: { checked } }) per user change; the component also dispatches one composed change because the native change event is not composed and would not cross the shadow boundary. Programmatic assignment emits none.
- Description wiring: description renders in part=description and is referenced by the internal control; text of elements named by the host's aria-describedby is mirrored into a hidden shadow node the internal control references, because a cross-tree IDREF cannot describe the focused shadow control and internals.ariaDescribedByElements describes only the host (support feature-tested on all three engines).
- External <label for> activation focuses and toggles the control; WebKit dispatches the label's synthetic click on the host without focusing the shadow descendant, so the component focuses it explicitly (F-1).
- default-checked is both the initial state and the reset target; checked reflects live state; reset restores default-checked once with no user events.
- The thumb snap keeps upstream's animate(x, springSnappy) physics (380/30/0.5) on the shared helper; a change mid-snap continues from the painted position with velocity reset to zero. The press stretch (scale 1.18/0.86) is a CSS :active rule. Both are removed under prefers-reduced-motion: reduce.
- Slotted interactive content inside the label slot keeps its own activation and does not toggle the switch (PC-11.6).
- Disabled state fades the track only, not the label or description text: fading the text to 30% measured about 1.9:1 and axe reports it as a contrast failure.
What it does
A form-associated switch that stretches to its label, reports validity and supports form reset and restore.
Upstream behaviour
- Thumb scaleX -> 1.18 / scaleY -> 0.86 while pointer is held for a tactile stretch
- Snappy spring (springSnappy) drives the snap to the new x position on release
- Press state is captured via pointerdown / pointerup, so it works for keyboard activations too
- Controlled / uncontrolled state, with onChange callback
- Light/dark theme aware via CSS variables — works in both palettes
Differences from upstream
- React `children`/`ReactNode` props become DOM slots; caller content stays light DOM.
- React callback props become typed composed CustomEvents; programmatic state assignment emits none.
- Framer Motion springs/AnimatePresence ported to CSS transitions, WAAPI or the shared rAF spring helper.
- React state/hooks replaced by element-owned state with pre-upgrade property preservation.
- Native form surface added by the Phase 1 plan: required, name (reflects to the attribute because the submitted entry name is read from the content attribute), value (default "on"), static formAssociated with setFormValue/setValidity, checkValidity()/reportValidity(), formResetCallback restoring default-checked, formStateRestoreCallback, formDisabledCallback. Not recorded upstream.
- The widget is a native <input type=checkbox role=switch> instead of <button role=switch>; aria-checked is implicit from the native checked state and is not duplicated (accessibility correction).
- One composed input (native) and one detent-change ({ detail: { checked } }) per user change; the component also dispatches one composed change because the native change event is not composed and would not cross the shadow boundary. Programmatic assignment emits none.
- Description wiring: description renders in part=description and is referenced by the internal control; text of elements named by the host's aria-describedby is mirrored into a hidden shadow node the internal control references, because a cross-tree IDREF cannot describe the focused shadow control and internals.ariaDescribedByElements describes only the host (support feature-tested on all three engines).
- External <label for> activation focuses and toggles the control; WebKit dispatches the label's synthetic click on the host without focusing the shadow descendant, so the component focuses it explicitly (F-1).
- default-checked is both the initial state and the reset target; checked reflects live state; reset restores default-checked once with no user events.
- The thumb snap keeps upstream's animate(x, springSnappy) physics (380/30/0.5) on the shared helper; a change mid-snap continues from the painted position with velocity reset to zero. The press stretch (scale 1.18/0.86) is a CSS :active rule. Both are removed under prefers-reduced-motion: reduce.
- Slotted interactive content inside the label slot keeps its own activation and does not toggle the switch (PC-11.6).
- Disabled state fades the track only, not the label or description text: fading the text to 30% measured about 1.9:1 and axe reports it as a contrast failure.