<!-- GENERATED by scripts/build-llms.mjs from llms/forms.md — do not edit this file. -->

# `lr-switch`

- **Import** `import '@aceshooting/lyra-ui/components/lr-switch.js';` (stable tag alias; registers the tag)
- **Class** `LyraSwitch`, also available unregistered from `@aceshooting/lyra-ui/components/forms/switch/switch.class.js`
- **Family** `components/forms/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 12 parts, 17 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-switch`

A boolean toggle-switch form control. `role="switch"` with `aria-checked` read as an on/off state
rather than checked/unchecked, and no indeterminate state. Structurally the same idea as
`<lr-checkbox>` (form-associated via `ElementInternals`; click and Space toggle). Enter is not a
special switch key. Logical ArrowLeft/ArrowRight set the switch off/on, mirrored under RTL.
Ships an opt-in `hint`/`errorText` form-control chrome (props + matching named slots + `hint`/`error`
CSS parts), mirroring `<lr-select>`'s pattern for those two pieces — left unset, neither renders.
Deliberately no separate top-of-field `label` prop/slot/part: the default slot already is this
control's visible, clickable label (same as `<lr-checkbox>`).

Host `aria-describedby` references resolve onto the internal `role="switch"` before its local
error/hint guidance. The relationship tracks missing IDs, target replacement/removal/reinsertion,
reconnect, and document adoption. Removing `hint`, `help-text`, or `error-text` safely omits that
content while preserving native `null` property readback; explicit empty and later text work
normally.

**Properties:**

- `checked: boolean = false` — the live, non-reflecting state
- `defaultChecked: boolean = false` (canonical attribute `checked`, reflected) — the current reset
  default; changing it updates `checked` only while the live state is pristine
