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

# `lr-slider`

- **Import** `import '@aceshooting/lyra-ui/components/lr-slider.js';` (stable tag alias; registers the tag)
- **Class** `LyraSlider`, also available unregistered from `@aceshooting/lyra-ui/components/forms/slider/slider.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** 26 parts, 24 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-slider`

A numeric range control (e.g. an LLM "temperature" setting). **Form-associated** directly through
`ElementInternals`, because its public `value` is a number rather than the string assumed by the
shared form mixin. `value`, `defaultValue`, and `valueAsNumber` are numeric; `valueAsString` is the
explicit compatibility round-trip for code that still wants a serialized value. A primary-button press anywhere
on `[part~="base"]` (not just the thumb) jumps the
thumb to that point and continues the same gesture as a drag, matching native `<input type=range>`
click-to-seek — the thumb is also `.focus()`ed on that click, so keyboard interaction can continue
seamlessly right after. Mirrors the core `<wa-slider>` API under the `lr-` prefix.

Secondary mouse buttons leave the value and input/change events untouched. Primary mouse, touch and
pen gestures remain supported, including concurrent pointers.

Removing `label`, `hint`, `help-text` or `error-text` safely removes the corresponding copy; native
attribute-removal property readback remains unchanged, including `null`. Supplying an empty string
or later replacement remains supported. In single mode, host `aria-describedby` references resolve
in the host's root and precede local error/hint descriptions on the slider handle. Target
replacement, removal, reinsertion, host reconnection and document adoption update the relationship
without losing local guidance.

**Two-handle `range` mode.** `range` turns the control into a selection between `minValue` and
`maxValue`, defaulting to `0`/`50`. Each handle is a separately focusable `role="slider"` with its
own localized accessible name and the full domain as its reachable range. When the active handle
crosses its sibling, it pushes that sibling to the same value instead of stopping, so the active
thumb remains under the pointer/key. A track click moves whichever handle is nearer the clicked
position. `[part~="base"]` then carries `role="group"`, named from
`label`/`aria-label`, so the pair is announced as one control.

Switching `range` while the outgoing handle owns focus transfers focus to the equivalent replacement
(single value to lower handle; either range handle to single value) without reclaiming newer
external focus. A mode switch during a pointer drag releases capture and cancels that gesture
without an extra commit.

A named range slider submits **two same-name entries**, lower then upper. For example,
`<lr-slider range name="window">` contributes `window=0&window=50` by default. Read both with
`formData.getAll('window')`; `get()` returns only the first entry. Turning `range` off restores the
single numeric string entry.

**Properties:**

- `min: number = 0`
- `max: number = 100`
- `step: number = 1` — a zero or negative value is kept as an explicit "unstepped" mode
- `range: boolean = false` (reflected) — two-handle mode; see above
- `minValue: number = 0` (attribute `min-value`) — the lower handle's value in `range` mode.
  Assigning past `maxValue` pushes `maxValue` to the same number
- `maxValue: number = 50` (attribute `max-value`) — the upper handle's value. Assigning below
  `minValue` pushes `minValue` to the same number. Only the `min-value`/`max-value`
  _attributes_ are captured as the `form.reset()` defaults, so a later property assignment never
  redefines what a reset restores to
- `orientation: 'horizontal' | 'vertical' = 'horizontal'` (reflected) — which axis carries the
  value. `'horizontal'` maps values to the inline axis (mirroring under RTL); `'vertical'` maps them
  to the block axis with the domain minimum at the block **end** (so "up" always means "more"),
  switches the primary keys to ArrowUp/ArrowDown, and exposes `aria-orientation="vertical"` on every
  handle
- `readonly: boolean = false` (reflected) — the value is displayed but not changeable. Unlike
  `disabled`, a read-only slider stays focusable, fully legible, and **still submits its value**; it
  renders `aria-readonly` in both states and withdraws the grab cursor
