Detent web components
By Shane Logsdon

Forms & inputs

StretchSwitch

Implemented

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
Notifications

5.9 KB gzip for the class module · inlines the shared spring

Variants

Digest Locked

Recorded states

Locked
disabled
Digest
checked
  • pressed recorded state; no setter
  • dark theme recorded 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

  • checked
  • default-checked
  • description
  • disabled

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.tsx sha256 3e3c141a1742…

Native files

The native module and its artifacts:

  • src/components/stretch-switch/stretch-switch.ts
  • src/components/stretch-switch/styles.css
  • src/components/stretch-switch/define.ts
  • docs/components/stretch-switch.md
  • site/examples/stretch-switch.ts
  • tests/browser/stretch-switch.spec.ts
  • evidence/components/stretch-switch.md

API

Boolean attributes follow HTML presence semantics: present means true, absent means false, and disabled="false" still means disabled.

Attributes

Attributes
AttributePropertyTypeDefaultNotes
checkedcheckedbooleanundefinedPresence semantics: present means true, absent means false.
default-checkeddefaultCheckedbooleanfalsePresence semantics: present means true, absent means false.
descriptiondescriptionstringundefinedKebab-case attribute; camelCase property.
disableddisabledbooleanfalsePresence semantics: present means true, absent means false.

Properties

Properties
PropertyTypeDefaultNotes
onChange(checked: boolean) => voidundefinedProperty-only: assign from script; not accepted as an attribute.

Events

Events
EventUpstream propPayload typeNotes
detent-changeonChange(checked: boolean) => voidComposed, bubbles; fires only for a user action.

Slots

Slots
SlotNotes
labelConsumer 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.
Previous: Story Cards Next: Text Scramble Decoder

Docs

Components

Project