- `disabled: boolean = false` (reflected)
- `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
- `name: string = ''`
- `value: string = 'on'` — only contributed to form submission while `checked`
- `customError: string | null` (attribute `custom-error`) — reflected consumer validation message
- `hint: string = ''` — hint text below the switch. Unset: no hint chrome renders.
- `helpText: string = ''` (attribute `help-text`) — Shoelace alias for `hint`; `hint` wins when both
  are supplied
- `withHint: boolean = false` (attribute `with-hint`) — WA SSR presence hint for slotted supporting
  text that cannot be inspected until hydration
- `errorText: string = ''` (attribute `error-text`) — error text below the switch (overridden by
  slotted `error` content). Unset: no error chrome renders.
- `size: LyraSize = 'm'` (reflected) — control size on the shared ladder, accepting both
  `2xs`/`xs`/`s`/`m`/`l`/`xl` and `small`/`medium`/`large`. It scales the track and thumb off the
  same values `lr-input`/`lr-select`/`lr-button` read, so controls of one `size` line up in a row.
  The slotted label keeps the library's standard control-label type size at every tier — restyle it
  through `::part(label)` if you want it to track the control.

**Events:** a user state change (click, Space, a logical ArrowLeft/ArrowRight change, or the
programmatic `click()` activation path) emits
`input`, then `lr-input`, then `change`, then `lr-change` (both aliases carry
`detail: { checked: boolean }`) — in that order, matching
the native checkbox/radio contract. The two native-style events are **new in 8.0.0**: a boolean
control that emitted only the `lr-`-prefixed alias was invisible to every form library, validation
helper, and `<form>`-level `change` listener that binds the native names, which is the ordinary way
a consumer observes a control they didn't write. `input` is an `InputEvent`; `change` is an
`Event`. Both bubble and compose, and neither carries a detail — read `event.target.checked`.
None of the four fires for a programmatic `.checked`
assignment, `form.reset()`, or session-state restoration. The internal control's native
`focus` and `blur` are re-dispatched as bubbling, composed host events. `lr-invalid` (no detail) fires when a validity
check finds the switch invalid.
**Refusing a toggle.** `lr-switch-toggle-request` is cancelable and fires *before* `checked` moves,
on every user path (click, Space, a logical ArrowLeft/ArrowRight change, and the host `click()`
activation it forwards). `detail: { checked: boolean }` is the state the switch **would** take;
`checked` itself still holds the old value while the event dispatches. `preventDefault()` keeps the
current state, so the switch never slides at all rather than sliding and snapping back, and none of
`input`/`lr-input`/`change`/`lr-change` follow. A listener can instead answer by assigning `checked`
itself during the dispatch, which suppresses the built-in write the same way. It does not fire for a
programmatic `.checked` assignment, `form.reset()`, session-state restoration, while the control is
disabled, or for an arrow key naming the state the switch already holds. A refused toggle also
leaves the control pristine: it does not count as the interaction that reveals
`:state(user-valid)`/`:state(user-invalid)`, so refusing a required switch's first toggle cannot
flash a validation error for a change that never happened. Blurring the control, or a
`reportValidity()` call, still marks it interacted, which is the native `:user-invalid` timing.

**Methods:** `focus(options?)`, `blur()`, and `click()` forward to the internal switch control;
focus/click and stale keyboard/pointer activation are synchronous no-ops as soon as direct or
fieldset disablement starts, even before the next render;
`getForm()` returns its owning form (including an external owner selected by `form`).
`setCustomValidity(message)` sets or clears a consumer-supplied error ("notifications are disabled
for your plan"): a non-empty message raises `customError` and blocks submission, `''` restores the
control's own computed validity so a required-and-unchecked switch goes back to `valueMissing`. It
survives every toggle and a form reset; `setCustomValidity('')` or `resetValidity()` clears it.

`checked`/`defaultChecked` use the same native dirty-state contract as `lr-checkbox`: live writes
never reflect, default/attribute changes cannot overwrite a dirty live state, and `form.reset()`
restores the current default before making the control pristine again.

**Slots:**

- default — rich label content rendered beside the semantic switch owner. Clicking plain label
  content toggles like an associated label; activating a nested link or button does not toggle. If
  left empty, set `aria-label` on the host so the control still has an accessible name. Flattened
  forwarding-slot assignment and later mutations
  keep the visual wrapper synchronized; element-only and visible `aria-hidden` decorations retain
  it. A host `aria-label` wins by presence, including an explicitly empty value.
- `hint` — custom hint content.
- `help-text` — Shoelace alias for the same hint surface.
- `error` — custom error content.

**CSS parts:** `form-control` (the outer wrapper around the switch, error and hint), `row` (the row
wrapping the switch owner and the label as siblings — the node to size or align when laying out a
column of switches, since `base`/`switch` below is only the track box and its inline size tracks the
track rather than the row), `base` /
`switch` / `wrapper` (the semantic interactive `role="switch"` owner; the rich label is its
sibling), `track` / `control` (the
pill-shaped background), `thumb` (the circular knob), `label` (wrapper around the default slot),
`hint` / `form-control-help-text` (the hint message), and `error`.

**Themeable custom properties:** `--lr-switch-track-block-size` (default
`calc(var(--lr-form-control-height) * 0.5)`), `--lr-switch-track-inline-size` (default
`calc(var(--lr-switch-track-block-size) * 1.8)`, the 1.8:1 aspect ratio the control has always had)
and `--lr-switch-thumb-offset` (default `var(--lr-size-2px)`) — component-local geometry knobs set
on `:host`, since a fully-rounded pill/thumb needs a radius well past the shared `--lr-radius`
default. Both track dimensions ride the shared `size` ladder, so at the default `m` tier they
resolve to exactly the `1.25rem` × `2.25rem` the switch shipped with before it had a `size` at all.
WA/Shoelace's `--width`, `--height`, and `--thumb-size` aliases feed those same rendered dimensions.
`--lr-switch-gap` (default `var(--lr-space-s)`) independently controls the track-to-label gap.

`--lr-switch-track-fill` (default `--lr-color-border`) is `[part='track']`'s unchecked resting
fill. `--lr-switch-checked-track-fill` (default `--lr-color-brand`) independently retints its
checked fill, and `--lr-switch-track-hover-fill` / `--lr-switch-track-active-fill` independently
retint the pointer states (their defaults remain mixes from the current resting fill).
`--lr-switch-track-border` is `[part='track']`'s border; **undeclared by default**, so no border
renders at all, matching today's chrome — set it to add a rim (e.g. for a themed high-contrast
look) without affecting any other switch. `--lr-switch-checked-track-border` (default
`var(--lr-switch-track-border)`) varies that border only while checked, so a bordered track can
differ by state without falling back to `lr-switch:state(checked)::part(track)`. It takes a whole
`border` shorthand value, like its resting sibling, and falls back through it to no border at all —
setting only the resting hook keeps one border in both states. Keep both widths equal unless a size
change between states is what you want: the track is `box-sizing: content-box`, so a border grows
its outer footprint.
`--lr-switch-thumb-fill` (default `--lr-color-surface`) controls the thumb while unchecked, and is
also the checked-state fallback. `--lr-switch-checked-thumb-fill` (default
`var(--lr-switch-thumb-fill)`) independently retints the thumb only while checked, leaving the
unchecked thumb untouched.
`--lr-switch-label-color` (default `var(--lr-color-text)`) controls `[part='label']`'s text color,
and is also the checked-state fallback. `--lr-switch-checked-label-color` (default
`var(--lr-switch-label-color)`) independently retints the label only while checked. Both label hooks
read the switch's **live** checked state (the same `:state(checked)` custom state the class doc's
`@cssstate checked` documents), not the `checked` *attribute* — which instead reflects
`defaultChecked`, the form-reset value — so the checked-state hook tracks user interaction correctly
even though the attribute does not.
Plus shared tokens
`--lr-space-s`, `--lr-color-border/-brand/-surface/-text`,
`--lr-transition-fast`, `--lr-focus-ring-width/-color/-offset`, `--lr-opacity-disabled`.

**Optional peer deps:** none.

```html
<lr-switch name="notifications" checked>Enable notifications</lr-switch>
<script type="module">
  import "@aceshooting/lyra-ui/components/forms/switch/switch.js";
  const sw = document.querySelector("lr-switch");
  sw.addEventListener("lr-change", (e) => console.log(e.detail.checked)); // prefixed alias
  sw.addEventListener("change", (e) => console.log(e.target.checked)); // native-style, no detail
</script>
```

Form-associated the same way as `<lr-checkbox>`: a directly-attached `ElementInternals` with a
hand-rolled `updateValidity()`, not the shared `FormAssociated` mixin. The thumb animates the
logical `inset-inline-start` property (not a physical `transform: translateX()`), so the slide
direction mirrors correctly under `dir="rtl"`.
Session-history/autofill restoration uses the same explicit `checked`/`unchecked` state tokens as
checkbox and does not emit `lr-change`.

**Known gotchas:**

- `checked` is live and dirty; `defaultChecked`/the `checked` attribute is the current reset default.
  A later `.checked = true` never redefines what `form.reset()` restores to. Shoelace's
  Only the canonical `checked` attribute and property-only `defaultChecked` IDL define that reset
  default.
- The rendered `aria-label` is copied from the host's own `aria-label` attribute at render time; with
  neither that nor slotted label text, the control has no accessible name.

---