- `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, the filled
  indicator, the tick marks and the handles off the same values `lr-input`/`lr-select`/`lr-button`
  read, so controls of one `size` line up in a row. The handle's transparent drag area keeps its own
  1.75rem/28px floor at every tier, so a small slider is still a conformant pointer target
- `withMarkers: boolean = false` (attribute `with-markers`) — draws a tick mark at every
  `step` position along the track. Purely decorative (`aria-hidden`). Nothing is drawn for an
  unstepped grid (`step` ≤ 0) or for one implying more than 100 intervals — ten million ticks would
  be visually indistinguishable and would hang the page, so the grid is dropped rather than
  half-drawn
- `withTooltip: boolean = false` (attribute `with-tooltip`) — shows a live value bubble
  above each handle while that handle is focused or being dragged. Its text is `valueFormatter`'s
  result when one is supplied, otherwise the locale-formatted number
- `label: string = ''`, `hint: string = ''`, and `errorText: string = ''` (attribute `error-text`) —
  visible form context around the track, with matching rich `label`/`hint`/`error` slots. A host
  `aria-label` wins for the interactive accessible name by attribute presence, including an
  explicitly empty value; range mode then suppresses `aria-labelledby` on its group owner as well.
  When error and hint content are both present, every handle's `aria-describedby` references the
  error first and the hint second. Rich slotted error content replaces the plain `errorText` copy.
- `helpText: string = ''` (`help-text`) and the `help-text` slot are Shoelace aliases for `hint`.
- `withLabel: boolean = false` / `withHint: boolean = false` (`with-label`/`with-hint`) are SSR
  presence hints; hydrated instances also discover populated slots automatically.
- `indicatorOffset?: number` (`indicator-offset`) — single-slider fill origin. The indicator spans
  between this number and `value`, whichever is lower.
- `autofocus: boolean = false` — focuses the first/lower thumb after the first client render.
- `tooltipPlacement: 'top' | 'right' | 'bottom' | 'left' = 'top'` and
  `tooltipDistance: number = 8` control physical tooltip layout in either orientation and RTL.
- `tooltip: 'top' | 'bottom' | 'none' = 'none'` and `tooltipFormatter?: (value) => string` are
  Shoelace-compatible aliases layered over the richer Lyra/Web Awesome tooltip surface. Lyra's
  default is `none`, while `<sl-range>` defaults to `top`; the bundled migration codemod inserts
  `tooltip="top"`, and a manual tag rename must set it explicitly to retain Shoelace's behavior.
- `valueFormatter?: SliderValueFormatter` (attribute: false) —
  `(value: number, handle: 'value' | 'min' | 'max') => string | null | undefined`. Maps the finite,
  clamped `aria-valuenow` number to optional human-readable `aria-valuetext`, and supplies the
  `with-tooltip` bubble's text. The second argument identifies which handle is being formatted
  (`'value'` on a single-handle slider). A nullish result omits `aria-valuetext`. Leaving the
  property unset preserves the numeric `aria-valuetext`.
- `showValue: boolean = false` (attribute `show-value`) — opt-in numeric readout next to the track;
  a range readout joins both values with an en dash. The explicit HTML spelling
  `show-value="false"` stays false.
- `valueDisplay: SliderValueDisplay = 'numeric'` (attribute `value-display`) — `'numeric' |
  'formatted'`. Opt into `formatted` to reuse `valueFormatter` (or `tooltipFormatter` when no
  value formatter is supplied) for the visible readout too. Each range handle is formatted
  separately; nullish results fall back to localized numbers. Existing ARIA and tooltip behavior
  is unchanged. The callback owns its unit labels and locale formatting.
- `valuePlacement: SliderValuePlacement = 'inline'` (attribute `value-placement`) — `'inline' |
  'label'`. With `showValue`, `label` places the readout opposite the label in a wrapping row that
  follows RTL. The readout stays outside the accessible label. These presentation options update
  on live `lr-input` changes without changing the commit-only `lr-change` contract.
- `value: number = 0`, `defaultValue: number = 0` (attribute `value`), `valueAsNumber: number`, and
  `valueAsString: string` are synchronized and finite. Values normalize the low/high domain, snap
  to the grid anchored at the low endpoint, then clamp so a non-grid endpoint remains reachable;
  invalid, nonpositive, or below-resolution steps are unstepped.
- `isRange: boolean` is the read-only normalized range-mode state.
- `name: string | null = null`, `disabled = false`, `required = false`, reflected `customError`,
  plus read-only `form`, `labels`, `validity`, `validationMessage`, and `willValidate` make up the
  native form surface.

**Events:** native-style `input` (no detail), then `lr-input`, fire continuously during an active
drag or keyboard step, including OS key-repeat while a key is held. Native-style `change` (no
detail), then `lr-change`, fire once an interaction commits: on pointerup for an enabled drag,
or on keyup or ordinary blur for a changed keyboard sequence. A later keyup cannot duplicate a
blur commit. Own or fieldset disablement, and readonly, cancel unfinished gestures without
reverting their live values or emitting a later change pair. Pointer cancellation, lost capture
and disconnection also remain noncommitting.
The focused handle's native `focus` and `blur` are re-dispatched from the host as bubbling,
composed events.
`lr-invalid` (no detail) fires when a validity check finds the slider invalid.
**Breaking in 8.0.0:** both details widened from `{ value: number }` to
`{ value: number; minValue: number; maxValue: number; handle: 'value' | 'min' | 'max' }`. `value` is
the value of the handle that moved and `handle` says which one that was (`'value'` on a
single-handle slider); `minValue`/`maxValue` always carry both range-handle positions. Existing
`e.detail.value` readers keep working unchanged.

**Methods:** `focus(options?)` and `click()` forward to the first/lower thumb; `blur()` releases the
thumb that actually owns focus. `stepUp(steps = 1)` / `stepDown(steps = 1)` silently move the
focused handle (first/lower when none is focused). `getForm()`, `checkValidity()`,
`reportValidity()`, `setCustomValidity(message)`, and `resetValidity()` mirror native form-control
methods.

**Slots:** `label`, `hint`, Shoelace alias `help-text`, `error`, and `reference`. Empty chrome stays
hidden and contributes no accessible relationship.

The standalone slider has a zero intrinsic flex minimum and a 100% allocation ceiling. Its label,
reference, error, and hint regions wrap even unbroken content in LTR and RTL; the fixed numeric
readout and track remain contained. An exact-320px story covers this composition.

**CSS parts:** `base slider form-control form-control-input input control` are tokens on the
interactive row (`role="group"` in range mode). `label form-control-label` share the visible label
node; `references` wraps the endpoint/unit slot; `error` is the error node; and
`hint form-control-help-text` share the hint node.
`track` is the full-length line; `indicator` is the filled portion from `min` up to the current
value, or between the two handles in `range` mode.
`markers` (the tick container, present only with `with-markers`) and `marker` (one `step`-grid
tick), `thumb` (a draggable handle, `role="slider"` — present on every handle including both range
ones), `thumb-min` and `thumb-max` (the lower and upper range handles; each carries `thumb` as
well, so `::part(thumb)` styles both while `::part(thumb-min)` reaches only one), `tooltip` (the
live value bubble per handle, present only with `with-tooltip`), `tooltip-visible` (added _to the
`tooltip` element's part list_ while that handle is focused or dragged — visibility is encoded in
the part name because `::part(tooltip)[data-visible]` is invalid CSS and never matches; write
`::part(tooltip-visible)`). The tooltip also exposes `tooltip__tooltip`, `tooltip__content`, and
`tooltip__arrow`. `value` is the opt-in readout; `label-row` contains the separate label and value
nodes when `showValue` and `valuePlacement="label"` are enabled.

**CSS custom states:** `disabled`, `dragging`, `focused`, `required`, `optional`, `valid`,
`invalid`, `user-valid`, and `user-invalid`. A slider always has a finite numeric value, so
`required` is still useful as a styling hook but does not by itself make the control invalid;
`setCustomValidity()` controls the invalid states.

**Breaking in 8.0.0:** the `fill` part was **renamed to `indicator`**, matching `wa-slider`. A
`::part(fill)` rule silently matches nothing now — rename it.

**The required marker.** `required` with a non-empty `label` paints the library's shared marker on
the `form-control-label` node — the one `::after` rule described under "The required-field marker"
above, not a copy of it, so `--lr-form-control-required-content`,
`--lr-form-control-required-color` and `--lr-form-control-required-offset` retune or suppress it
here exactly as they do on `lr-input`. With no label text the node is hidden and no glyph is
painted. It is purely a visual convention here, for the reason the custom states note above gives:
a slider always has a value, so the marker never accompanies a `valueMissing` violation.

**Themeable custom properties:** three geometry knobs ride the shared `size` ladder, so a tier moves
them all without a per-tier rule, and the values in brackets are what they resolve to at the default
`m`:

`--lr-slider-gap` (default `var(--lr-space-s)`) controls the row/column gap between the track,
value readout, label, references, error, and hint as those flex items wrap.

- `--lr-slider-thumb-size` (default `calc(var(--lr-form-control-height) * 0.4)`; `1rem`) — the
  diameter of each draggable handle. The transparent drag area around it never drops below
  1.75rem/28px whatever this is set to, so shrinking the visible dot cannot cost you the pointer
  target.
- `--lr-slider-track-thickness` (default `calc(var(--lr-slider-thumb-size) * 0.25)`; `0.25rem`) —
  the thickness of the track, the filled `indicator`, and (scaled from it) the `with-markers` ticks.
- `--lr-slider-row-size` (default `calc(var(--lr-form-control-height) * 0.6)`; `1.5rem`) — the
  cross-axis extent of `[part="base"]`: its block size when horizontal, its inline size when
  vertical.

`--lr-slider-track-length` (default `var(--lr-size-10rem)`) is the
track's length in `orientation="vertical"`; a horizontal track fills its container instead, so the
token is inert there. It is declared as an inline `var()` fallback and never on `:host`, so a
consumer value set on any ancestor is never shadowed. `--lr-slider-tooltip-distance` carries the
finite, unit-resolved tooltip offset computed from `tooltipDistance`; set `tooltipDistance` rather
than overriding this runtime value. The thumb has independent paint hooks:
`--lr-slider-thumb-bg` (default `var(--lr-color-brand)`), `--lr-slider-thumb-border-color`
(default `var(--lr-color-surface)`), `--lr-slider-thumb-hover-ring-color` (default
`var(--lr-color-brand-quiet)`), and `--lr-slider-thumb-active-ring-color` (defaulting through the
hover ring). Everything else is shared tokens —
`--lr-space-s`, `--lr-color-border/-brand/-surface/-text-quiet`, `--lr-shadow`,
`--lr-focus-ring-width/-color/-offset`, `--lr-opacity-disabled`.

Mapped aliases are also live: `--thumb-size`, `--thumb-width`, `--thumb-height`, `--track-height`,
`--track-size`, `--track-color-active`, `--track-color-inactive`, `--track-active-offset`,
`--tooltip-offset`, `--marker-width`, and `--marker-height`.

**Optional peer deps:** none.

```html
<lr-slider
  id="temperature-slider"
  name="temperature"
  min="0"
  max="2"
  step="0.1"
  label="Temperature"
  hint="Higher values make replies more varied."
  with-markers
  with-tooltip
></lr-slider>

<!-- Two handles, vertical. A name submits two `price` entries. -->
<lr-slider
  id="price-slider"
  range
  name="price"
  orientation="vertical"
  min="0"
  max="1000"
  step="50"
  min-value="200"
  max-value="800"
  label="Price"
></lr-slider>
<script type="module">
  const temperature = document.getElementById("temperature-slider");
  temperature.valueAsNumber = 0.7;
  temperature.valueFormatter = (value, handle) => `${value * 100}%`;
  temperature.addEventListener("lr-input", (e) => setDraftTemperature(e.detail.value));
  temperature.addEventListener("lr-change", (e) => commitTemperature(e.detail.value));

  const price = document.getElementById("price-slider");
  price.addEventListener("lr-change", (e) => applyPriceFilter(e.detail.minValue, e.detail.maxValue));
</script>
```

An unset `value` starts at numeric `0`, clamped into the configured domain and step grid. A reset
restores the numeric `defaultValue` sourced from the `value` content attribute, or the same zero
default when the attribute was absent. A slider therefore always represents a number; `required`
is present for upstream form-surface parity but adds no missing-value constraint.

**Known gotchas:**

- `valueAsNumber` always returns a real, clamped number — never `NaN` or `""` — even reading it in the
  brief window right after a `form.reset()`.
- Under `direction: rtl`, physical ArrowRight/ArrowLeft swap which one counts as "forward" (increasing
  value); ArrowUp/ArrowDown are never swapped, since direction only affects the horizontal inline axis.
- Changing `min`/`max`/`step` after mount automatically re-clamps/re-snaps the current `value` in the
  next update — narrowing the domain can silently move the slider's value.
- `valueFormatter` is presentation-only: `aria-valuenow`, geometry, form value, and emitted values
  stay numeric. The visible readout stays numeric unless `valueDisplay="formatted"` is set.
- Numeric value/domain assignments in the same Lit update batch are normalized against the final
  `min`, `max`, and `step`, regardless of binding order. Await `updateComplete` to read the settled
  scalar or range endpoints. Immediate imperative readback remains clamped to the current domain;
  separate update batches intentionally normalize independently. Programmatic normalization emits
  no user input/change event.
  With no formatter, `aria-valuetext` remains the
  numeric string rendered by earlier versions; a nullish formatter result omits it.
- A pointer drag fires `lr-input` continuously and a single `lr-change` on release; a keyboard step
  fires exactly one of each per press, but OS key-repeat while a key is held re-fires `lr-input` on
  every repeat while still only committing `lr-change` once, on the eventual keyup. A gesture that
  ends without a pointerup (`pointercancel`, lost pointer capture, the element being removed
  mid-drag) tears down cleanly and commits nothing.
- **`::part(fill)` no longer matches** — the part is `indicator` as of 8.0.0.
- A named `range` slider contributes two same-name entries. Use `FormData#getAll()`, not `get()`,
  when both values are required.
- `min-value`/`max-value` are clamped against the domain, snapped to `step`, and re-sanitized once
  every declarative attribute has landed, so narrowing `min`/`max` after mount can silently move
  both handles. Only the attributes seed the `form.reset()` defaults.
- Vertical sliders put the domain minimum at the block **end**: ArrowUp increases. ArrowUp/ArrowDown
  are never mirrored under RTL, which is exactly what makes them the stable primary keys there.
- `with-markers` silently draws nothing when `step` is 0/negative or when the domain implies more
  than 100 intervals. That is a deliberate ceiling, not a bug — check the rendered `[part="marker"]`
  count rather than assuming the ticks are there.
- The visible thumb is deliberately below the library's usual 40px icon-button floor — 16px at the
  default `m` tier, and smaller at the tighter ones. A transparent `::before` carries the hit/drag
  area at `max(28px, calc(var(--lr-slider-thumb-size) * 1.75))`, which clears WCAG 2.5.8's 24px
  minimum at **every** tier, while a 40px _visible_ thumb would make two range handles overlap
  across 40px of track and hijack track clicks. The pseudo-element has no DOM node of its own, so a
  pointerdown inside it still reports the thumb as `e.target`.

---
