Skip to content

Slider — live demo

This is the real @rozie-ui/slider-vue package running on this page (VitePress is itself a Vue app). Drag a thumb with the mouse or touch, focus it and press the arrow / Home / End / PageUp keys, drag the two range thumbs past each other (they clamp and stay sorted), or tip the vertical one — then watch the two-way bound value update. Everything below is driven by the same Slider.rozie source that compiles to all six frameworks, built on the browser's native <input type="range"> with no engine and no required CSS — the platform input behaviour, the cross-browser thumb styling, and a tokenised skin all ship inside the component.

value is two-way bound with v-model:value — the readout updates the instant you commit a value, and a consumer write flows back in. In single mode it's a scalar; with :range="true" it's a sorted [lo, hi] array (each thumb neighbour-clamped). The Single instance's buttons drive the imperative handle (increment(), decrement(), focus()) grabbed through Vue's ref. Flip orientation="vertical" to rotate the track (up = increase), pass :marks for tick marks, or :show-value for the value bubble — the same component, the same surface. See the full API for every prop, event, slot, and handle verb, plus theming and keyboard reference.

One source, six outputs

You author the component once as a .rozie file:

html
<!--
  Slider.rozie — a headless, WAI-ARIA accessible slider / range.

  The SECOND @rozie-ui family with NO third-party vanilla engine. Under
  Approach B the engine IS the browser's native <input type="range">: drag
  (mouse + touch), keyboard (arrows / Home / End), focus, role="slider",
  aria-value*, step / min / max, disabled, and RTL come from the platform for
  free. Rozie owns the author-side API, the two-way binding, the fill-var math,
  the range sort, the overlays, and a thin PageUp/PageDown step augment.

  Two overlapping transparent native inputs implement dual-thumb range
  (`range` prop). Vertical is a `transform: rotate(-90deg)` wrapper. The colored
  fill is a positioned <div> underlay driven by inline CSS custom properties
  (`--rozie-slider-fill-start` / `-end`) computed purely from value/min/max — no
  measured geometry, so ROZ123 ($refs only in $onMount) is never tripped.

  Authoring notes (collision classes — Phase 46 hardening makes natural names work):
    - The focus verb is `focus` — a DELIBERATE override of the inherited
      HTMLElement.focus on the Lit custom element. ROZ137 (EXPOSE_RESERVED_MEMBER)
      WARNS on this and does NOT auto-rename; the warn is accepted because the
      public `focus()` handle is intended. This INVERTS listbox's choice: listbox
      named its verb `focusControl` to AVOID the override; the slider accepts it
      (CONTEXT D-05). The accepted ROZ137 warn is documented here AND in every
      leaf README. `increment` / `decrement` are collision-safe (NOT host-element
      members), so they need no rename.
    - Every event fires through ONE wrapper fn (`fireChange`) so its
      prop-destructure hoists exactly once on React (a duplicate-emit-site would
      emit two `const … = props`). Phase 46 ITEM-1 also dedups, but the
      single-emit-site pattern is kept for clarity.
    - The fill is a `$computed` returning an object of inline CSS vars. Like every
      $computed it is read BARE in the template (`:style="fillStyle"`), never
      aliased in script — a $computed is a value on React but an accessor on
      Solid, so aliasing it diverges. Plain arithmetic (`pct`) stays a function.
    - The native value is read as `valueAsNumber` (a number), never bare `.value`
      (a string → arithmetic concatenation). The fresh number is used throughout
      a handler; $data is never re-read after a model write (ROZ138 staleness).

  Consumer example (single + range):

    <Slider r-model:value="$data.volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />

    <Slider range r-model:value="$data.priceRange" :min="0" :max="500" ariaLabel="Price range" />
-->

<rozie name="Slider">

<props>
{
  // The current value (two-way). A scalar number in single mode; a sorted
  // [lo, hi] array in range mode. As the sole `model:true` prop it drives the
  // Angular ControlValueAccessor — a Slider IS a form control.
  value:       {
    type: null,
    default: null,
    model: true,
    docs: {
      description:
        'The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).',
      example: '<Slider r-model:value="volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />',
    },
  },

  // Range mode: `value` becomes a sorted [lo, hi] array driven by two
  // overlapping thumbs. Exact mirror of listbox's `multiple` (scalar↔array).
  // Bare-attr `<Slider range>` coerces to true (Phase 46 ITEM-2).
  range:       {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox\'s `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.',
    },
  },

  // Linear scale bounds + granularity. Forwarded to the native input as the
  // `min`/`max`/`step` attributes (NOT aria-valuemin/max — the browser derives
  // those; MDN slider role).
  min:         {
    type: Number,
    default: 0,
    docs: {
      description:
        'The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).',
    },
  },
  max:         {
    type: Number,
    default: 100,
    docs: {
      description:
        'The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).',
    },
  },
  step:        {
    type: Number,
    default: 1,
    docs: {
      description:
        'The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.',
    },
  },

  // 'horizontal' (default) or 'vertical'. Vertical rotates the wrapper -90deg so
  // up = increase, and sets aria-orientation="vertical" explicitly.
  orientation: {
    type: String,
    default: 'horizontal',
    docs: {
      description:
        "Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation=\"vertical\"` explicitly (a native range input always reports itself as horizontal even when visually rotated).",
    },
  },

  disabled:    {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.',
    },
  },

  // Tick marks. A union: a bare value[] (positions only) OR a {value,label}[]
  // (positioned, labelled). The #mark scoped slot ({ value, label, position })
  // overrides the default rendering. Marks are a decorative overlay over the
  // track (siblings of the native input).
  marks:       {
    type: Array,
    default: () => [],
    docs: {
      description:
        'Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).',
    },
  },

  // Accessible name when there is no visible <label for>. Reflected onto each
  // native input's aria-label.
  ariaLabel:   {
    type: String,
    default: null,
    docs: {
      description:
        'Accessible name for each native input when there is no visible `<label for>`, reflected onto the input\'s `aria-label`.',
    },
  },

  // The PageUp/PageDown jump. null → step × 10. Applied by a thin @keydown
  // augment (arrows / Home / End stay native).
  pageStep:    {
    type: Number,
    default: null,
    docs: {
      description:
        'The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.',
    },
  },

  // Formats the value shown in the #value bubble and aria-valuetext. null → the
  // raw value. Receives the numeric value, returns a string.
  formatValue: {
    type: Function,
    default: null,
    docs: {
      description:
        'A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.',
    },
  },

  // Render the #value bubble overlay (one per thumb in range mode). Headless:
  // no default-styled bubble — the slot stays opt-in.
  showValue:   {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.',
    },
  },
}
</props>

<data>
{
  // No measured geometry: the fill is pure value/min/max arithmetic (D-06), so
  // there is no transient state to track here.
}
</data>

<script>
// ---- numeric helpers ---------------------------------------------------
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — it is called from both the fill $computed and the keyboard augment.
const pct = (v) => {
  const span = $props.max - $props.min
  if (span === 0) return 0
  const p = (v - $props.min) / span * 100
  if (p < 0) return 0
  if (p > 100) return 100
  return p
}

// Clamp a raw number into [min,max] and quantize to `step` (guarding against a
// non-finite or zero step). Returns a finite number bounded by the scale.
const clampStep = (raw) => {
  if (!Number.isFinite(raw)) return $props.min
  let v = raw
  if (v < $props.min) v = $props.min
  if (v > $props.max) v = $props.max
  const step = $props.step
  if (Number.isFinite(step) && step > 0) {
    const steps = Math.round((v - $props.min) / step)
    v = $props.min + steps * step
    if (v < $props.min) v = $props.min
    if (v > $props.max) v = $props.max
  }
  return v
}

// The current range pair, defaulting to the full span when `value` is not yet a
// 2-tuple. Read into a stable local before destructuring — `$props.value`
// lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
const rangePair = () => {
  const cur = $props.value
  if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]]
  return [$props.min, $props.max]
}

// The single (scalar) value, defaulting to min when not yet a number.
const singleValue = () => {
  const cur = $props.value
  return typeof cur === 'number' && Number.isFinite(cur) ? cur : $props.min
}

// ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
// Read BARE in the template via :style="fillStyle". Returns the fill extent as a
// % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
// start/end vars drive the (rotated) fill — no separate vertical math.
const fillStyle = $computed(() => {
  let start, end
  if ($props.range) {
    const arr = Array.isArray($props.value) && $props.value.length === 2
      ? $props.value
      : [$props.min, $props.max]
    start = pct(arr[0])
    end = pct(arr[1])
  } else {
    start = 0
    end = pct(typeof $props.value === 'number' && Number.isFinite($props.value) ? $props.value : $props.min)
  }
  return {
    '--rozie-slider-fill-start': start + '%',
    '--rozie-slider-fill-end': end + '%',
  }
})

// The marks list, normalised to { value, label } objects. A bare value[] entry
// becomes { value, label: String(value) }. A plain function (not $computed) so
// it reads uniformly and can be called in the r-for.
const normalizedMarks = () => {
  const list = Array.isArray($props.marks) ? $props.marks : []
  return list.map((m) => {
    if (m !== null && typeof m === 'object' && 'value' in m) {
      return { value: m.value, label: 'label' in m && m.label != null ? m.label : String(m.value) }
    }
    return { value: m, label: String(m) }
  })
}

// Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
// reads uniformly inside it.
const display = (v) => {
  if ($props.formatValue !== null) return $props.formatValue(v)
  return String(v)
}

// ---- write-back (single emit funnel) -----------------------------------
// The SOLE `$emit('change')` site, called from every commit path so the React
// prop-destructure for `onChange` hoists exactly once.
const fireChange = (value) => $emit('change', { value })

// Single-mode commit: capture the fresh number, write the scalar, emit. Never
// re-read $data after the write (ROZ138: React setState is async).
const commitSingle = (raw) => {
  const v = clampStep(raw)
  $model.value = v
  fireChange(v)
}

// Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
// neighbour, then write a FRESH array (in-place mutation is dropped on
// React/Solid/Lit/Angular change detectors — listbox precedent).
const commitRange = (which, raw) => {
  const pair = rangePair()
  let lo = pair[0]
  let hi = pair[1]
  const v = clampStep(raw)
  if (which === 'lo') lo = Math.min(v, hi)
  else hi = Math.max(v, lo)
  const next = [lo, hi]
  $model.value = next
  fireChange(next)
}

// ---- native input handlers ---------------------------------------------
// Single input. `valueAsNumber` is a number (never the string `.value`).
const onInputSingle = ($event) => commitSingle($event.target.valueAsNumber)
// Range inputs (lo / hi).
const onInputLo = ($event) => commitRange('lo', $event.target.valueAsNumber)
const onInputHi = ($event) => commitRange('hi', $event.target.valueAsNumber)

// ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
// Native PageUp/PageDown uses the browser's default large step, which may not
// equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
// quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
const effectivePageStep = () => {
  const ps = $props.pageStep
  if (Number.isFinite(ps) && ps > 0) return ps
  const step = Number.isFinite($props.step) && $props.step > 0 ? $props.step : 1
  return step * 10
}

const onKeyDownSingle = ($event) => {
  const key = $event.key
  if (key !== 'PageUp' && key !== 'PageDown') return
  $event.preventDefault()
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep()
  commitSingle(singleValue() + delta)
}

const onKeyDownRange = (which, $event) => {
  const key = $event.key
  if (key !== 'PageUp' && key !== 'PageDown') return
  $event.preventDefault()
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep()
  const pair = rangePair()
  const base = which === 'lo' ? pair[0] : pair[1]
  commitRange(which, base + delta)
}

// ---- imperative handle (D-05) ------------------------------------------
// `focus` reads $refs in a post-mount callback (called via the handle) — safe,
// never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
// (ROZ137 warns; accepted — see header).
const focus = () => $refs.inputEl?.focus()

// Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
const increment = (thumb) => {
  if ($props.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo'
    const pair = rangePair()
    const base = which === 'lo' ? pair[0] : pair[1]
    commitRange(which, base + $props.step)
  } else {
    commitSingle(singleValue() + $props.step)
  }
}
const decrement = (thumb) => {
  if ($props.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo'
    const pair = rangePair()
    const base = which === 'lo' ? pair[0] : pair[1]
    commitRange(which, base - $props.step)
  } else {
    commitSingle(singleValue() - $props.step)
  }
}

// Shorthand keys (aliased `{ focus: fn }` keys are dropped by the React emitter)
// — every function is named exactly as its verb. `focus` triggers the accepted
// ROZ137 warn.
$expose({ focus, increment, decrement })
</script>

<template>
<div
  class="rozie-slider"
  :class="{ 'rozie-slider--vertical': $props.orientation === 'vertical', 'rozie-slider--horizontal': $props.orientation !== 'vertical', 'rozie-slider--range': $props.range, 'rozie-slider--disabled': $props.disabled }"
  :style="fillStyle"
>
  <!-- The track, with the colored filled-<div> underlay (D-07, z under thumbs).
       Both are positioned via the --rozie-slider-fill-* vars from fillStyle. -->
  <div class="rozie-slider-track" aria-hidden="true">
    <div class="rozie-slider-fill"></div>
  </div>

  <!-- Marks overlay layer (D-04). One decorative marker per mark; the #mark
       scoped slot ({ value, label, position }) overrides the default label.
       Mirrors the listbox #option scoped-slot shape. -->
  <div r-if="normalizedMarks().length > 0" class="rozie-slider-marks" aria-hidden="true">
    <!-- Loop var is `tick`, NOT `mark`: a loop var named `mark` shadows the
         `#mark` slot snippet on Svelte (`{#each … as mark}` collides with the
         `mark` snippet binding → `{@render mark()}` renders a non-function →
         Svelte-only mount throw). Same class of bug as embla's slide→item. -->
    <div
      r-for="tick in normalizedMarks()"
      :key="tick.value"
      class="rozie-slider-mark"
      :style="{ left: pct(tick.value) + '%' }"
    >
      <slot name="mark" :value="tick.value" :label="tick.label" :position="pct(tick.value)">
        <span class="rozie-slider-mark-label">{{ tick.label }}</span>
      </slot>
    </div>
  </div>

  <!-- Value bubble overlay (D-03), gated by showValue. One bubble per thumb in
       range mode, each positioned at its fill var. Decorative siblings of the
       thumbs (content cannot project into the native thumb pseudo-element).
       The slot is named `bubble`, NOT `value` — a slot sharing a declared prop
       name (`value`) is a hard ROZ127 error (Svelte 5 unifies snippets + props
       into one $props namespace). It still exposes the per-thumb `value`. -->
  <div r-if="$props.showValue && !$props.range" class="rozie-slider-bubbles" aria-hidden="true">
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-end)' }">
      <slot name="bubble" :value="singleValue()">
        <span class="rozie-slider-bubble-text">{{ display(singleValue()) }}</span>
      </slot>
    </div>
  </div>
  <div r-if="$props.showValue && $props.range" class="rozie-slider-bubbles" aria-hidden="true">
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-start)' }">
      <slot name="bubble" :value="rangePair()[0]">
        <span class="rozie-slider-bubble-text">{{ display(rangePair()[0]) }}</span>
      </slot>
    </div>
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-end)' }">
      <slot name="bubble" :value="rangePair()[1]">
        <span class="rozie-slider-bubble-text">{{ display(rangePair()[1]) }}</span>
      </slot>
    </div>
  </div>

  <!-- Single-value native input. Drag / keyboard / role="slider" / aria-value*
       come from the platform; only aria-orientation is set explicitly when
       vertical (the input itself reports horizontal). min/max/step are native
       attrs (NOT aria-valuemin/max — MDN). -->
  <input
    r-if="!$props.range"
    ref="inputEl"
    class="rozie-slider-input"
    type="range"
    :min="$props.min"
    :max="$props.max"
    :step="$props.step"
    :value="singleValue()"
    :disabled="!!$props.disabled"
    :aria-label="$props.ariaLabel"
    :aria-orientation="$props.orientation === 'vertical' ? 'vertical' : 'horizontal'"
    :aria-valuetext="$props.formatValue !== null ? display(singleValue()) : null"
    @input="onInputSingle($event)"
    @keydown="onKeyDownSingle($event)"
  />

  <!-- Range: two overlapping native inputs (D-01 / Pattern 1). `inputEl` ref is
       on the lo thumb for the focus handle. -->
  <input
    r-if="$props.range"
    ref="inputEl"
    class="rozie-slider-input rozie-slider-input--lo"
    type="range"
    :min="$props.min"
    :max="$props.max"
    :step="$props.step"
    :value="rangePair()[0]"
    :disabled="!!$props.disabled"
    :aria-label="$props.ariaLabel"
    :aria-orientation="$props.orientation === 'vertical' ? 'vertical' : 'horizontal'"
    :aria-valuetext="$props.formatValue !== null ? display(rangePair()[0]) : null"
    @input="onInputLo($event)"
    @keydown="onKeyDownRange('lo', $event)"
  />
  <input
    r-if="$props.range"
    class="rozie-slider-input rozie-slider-input--hi"
    type="range"
    :min="$props.min"
    :max="$props.max"
    :step="$props.step"
    :value="rangePair()[1]"
    :disabled="!!$props.disabled"
    :aria-label="$props.ariaLabel"
    :aria-orientation="$props.orientation === 'vertical' ? 'vertical' : 'horizontal'"
    :aria-valuetext="$props.formatValue !== null ? display(rangePair()[1]) : null"
    @input="onInputHi($event)"
    @keydown="onKeyDownRange('hi', $event)"
  />
</div>
</template>

<style>
/*
  Fully token-driven. EVERY visual value is a `var(--rozie-slider-*, <fallback>)`,
  so the component renders with zero configuration yet is completely re-skinnable
  by setting tokens at any ancestor scope (`:root`, `.dark`, a wrapper, or the
  `.rozie-slider` element itself). The shipped `themes/*.css` presets map these
  tokens onto shadcn/Radix, Material 3, and Bootstrap 5.

  STRUCTURAL rules (Approach B overlap, the filled-<div> underlay, the rotate-90
  vertical wrapper, the per-vendor pseudo-elements) are behavior-critical (D-08):
  they compile per-leaf and are NOT consumer-overridable. Token DEFAULTS live in
  themes/base.css; only the cosmetic values flow through tokens.
*/

/* ---- wrapper + track ------------------------------------------------- */
.rozie-slider {
  position: relative;
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--rozie-slider-thumb-size, 1rem);
  padding: var(--rozie-slider-pad, 0.5rem 0);
  font: var(--rozie-slider-font, inherit);
}

/* The track is a thin centered bar behind the thumbs. */
.rozie-slider-track {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  transform: translateY(-50%);
  height: var(--rozie-slider-track-height, 0.375rem);
  border-radius: var(--rozie-slider-track-radius, 999px);
  background: var(--rozie-slider-track-bg, rgba(0, 0, 0, 0.18));
  pointer-events: none;
}

/* The colored fill is a positioned <div> underlay (D-07), NOT a gradient track.
   It spans from --fill-start to --fill-end (both % of the track). */
.rozie-slider-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--rozie-slider-fill-start, 0%);
  right: calc(100% - var(--rozie-slider-fill-end, 0%));
  border-radius: inherit;
  background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, #0066cc));
}

/* ---- overlapping native inputs (D-08, Approach B) -------------------- */
/* Transparent tracks; clicks pass through the bar; only the thumb pseudo-
   elements catch pointer events; the focused input is raised so its thumb wins. */
.rozie-slider-input {
  -webkit-appearance: none;
  appearance: none;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
  width: 100%;
  height: var(--rozie-slider-thumb-size, 1rem);
  margin: 0;
  background: none;
  pointer-events: none;
  cursor: pointer;
  accent-color: var(--rozie-slider-accent, #0066cc);
}
.rozie-slider-input:focus { outline: none; z-index: 2; }
.rozie-slider--range .rozie-slider-input { pointer-events: none; }
.rozie-slider--disabled .rozie-slider-input { cursor: not-allowed; }
.rozie-slider--disabled { opacity: var(--rozie-slider-disabled-opacity, 0.55); }

/* Transparent native tracks — the filled <div> shows through. Each vendor
   pseudo-element is in its OWN rule block: comma-combining them drops the whole
   selector on both browsers (Pitfall 2). */
.rozie-slider-input::-webkit-slider-runnable-track {
  background: none;
  height: var(--rozie-slider-track-height, 0.375rem);
}
.rozie-slider-input::-moz-range-track {
  background: none;
  height: var(--rozie-slider-track-height, 0.375rem);
}
/* Firefox draws a progress fill on the native track — suppress it (we own the
   fill via the <div> underlay). */
.rozie-slider-input::-moz-range-progress {
  background: none;
}

/* Thumb pseudo-elements re-enable pointer events and carry the token styling.
   SEPARATE blocks per vendor (never comma-joined). WebKit needs an explicit
   -webkit-appearance:none and a margin-top to vertically center the thumb. */
.rozie-slider-input::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, 1rem);
  height: var(--rozie-slider-thumb-size, 1rem);
  border: var(--rozie-slider-thumb-border, 2px solid #fff);
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
  box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
  margin-top: var(--rozie-slider-thumb-offset, calc((0.375rem - 1rem) / 2));
  cursor: pointer;
}
.rozie-slider-input::-moz-range-thumb {
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, 1rem);
  height: var(--rozie-slider-thumb-size, 1rem);
  border: var(--rozie-slider-thumb-border, 2px solid #fff);
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
  box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
  cursor: pointer;
}

/* ---- vertical (rotate-90 wrapper, Pattern 3) ------------------------- */
/* Swap the box to a tall, narrow column; rotate the (still-horizontal) input
   -90deg so up = increase. transform-origin centers the rotation in the column. */
.rozie-slider--vertical {
  width: var(--rozie-slider-thickness, 2.5rem);
  height: var(--rozie-slider-length, 12rem);
  padding: 0;
}
.rozie-slider--vertical .rozie-slider-track,
.rozie-slider--vertical .rozie-slider-input {
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, 12rem);
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider--vertical .rozie-slider-fill {
  /* The fill still spans start→end along the (now rotated) input axis. */
}
.rozie-slider--vertical .rozie-slider-marks,
.rozie-slider--vertical .rozie-slider-bubbles {
  /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, 12rem);
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}

/* ---- marks overlay --------------------------------------------------- */
.rozie-slider-marks {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-mark {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  color: var(--rozie-slider-mark-color, rgba(0, 0, 0, 0.55));
}
.rozie-slider-mark-label {
  position: absolute;
  top: var(--rozie-slider-mark-offset, 0.75rem);
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--rozie-slider-mark-font-size, 0.6875rem);
  white-space: nowrap;
}

/* ---- value bubble overlay ------------------------------------------- */
.rozie-slider-bubbles {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-bubble {
  position: absolute;
  top: var(--rozie-slider-bubble-offset, -1.25rem);
  transform: translateX(-50%);
}
.rozie-slider-bubble-text {
  display: inline-block;
  padding: var(--rozie-slider-bubble-padding, 0.0625rem 0.375rem);
  font-size: var(--rozie-slider-bubble-font-size, 0.6875rem);
  color: var(--rozie-slider-bubble-fg, #fff);
  background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, #0066cc));
  border-radius: var(--rozie-slider-bubble-radius, 4px);
  white-space: nowrap;
}
</style>

</rozie>

…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/slider-{react,vue,svelte,angular,solid,lit}):

tsx
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, parseInlineStyle, rozieAttr, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './Slider.css';

interface MarkCtx { value: any; label: any; position: any; }

interface BubbleCtx { value: any; }

interface SliderProps {
  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <Slider r-model:value="volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />
   */
  value?: (unknown) | null;
  defaultValue?: (unknown) | null;
  onValueChange?: (value: (unknown) | null) => void;
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  range?: boolean;
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  min?: number;
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  max?: number;
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  step?: number;
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  orientation?: string;
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  marks?: any[];
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  ariaLabel?: (string) | null;
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  pageStep?: (number) | null;
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  formatValue?: ((...args: any[]) => any) | null;
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  showValue?: boolean;
  onChange?: (...args: any[]) => void;
  renderMark?: (ctx: MarkCtx) => ReactNode;
  renderBubble?: (ctx: BubbleCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface SliderHandle {
  focus: (...args: any[]) => any;
  increment: (...args: any[]) => any;
  decrement: (...args: any[]) => any;
}

const Slider = forwardRef<SliderHandle, SliderProps>(function Slider(_props: SliderProps, ref): JSX.Element {
  const __defaultMarks = useState(() => (() => [])())[0];
  const props: Omit<SliderProps, 'range' | 'min' | 'max' | 'step' | 'orientation' | 'disabled' | 'marks' | 'ariaLabel' | 'pageStep' | 'formatValue' | 'showValue'> & { range: boolean; min: number; max: number; step: number; orientation: string; disabled: boolean; marks: any[]; ariaLabel: (string) | null; pageStep: (number) | null; formatValue: ((...args: any[]) => any) | null; showValue: boolean } = {
    ..._props,
    range: _props.range ?? false,
    min: _props.min ?? 0,
    max: _props.max ?? 100,
    step: _props.step ?? 1,
    orientation: _props.orientation ?? 'horizontal',
    disabled: _props.disabled ?? false,
    marks: _props.marks ?? __defaultMarks,
    ariaLabel: _props.ariaLabel ?? null,
    pageStep: _props.pageStep ?? null,
    formatValue: _props.formatValue ?? null,
    showValue: _props.showValue ?? false,
  };
  const attrs: Record<string, unknown> = (() => {
    const { value, range, min, max, step, orientation, disabled, marks, ariaLabel, pageStep, formatValue, showValue, defaultValue, onValueChange, ...rest } = _props as SliderProps & Record<string, unknown>;
    void value; void range; void min; void max; void step; void orientation; void disabled; void marks; void ariaLabel; void pageStep; void formatValue; void showValue; void defaultValue; void onValueChange;
    return rest;
  })();
  const [value, setValue] = useControllableState({
    value: props.value,
    defaultValue: props.defaultValue ?? null,
    onValueChange: props.onValueChange,
  });
  const inputEl = useRef<HTMLInputElement | null>(null);
  const fillStyle = useMemo(() => {
    let start, end;
    if (props.range) {
      const arr = Array.isArray(value) && value.length === 2 ? value : [props.min, props.max];
      start = pct(arr[0]);
      end = pct(arr[1]);
    } else {
      start = 0;
      end = pct(typeof value === 'number' && Number.isFinite(value) ? value : props.min);
    }
    return {
      '--rozie-slider-fill-start': start + '%',
      '--rozie-slider-fill-end': end + '%'
    };
  }, [Array, Number, pct, props.max, props.min, props.range, value]);

  function pct(v: any) {
    const span = props.max - props.min;
    if (span === 0) return 0;
    const p = (v - props.min) / span * 100;
    if (p < 0) return 0;
    if (p > 100) return 100;
    return p;
  }
  function clampStep(raw: any) {
    if (!Number.isFinite(raw)) return props.min;
    let v = raw;
    if (v < props.min) v = props.min;
    if (v > props.max) v = props.max;
    const step = props.step;
    if (Number.isFinite(step) && step > 0) {
      const steps = Math.round((v - props.min) / step);
      v = props.min + steps * step;
      if (v < props.min) v = props.min;
      if (v > props.max) v = props.max;
    }
    return v;
  }
  function rangePair() {
    const cur = value;
    if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
    return [props.min, props.max];
  }
  function singleValue() {
    const cur = value;
    return typeof cur === 'number' && Number.isFinite(cur) ? cur : props.min;
  }
  function normalizedMarks() {
    const list = Array.isArray(props.marks) ? props.marks : [];
    return list.map((m: any) => {
      if (m !== null && typeof m === 'object' && 'value' in m) {
        return {
          value: m.value,
          label: 'label' in m && m.label != null ? m.label : String(m.value)
        };
      }
      return {
        value: m,
        label: String(m)
      };
    });
  }
  function display(v: any) {
    if (props.formatValue !== null) return props.formatValue(v);
    return String(v);
  }
  function fireChange(value: any) {
    return props.onChange && props.onChange({
      value
    });
  }
  function commitSingle(raw: any) {
    const v = clampStep(raw);
    setValue(v);
    fireChange(v);
  }
  function commitRange(which: any, raw: any) {
    const pair = rangePair();
    let lo = pair[0];
    let hi = pair[1];
    const v = clampStep(raw);
    if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
    const next = [lo, hi];
    setValue(next);
    fireChange(next);
  }
  const onInputSingle = useCallback(($event: any) => commitSingle($event.target.valueAsNumber), [commitSingle]);
  const onInputLo = useCallback(($event: any) => commitRange('lo', $event.target.valueAsNumber), [commitRange]);
  const onInputHi = useCallback(($event: any) => commitRange('hi', $event.target.valueAsNumber), [commitRange]);
  function effectivePageStep() {
    const ps = props.pageStep;
    if (Number.isFinite(ps) && ps > 0) return ps;
    const step = Number.isFinite(props.step) && props.step > 0 ? props.step : 1;
    return step * 10;
  }
  const onKeyDownSingle = useCallback(($event: any) => {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
    commitSingle(singleValue() + delta);
  }, [commitSingle, effectivePageStep, singleValue]);
  const onKeyDownRange = useCallback((which: any, $event: any) => {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base + delta);
  }, [commitRange, effectivePageStep, rangePair]);
  function focus() {
    return inputEl.current?.focus();
  }
  function increment(thumb: any) {
    if (props.range) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      commitRange(which, base + props.step);
    } else {
      commitSingle(singleValue() + props.step);
    }
  }
  function decrement(thumb: any) {
    if (props.range) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      commitRange(which, base - props.step);
    } else {
      commitSingle(singleValue() - props.step);
    }
  }

  const _rozieExposeRef = useRef({ focus, increment, decrement });
  _rozieExposeRef.current = { focus, increment, decrement };
  useImperativeHandle(ref, () => ({ focus: (...args: Parameters<typeof focus>): ReturnType<typeof focus> => _rozieExposeRef.current.focus(...args), increment: (...args: Parameters<typeof increment>): ReturnType<typeof increment> => _rozieExposeRef.current.increment(...args), decrement: (...args: Parameters<typeof decrement>): ReturnType<typeof decrement> => _rozieExposeRef.current.decrement(...args) }), []);

  return (
    <>
    <div style={parseInlineStyle(fillStyle)} {...attrs} className={clsx(clsx("rozie-slider", { "rozie-slider--vertical": props.orientation === 'vertical', "rozie-slider--horizontal": props.orientation !== 'vertical', "rozie-slider--range": props.range, "rozie-slider--disabled": props.disabled }), (attrs.className as string | undefined))} data-rozie-s-4e6f0be6="">
      
      <div className={"rozie-slider-track"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div className={"rozie-slider-fill"} data-rozie-s-4e6f0be6="" />
      </div>

      
      {(normalizedMarks().length > 0) && <div className={"rozie-slider-marks"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        
        {normalizedMarks().map((tick) => <div key={tick.value} className={"rozie-slider-mark"} style={{ left: pct(tick.value) + '%' }} data-rozie-s-4e6f0be6="">
          {(props.renderMark ?? props.slots?.['mark']) ? ((props.renderMark ?? props.slots?.['mark']) as Function)({ value: tick.value, label: tick.label, position: pct(tick.value) }) : <span className={"rozie-slider-mark-label"} data-rozie-s-4e6f0be6="">{rozieDisplay(tick.label)}</span>}
        </div>)}
      </div>}{(props.showValue && !props.range) && <div className={"rozie-slider-bubbles"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div className={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-end)' }} data-rozie-s-4e6f0be6="">
          {(props.renderBubble ?? props.slots?.['bubble']) ? ((props.renderBubble ?? props.slots?.['bubble']) as Function)({ value: singleValue() }) : <span className={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(singleValue()))}</span>}
        </div>
      </div>}{(props.showValue && props.range) && <div className={"rozie-slider-bubbles"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div className={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-start)' }} data-rozie-s-4e6f0be6="">
          {(props.renderBubble ?? props.slots?.['bubble']) ? ((props.renderBubble ?? props.slots?.['bubble']) as Function)({ value: rangePair()[0] }) : <span className={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(rangePair()[0]))}</span>}
        </div>
        <div className={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-end)' }} data-rozie-s-4e6f0be6="">
          {(props.renderBubble ?? props.slots?.['bubble']) ? ((props.renderBubble ?? props.slots?.['bubble']) as Function)({ value: rangePair()[1] }) : <span className={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(rangePair()[1]))}</span>}
        </div>
      </div>}{(!props.range) && <input ref={inputEl} className={"rozie-slider-input"} type="range" min={props.min} max={props.max} step={props.step} value={singleValue()} disabled={!!props.disabled} aria-label={rozieAttr(props.ariaLabel)} aria-orientation={rozieAttr(props.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(props.formatValue !== null ? display(singleValue()) : undefined)} onInput={($event) => { onInputSingle($event); }} onKeyDown={($event) => { onKeyDownSingle($event); }} data-rozie-s-4e6f0be6="" />}{(props.range) && <input ref={inputEl} className={"rozie-slider-input rozie-slider-input--lo"} type="range" min={props.min} max={props.max} step={props.step} value={rangePair()[0]} disabled={!!props.disabled} aria-label={rozieAttr(props.ariaLabel)} aria-orientation={rozieAttr(props.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(props.formatValue !== null ? display(rangePair()[0]) : undefined)} onInput={($event) => { onInputLo($event); }} onKeyDown={($event) => { onKeyDownRange('lo', $event); }} data-rozie-s-4e6f0be6="" />}{(props.range) && <input className={"rozie-slider-input rozie-slider-input--hi"} type="range" min={props.min} max={props.max} step={props.step} value={rangePair()[1]} disabled={!!props.disabled} aria-label={rozieAttr(props.ariaLabel)} aria-orientation={rozieAttr(props.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(props.formatValue !== null ? display(rangePair()[1]) : undefined)} onInput={($event) => { onInputHi($event); }} onKeyDown={($event) => { onKeyDownRange('hi', $event); }} data-rozie-s-4e6f0be6="" />}</div>
    </>
  );
});
export default Slider;
vue
<template>

<div :class="['rozie-slider', { 'rozie-slider--vertical': props.orientation === 'vertical', 'rozie-slider--horizontal': props.orientation !== 'vertical', 'rozie-slider--range': props.range, 'rozie-slider--disabled': props.disabled }]" :style="fillStyle" v-bind="$attrs">
  
  <div class="rozie-slider-track" aria-hidden="true">
    <div class="rozie-slider-fill"></div>
  </div>

  
  <div v-if="normalizedMarks().length > 0" class="rozie-slider-marks" aria-hidden="true">
    
    <div v-for="tick in normalizedMarks()" :key="tick.value" class="rozie-slider-mark" :style="{ left: pct(tick.value) + '%' }">
      <slot name="mark" :value="tick.value" :label="tick.label" :position="pct(tick.value)">
        <span class="rozie-slider-mark-label">{{ tick.label }}</span>
      </slot>
    </div>
  </div><div v-if="props.showValue && !props.range" class="rozie-slider-bubbles" aria-hidden="true">
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-end)' }">
      <slot name="bubble" :value="singleValue()">
        <span class="rozie-slider-bubble-text">{{ display(singleValue()) }}</span>
      </slot>
    </div>
  </div><div v-if="props.showValue && props.range" class="rozie-slider-bubbles" aria-hidden="true">
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-start)' }">
      <slot name="bubble" :value="rangePair()[0]">
        <span class="rozie-slider-bubble-text">{{ display(rangePair()[0]) }}</span>
      </slot>
    </div>
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-end)' }">
      <slot name="bubble" :value="rangePair()[1]">
        <span class="rozie-slider-bubble-text">{{ display(rangePair()[1]) }}</span>
      </slot>
    </div>
  </div><input v-if="!props.range" ref="inputElRef" class="rozie-slider-input" type="range" :min="props.min" :max="props.max" :step="props.step" :value="singleValue()" :disabled="!!props.disabled" :aria-label="props.ariaLabel" :aria-orientation="props.orientation === 'vertical' ? 'vertical' : 'horizontal'" :aria-valuetext="props.formatValue !== null ? display(singleValue()) : undefined" @input="onInputSingle($event)" @keydown="onKeyDownSingle($event)" /><input v-if="props.range" ref="inputElRef" class="rozie-slider-input rozie-slider-input--lo" type="range" :min="props.min" :max="props.max" :step="props.step" :value="rangePair()[0]" :disabled="!!props.disabled" :aria-label="props.ariaLabel" :aria-orientation="props.orientation === 'vertical' ? 'vertical' : 'horizontal'" :aria-valuetext="props.formatValue !== null ? display(rangePair()[0]) : undefined" @input="onInputLo($event)" @keydown="onKeyDownRange('lo', $event)" /><input v-if="props.range" class="rozie-slider-input rozie-slider-input--hi" type="range" :min="props.min" :max="props.max" :step="props.step" :value="rangePair()[1]" :disabled="!!props.disabled" :aria-label="props.ariaLabel" :aria-orientation="props.orientation === 'vertical' ? 'vertical' : 'horizontal'" :aria-valuetext="props.formatValue !== null ? display(rangePair()[1]) : undefined" @input="onInputHi($event)" @keydown="onKeyDownRange('hi', $event)" /></div>

</template>

<script setup lang="ts">
import { computed, ref } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
     */
    range?: boolean;
    /**
     * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
     */
    min?: number;
    /**
     * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
     */
    max?: number;
    /**
     * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
     */
    step?: number;
    /**
     * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
     */
    orientation?: string;
    /**
     * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
     */
    disabled?: boolean;
    /**
     * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
     */
    marks?: any[];
    /**
     * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
     */
    ariaLabel?: string | null;
    /**
     * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
     */
    pageStep?: number | null;
    /**
     * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
     */
    formatValue?: ((...args: any[]) => any) | null;
    /**
     * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
     */
    showValue?: boolean;
  }>(),
  { range: false, min: 0, max: 100, step: 1, orientation: 'horizontal', disabled: false, marks: () => [], ariaLabel: null, pageStep: null, formatValue: null, showValue: false }
);

/**
 * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
 * @example
 * <Slider r-model:value="volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />
 */
const value = defineModel<unknown>('value', { default: null });

const emit = defineEmits<{
  change: [...args: any[]];
}>();

defineSlots<{
  mark(props: { value: any; label: any; position: any }): any;
  bubble(props: { value: any }): any;
  bubble(props: { value: any }): any;
  bubble(props: { value: any }): any;
}>();

const inputElRef = ref<HTMLInputElement>();

const fillStyle = computed(() => {
  let start, end;
  if (props.range) {
    const arr = Array.isArray(value.value) && value.value.length === 2 ? value.value : [props.min, props.max];
    start = pct(arr[0]);
    end = pct(arr[1]);
  } else {
    start = 0;
    end = pct(typeof value.value === 'number' && Number.isFinite(value.value) ? value.value : props.min);
  }
  return {
    '--rozie-slider-fill-start': start + '%',
    '--rozie-slider-fill-end': end + '%'
  };
});

// ---- numeric helpers ---------------------------------------------------
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — it is called from both the fill $computed and the keyboard augment.
const pct = (v: any) => {
  const span = props.max - props.min;
  if (span === 0) return 0;
  const p = (v - props.min) / span * 100;
  if (p < 0) return 0;
  if (p > 100) return 100;
  return p;
};

// Clamp a raw number into [min,max] and quantize to `step` (guarding against a
// non-finite or zero step). Returns a finite number bounded by the scale.
// Clamp a raw number into [min,max] and quantize to `step` (guarding against a
// non-finite or zero step). Returns a finite number bounded by the scale.
const clampStep = (raw: any) => {
  if (!Number.isFinite(raw)) return props.min;
  let v = raw;
  if (v < props.min) v = props.min;
  if (v > props.max) v = props.max;
  const step = props.step;
  if (Number.isFinite(step) && step > 0) {
    const steps = Math.round((v - props.min) / step);
    v = props.min + steps * step;
    if (v < props.min) v = props.min;
    if (v > props.max) v = props.max;
  }
  return v;
};

// The current range pair, defaulting to the full span when `value` is not yet a
// 2-tuple. Read into a stable local before destructuring — `$props.value`
// lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
// The current range pair, defaulting to the full span when `value` is not yet a
// 2-tuple. Read into a stable local before destructuring — `$props.value`
// lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
const rangePair = () => {
  const cur = value.value;
  if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
  return [props.min, props.max];
};

// The single (scalar) value, defaulting to min when not yet a number.
// The single (scalar) value, defaulting to min when not yet a number.
const singleValue = () => {
  const cur = value.value;
  return typeof cur === 'number' && Number.isFinite(cur) ? cur : props.min;
};

// ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
// Read BARE in the template via :style="fillStyle". Returns the fill extent as a
// % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
// start/end vars drive the (rotated) fill — no separate vertical math.
// The marks list, normalised to { value, label } objects. A bare value[] entry
// becomes { value, label: String(value) }. A plain function (not $computed) so
// it reads uniformly and can be called in the r-for.
const normalizedMarks = () => {
  const list = Array.isArray(props.marks) ? props.marks : [];
  return list.map((m: any) => {
    if (m !== null && typeof m === 'object' && 'value' in m) {
      return {
        value: m.value,
        label: 'label' in m && m.label != null ? m.label : String(m.value)
      };
    }
    return {
      value: m,
      label: String(m)
    };
  });
};

// Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
// reads uniformly inside it.
// Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
// reads uniformly inside it.
const display = (v: any) => {
  if (props.formatValue !== null) return props.formatValue(v);
  return String(v);
};

// ---- write-back (single emit funnel) -----------------------------------
// The SOLE `$emit('change')` site, called from every commit path so the React
// prop-destructure for `onChange` hoists exactly once.
// ---- write-back (single emit funnel) -----------------------------------
// The SOLE `$emit('change')` site, called from every commit path so the React
// prop-destructure for `onChange` hoists exactly once.
const fireChange = (value: any) => emit('change', {
  value
});

// Single-mode commit: capture the fresh number, write the scalar, emit. Never
// re-read $data after the write (ROZ138: React setState is async).
// Single-mode commit: capture the fresh number, write the scalar, emit. Never
// re-read $data after the write (ROZ138: React setState is async).
const commitSingle = (raw: any) => {
  const v = clampStep(raw);
  value.value = v;
  fireChange(v);
};

// Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
// neighbour, then write a FRESH array (in-place mutation is dropped on
// React/Solid/Lit/Angular change detectors — listbox precedent).
// Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
// neighbour, then write a FRESH array (in-place mutation is dropped on
// React/Solid/Lit/Angular change detectors — listbox precedent).
const commitRange = (which: any, raw: any) => {
  const pair = rangePair();
  let lo = pair[0];
  let hi = pair[1];
  const v = clampStep(raw);
  if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
  const next = [lo, hi];
  value.value = next;
  fireChange(next);
};

// ---- native input handlers ---------------------------------------------
// Single input. `valueAsNumber` is a number (never the string `.value`).
// ---- native input handlers ---------------------------------------------
// Single input. `valueAsNumber` is a number (never the string `.value`).
const onInputSingle = ($event: any) => commitSingle($event.target.valueAsNumber);
// Range inputs (lo / hi).
// Range inputs (lo / hi).
const onInputLo = ($event: any) => commitRange('lo', $event.target.valueAsNumber);
const onInputHi = ($event: any) => commitRange('hi', $event.target.valueAsNumber);

// ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
// Native PageUp/PageDown uses the browser's default large step, which may not
// equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
// quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
// ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
// Native PageUp/PageDown uses the browser's default large step, which may not
// equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
// quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
const effectivePageStep = () => {
  const ps = props.pageStep;
  if (Number.isFinite(ps) && ps > 0) return ps;
  const step = Number.isFinite(props.step) && props.step > 0 ? props.step : 1;
  return step * 10;
};
const onKeyDownSingle = ($event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
  commitSingle(singleValue() + delta);
};
const onKeyDownRange = (which: any, $event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
  const pair = rangePair();
  const base = which === 'lo' ? pair[0] : pair[1];
  commitRange(which, base + delta);
};

// ---- imperative handle (D-05) ------------------------------------------
// `focus` reads $refs in a post-mount callback (called via the handle) — safe,
// never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
// (ROZ137 warns; accepted — see header).
// ---- imperative handle (D-05) ------------------------------------------
// `focus` reads $refs in a post-mount callback (called via the handle) — safe,
// never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
// (ROZ137 warns; accepted — see header).
const focus = () => inputElRef.value?.focus();

// Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
// Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
const increment = (thumb: any) => {
  if (props.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base + props.step);
  } else {
    commitSingle(singleValue() + props.step);
  }
};
const decrement = (thumb: any) => {
  if (props.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base - props.step);
  } else {
    commitSingle(singleValue() - props.step);
  }
};

// Shorthand keys (aliased `{ focus: fn }` keys are dropped by the React emitter)
// — every function is named exactly as its verb. `focus` triggers the accepted
// ROZ137 warn.

defineExpose({ focus, increment, decrement });
</script>

<style scoped>
.rozie-slider {
  position: relative;
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--rozie-slider-thumb-size, 1rem);
  padding: var(--rozie-slider-pad, 0.5rem 0);
  font: var(--rozie-slider-font, inherit);
}
.rozie-slider-track {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  transform: translateY(-50%);
  height: var(--rozie-slider-track-height, 0.375rem);
  border-radius: var(--rozie-slider-track-radius, 999px);
  background: var(--rozie-slider-track-bg, rgba(0, 0, 0, 0.18));
  pointer-events: none;
}
.rozie-slider-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--rozie-slider-fill-start, 0%);
  right: calc(100% - var(--rozie-slider-fill-end, 0%));
  border-radius: inherit;
  background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, #0066cc));
}
.rozie-slider-input {
  -webkit-appearance: none;
  appearance: none;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
  width: 100%;
  height: var(--rozie-slider-thumb-size, 1rem);
  margin: 0;
  background: none;
  pointer-events: none;
  cursor: pointer;
  accent-color: var(--rozie-slider-accent, #0066cc);
}
.rozie-slider-input:focus { outline: none; z-index: 2; }
.rozie-slider--range .rozie-slider-input { pointer-events: none; }
.rozie-slider--disabled .rozie-slider-input { cursor: not-allowed; }
.rozie-slider--disabled { opacity: var(--rozie-slider-disabled-opacity, 0.55); }
.rozie-slider-input::-webkit-slider-runnable-track {
  background: none;
  height: var(--rozie-slider-track-height, 0.375rem);
}
.rozie-slider-input::-moz-range-track {
  background: none;
  height: var(--rozie-slider-track-height, 0.375rem);
}
.rozie-slider-input::-moz-range-progress {
  background: none;
}
.rozie-slider-input::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, 1rem);
  height: var(--rozie-slider-thumb-size, 1rem);
  border: var(--rozie-slider-thumb-border, 2px solid #fff);
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
  box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
  margin-top: var(--rozie-slider-thumb-offset, calc((0.375rem - 1rem) / 2));
  cursor: pointer;
}
.rozie-slider-input::-moz-range-thumb {
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, 1rem);
  height: var(--rozie-slider-thumb-size, 1rem);
  border: var(--rozie-slider-thumb-border, 2px solid #fff);
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
  box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
  cursor: pointer;
}
.rozie-slider--vertical {
  width: var(--rozie-slider-thickness, 2.5rem);
  height: var(--rozie-slider-length, 12rem);
  padding: 0;
}
.rozie-slider--vertical .rozie-slider-track,
.rozie-slider--vertical .rozie-slider-input {
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, 12rem);
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider--vertical .rozie-slider-fill {
  /* The fill still spans start→end along the (now rotated) input axis. */
}
.rozie-slider--vertical .rozie-slider-marks,
.rozie-slider--vertical .rozie-slider-bubbles {
  /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, 12rem);
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider-marks {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-mark {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  color: var(--rozie-slider-mark-color, rgba(0, 0, 0, 0.55));
}
.rozie-slider-mark-label {
  position: absolute;
  top: var(--rozie-slider-mark-offset, 0.75rem);
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--rozie-slider-mark-font-size, 0.6875rem);
  white-space: nowrap;
}
.rozie-slider-bubbles {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-bubble {
  position: absolute;
  top: var(--rozie-slider-bubble-offset, -1.25rem);
  transform: translateX(-50%);
}
.rozie-slider-bubble-text {
  display: inline-block;
  padding: var(--rozie-slider-bubble-padding, 0.0625rem 0.375rem);
  font-size: var(--rozie-slider-bubble-font-size, 0.6875rem);
  color: var(--rozie-slider-bubble-fg, #fff);
  background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, #0066cc));
  border-radius: var(--rozie-slider-bubble-radius, 4px);
  white-space: nowrap;
}
</style>
svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieDisplay, rozieStyle } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';

interface Props {
  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <Slider r-model:value="volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />
   */
  value?: (unknown) | null;
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  range?: boolean;
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  min?: number;
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  max?: number;
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  step?: number;
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  orientation?: string;
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  marks?: any[];
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  ariaLabel?: (string) | null;
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  pageStep?: (number) | null;
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  formatValue?: ((...args: any[]) => any) | null;
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  showValue?: boolean;
  mark?: Snippet<[{ value: any; label: any; position: any }]>;
  bubble?: Snippet<[{ value: any }]>;
  snippets?: Record<string, any>;
  onchange?: (...args: unknown[]) => void;
  [key: string]: unknown;
}

let __defaultMarks = (() => [])();

let {
  value = $bindable(null),
  range = false,
  min = 0,
  max = 100,
  step = 1,
  orientation = 'horizontal',
  disabled = false,
  marks = __defaultMarks,
  ariaLabel = null,
  pageStep = null,
  formatValue = null,
  showValue = false,
  mark: __markProp,
  bubble: __bubbleProp,
  snippets,
  onchange,
  ...__rozieAttrs
}: Props = $props();

const mark = $derived(__markProp ?? snippets?.mark);
const bubble = $derived(__bubbleProp ?? snippets?.bubble);

let inputEl = $state<HTMLInputElement | undefined>(undefined);

// ---- numeric helpers ---------------------------------------------------
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — it is called from both the fill $computed and the keyboard augment.
const pct = (v: any) => {
  const span = max - min;
  if (span === 0) return 0;
  const p = (v - min) / span * 100;
  if (p < 0) return 0;
  if (p > 100) return 100;
  return p;
};

// Clamp a raw number into [min,max] and quantize to `step` (guarding against a
// non-finite or zero step). Returns a finite number bounded by the scale.
// Clamp a raw number into [min,max] and quantize to `step` (guarding against a
// non-finite or zero step). Returns a finite number bounded by the scale.
const clampStep = (raw: any) => {
  if (!Number.isFinite(raw)) return min;
  let v = raw;
  if (v < min) v = min;
  if (v > max) v = max;
  const step$local = step;
  if (Number.isFinite(step$local) && step$local > 0) {
    const steps = Math.round((v - min) / step$local);
    v = min + steps * step$local;
    if (v < min) v = min;
    if (v > max) v = max;
  }
  return v;
};

// The current range pair, defaulting to the full span when `value` is not yet a
// 2-tuple. Read into a stable local before destructuring — `$props.value`
// lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
// The current range pair, defaulting to the full span when `value` is not yet a
// 2-tuple. Read into a stable local before destructuring — `$props.value`
// lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
const rangePair = () => {
  const cur = value;
  if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
  return [min, max];
};

// The single (scalar) value, defaulting to min when not yet a number.
// The single (scalar) value, defaulting to min when not yet a number.
const singleValue = () => {
  const cur = value;
  return typeof cur === 'number' && Number.isFinite(cur) ? cur : min;
};

// ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
// Read BARE in the template via :style="fillStyle". Returns the fill extent as a
// % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
// start/end vars drive the (rotated) fill — no separate vertical math.
// The marks list, normalised to { value, label } objects. A bare value[] entry
// becomes { value, label: String(value) }. A plain function (not $computed) so
// it reads uniformly and can be called in the r-for.
const normalizedMarks = () => {
  const list = Array.isArray(marks) ? marks : [];
  return list.map((m: any) => {
    if (m !== null && typeof m === 'object' && 'value' in m) {
      return {
        value: m.value,
        label: 'label' in m && m.label != null ? m.label : String(m.value)
      };
    }
    return {
      value: m,
      label: String(m)
    };
  });
};

// Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
// reads uniformly inside it.
// Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
// reads uniformly inside it.
const display = (v: any) => {
  if (formatValue !== null) return formatValue(v);
  return String(v);
};

// ---- write-back (single emit funnel) -----------------------------------
// The SOLE `$emit('change')` site, called from every commit path so the React
// prop-destructure for `onChange` hoists exactly once.
// ---- write-back (single emit funnel) -----------------------------------
// The SOLE `$emit('change')` site, called from every commit path so the React
// prop-destructure for `onChange` hoists exactly once.
const fireChange = (value: any) => onchange?.({
  value
});

// Single-mode commit: capture the fresh number, write the scalar, emit. Never
// re-read $data after the write (ROZ138: React setState is async).
// Single-mode commit: capture the fresh number, write the scalar, emit. Never
// re-read $data after the write (ROZ138: React setState is async).
const commitSingle = (raw: any) => {
  const v = clampStep(raw);
  value = v;
  fireChange(v);
};

// Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
// neighbour, then write a FRESH array (in-place mutation is dropped on
// React/Solid/Lit/Angular change detectors — listbox precedent).
// Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
// neighbour, then write a FRESH array (in-place mutation is dropped on
// React/Solid/Lit/Angular change detectors — listbox precedent).
const commitRange = (which: any, raw: any) => {
  const pair = rangePair();
  let lo = pair[0];
  let hi = pair[1];
  const v = clampStep(raw);
  if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
  const next = [lo, hi];
  value = next;
  fireChange(next);
};

// ---- native input handlers ---------------------------------------------
// Single input. `valueAsNumber` is a number (never the string `.value`).
// ---- native input handlers ---------------------------------------------
// Single input. `valueAsNumber` is a number (never the string `.value`).
const onInputSingle = ($event: any) => commitSingle($event.target.valueAsNumber);
// Range inputs (lo / hi).
// Range inputs (lo / hi).
const onInputLo = ($event: any) => commitRange('lo', $event.target.valueAsNumber);
const onInputHi = ($event: any) => commitRange('hi', $event.target.valueAsNumber);

// ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
// Native PageUp/PageDown uses the browser's default large step, which may not
// equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
// quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
// ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
// Native PageUp/PageDown uses the browser's default large step, which may not
// equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
// quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
const effectivePageStep = () => {
  const ps = pageStep;
  if (Number.isFinite(ps) && ps > 0) return ps;
  const step$local = Number.isFinite(step) && step > 0 ? step : 1;
  return step$local * 10;
};
const onKeyDownSingle = ($event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
  commitSingle(singleValue() + delta);
};
const onKeyDownRange = (which: any, $event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
  const pair = rangePair();
  const base = which === 'lo' ? pair[0] : pair[1];
  commitRange(which, base + delta);
};

// ---- imperative handle (D-05) ------------------------------------------
// `focus` reads $refs in a post-mount callback (called via the handle) — safe,
// never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
// (ROZ137 warns; accepted — see header).
// ---- imperative handle (D-05) ------------------------------------------
// `focus` reads $refs in a post-mount callback (called via the handle) — safe,
// never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
// (ROZ137 warns; accepted — see header).
export const focus = () => inputEl?.focus();

// Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
// Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
export const increment = (thumb: any) => {
  if (range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base + step);
  } else {
    commitSingle(singleValue() + step);
  }
};
export const decrement = (thumb: any) => {
  if (range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base - step);
  } else {
    commitSingle(singleValue() - step);
  }
};

// Shorthand keys (aliased `{ focus: fn }` keys are dropped by the React emitter)
// — every function is named exactly as its verb. `focus` triggers the accepted
// ROZ137 warn.

const fillStyle = $derived.by(() => {
  let start, end;
  if (range) {
    const arr = Array.isArray(value) && value.length === 2 ? value : [min, max];
    start = pct(arr[0]);
    end = pct(arr[1]);
  } else {
    start = 0;
    end = pct(typeof value === 'number' && Number.isFinite(value) ? value : min);
  }
  return {
    '--rozie-slider-fill-start': start + '%',
    '--rozie-slider-fill-end': end + '%'
  };
});
</script>

<div style={rozieStyle(fillStyle)} {...__rozieAttrs} class={["rozie-slider", { 'rozie-slider--vertical': orientation === 'vertical', 'rozie-slider--horizontal': orientation !== 'vertical', 'rozie-slider--range': range, 'rozie-slider--disabled': disabled }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-4e6f0be6><div class="rozie-slider-track" aria-hidden="true" data-rozie-s-4e6f0be6><div class="rozie-slider-fill" data-rozie-s-4e6f0be6></div></div>{#if normalizedMarks().length > 0}<div class="rozie-slider-marks" aria-hidden="true" data-rozie-s-4e6f0be6>{#each normalizedMarks() as tick (tick.value)}<div class="rozie-slider-mark" style:left={pct(tick.value) + '%'} data-rozie-s-4e6f0be6>{#if mark}{@render mark({ value: tick.value, label: tick.label, position: pct(tick.value) })}{:else}<span class="rozie-slider-mark-label" data-rozie-s-4e6f0be6>{rozieDisplay(tick.label)}</span>{/if}</div>{/each}</div>{/if}{#if showValue && !range}<div class="rozie-slider-bubbles" aria-hidden="true" data-rozie-s-4e6f0be6><div class="rozie-slider-bubble" style:left={'var(--rozie-slider-fill-end)'} data-rozie-s-4e6f0be6>{#if bubble}{@render bubble({ value: singleValue() })}{:else}<span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>{rozieDisplay(display(singleValue()))}</span>{/if}</div></div>{/if}{#if showValue && range}<div class="rozie-slider-bubbles" aria-hidden="true" data-rozie-s-4e6f0be6><div class="rozie-slider-bubble" style:left={'var(--rozie-slider-fill-start)'} data-rozie-s-4e6f0be6>{#if bubble}{@render bubble({ value: rangePair()[0] })}{:else}<span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>{rozieDisplay(display(rangePair()[0]))}</span>{/if}</div><div class="rozie-slider-bubble" style:left={'var(--rozie-slider-fill-end)'} data-rozie-s-4e6f0be6>{#if bubble}{@render bubble({ value: rangePair()[1] })}{:else}<span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>{rozieDisplay(display(rangePair()[1]))}</span>{/if}</div></div>{/if}{#if !range}<input bind:this={inputEl} class="rozie-slider-input" type="range" min={min} max={max} step={step} value={singleValue()} disabled={!!disabled} aria-label={ariaLabel} aria-orientation={rozieAttr(orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(formatValue !== null ? display(singleValue()) : null)} oninput={($event) => { onInputSingle($event); }} onkeydown={($event) => { onKeyDownSingle($event); }} data-rozie-s-4e6f0be6 />{/if}{#if range}<input bind:this={inputEl} class="rozie-slider-input rozie-slider-input--lo" type="range" min={min} max={max} step={step} value={rangePair()[0]} disabled={!!disabled} aria-label={ariaLabel} aria-orientation={rozieAttr(orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(formatValue !== null ? display(rangePair()[0]) : null)} oninput={($event) => { onInputLo($event); }} onkeydown={($event) => { onKeyDownRange('lo', $event); }} data-rozie-s-4e6f0be6 />{/if}{#if range}<input class="rozie-slider-input rozie-slider-input--hi" type="range" min={min} max={max} step={step} value={rangePair()[1]} disabled={!!disabled} aria-label={ariaLabel} aria-orientation={rozieAttr(orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(formatValue !== null ? display(rangePair()[1]) : null)} oninput={($event) => { onInputHi($event); }} onkeydown={($event) => { onKeyDownRange('hi', $event); }} data-rozie-s-4e6f0be6 />{/if}</div>

<style>
:global {
  .rozie-slider[data-rozie-s-4e6f0be6] {
    position: relative;
    display: block;
    box-sizing: border-box;
    width: 100%;
    min-height: var(--rozie-slider-thumb-size, 1rem);
    padding: var(--rozie-slider-pad, 0.5rem 0);
    font: var(--rozie-slider-font, inherit);
  }
  .rozie-slider-track[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    transform: translateY(-50%);
    height: var(--rozie-slider-track-height, 0.375rem);
    border-radius: var(--rozie-slider-track-radius, 999px);
    background: var(--rozie-slider-track-bg, rgba(0, 0, 0, 0.18));
    pointer-events: none;
  }
  .rozie-slider-fill[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 0;
    bottom: 0;
    left: var(--rozie-slider-fill-start, 0%);
    right: calc(100% - var(--rozie-slider-fill-end, 0%));
    border-radius: inherit;
    background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, #0066cc));
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6] {
    -webkit-appearance: none;
    appearance: none;
    position: absolute;
    top: 50%;
    left: 0;
    transform: translateY(-50%);
    width: 100%;
    height: var(--rozie-slider-thumb-size, 1rem);
    margin: 0;
    background: none;
    pointer-events: none;
    cursor: pointer;
    accent-color: var(--rozie-slider-accent, #0066cc);
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]:focus { outline: none; z-index: 2; }
  .rozie-slider--range[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { pointer-events: none; }
  .rozie-slider--disabled[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { cursor: not-allowed; }
  .rozie-slider--disabled[data-rozie-s-4e6f0be6] { opacity: var(--rozie-slider-disabled-opacity, 0.55); }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-runnable-track {
    background: none;
    height: var(--rozie-slider-track-height, 0.375rem);
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-track {
    background: none;
    height: var(--rozie-slider-track-height, 0.375rem);
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-progress {
    background: none;
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    pointer-events: auto;
    width: var(--rozie-slider-thumb-size, 1rem);
    height: var(--rozie-slider-thumb-size, 1rem);
    border: var(--rozie-slider-thumb-border, 2px solid #fff);
    border-radius: 50%;
    background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
    box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
    margin-top: var(--rozie-slider-thumb-offset, calc((0.375rem - 1rem) / 2));
    cursor: pointer;
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-thumb {
    pointer-events: auto;
    width: var(--rozie-slider-thumb-size, 1rem);
    height: var(--rozie-slider-thumb-size, 1rem);
    border: var(--rozie-slider-thumb-border, 2px solid #fff);
    border-radius: 50%;
    background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
    box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
    cursor: pointer;
  }
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] {
    width: var(--rozie-slider-thickness, 2.5rem);
    height: var(--rozie-slider-length, 12rem);
    padding: 0;
  }
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-track[data-rozie-s-4e6f0be6],
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] {
    top: 50%;
    left: 50%;
    width: var(--rozie-slider-length, 12rem);
    transform: translate(-50%, -50%) rotate(-90deg);
    transform-origin: center center;
  }
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-fill[data-rozie-s-4e6f0be6] {
    /* The fill still spans start→end along the (now rotated) input axis. */
  }
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-marks[data-rozie-s-4e6f0be6],
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
    /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
    top: 50%;
    left: 50%;
    width: var(--rozie-slider-length, 12rem);
    transform: translate(-50%, -50%) rotate(-90deg);
    transform-origin: center center;
  }
  .rozie-slider-marks[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    height: 0;
    pointer-events: none;
  }
  .rozie-slider-mark[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 0;
    transform: translateX(-50%);
    color: var(--rozie-slider-mark-color, rgba(0, 0, 0, 0.55));
  }
  .rozie-slider-mark-label[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: var(--rozie-slider-mark-offset, 0.75rem);
    left: 50%;
    transform: translateX(-50%);
    font-size: var(--rozie-slider-mark-font-size, 0.6875rem);
    white-space: nowrap;
  }
  .rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 0;
    pointer-events: none;
  }
  .rozie-slider-bubble[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: var(--rozie-slider-bubble-offset, -1.25rem);
    transform: translateX(-50%);
  }
  .rozie-slider-bubble-text[data-rozie-s-4e6f0be6] {
    display: inline-block;
    padding: var(--rozie-slider-bubble-padding, 0.0625rem 0.375rem);
    font-size: var(--rozie-slider-bubble-font-size, 0.6875rem);
    color: var(--rozie-slider-bubble-fg, #fff);
    background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, #0066cc));
    border-radius: var(--rozie-slider-bubble-radius, 4px);
    white-space: nowrap;
  }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, effect, forwardRef, inject, input, model, output, signal, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

interface MarkCtx {
  $implicit: { value: any; label: any; position: any };
  value: any;
  label: any;
  position: any;
}

interface BubbleCtx {
  $implicit: { value: any };
  value: any;
}

function __rozieDisplay(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'object') {
    try {
      return JSON.stringify(v, null, 2);
    } catch {
      // Circular structure or a non-serialisable value (BigInt nested in an
      // object). Degrade to a non-throwing form so the wrap never crashes the
      // render — that is the entire point of "safe" interpolation (SPEC-1).
      return String(v);
    }
  }
  return String(v);
}

function __rozieAttr(v: unknown): string | null {
  return v == null ? null : __rozieDisplay(v);
}

@Component({
  selector: 'rozie-slider',
  standalone: true,
  imports: [NgTemplateOutlet, NgClass],
  template: `

    <div class="rozie-slider" [ngClass]="{ 'rozie-slider--vertical': orientation() === 'vertical', 'rozie-slider--horizontal': orientation() !== 'vertical', 'rozie-slider--range': range(), 'rozie-slider--disabled': (disabled() || this.__rozieCvaDisabled()) }" [style]="fillStyle()" #rozieSpread_0 #rozieListenersTarget_1>
      
      <div class="rozie-slider-track" aria-hidden="true">
        <div class="rozie-slider-fill"></div>
      </div>

      
      @if (normalizedMarks().length > 0) {
    <div class="rozie-slider-marks" aria-hidden="true">
        
        @for (tick of normalizedMarks(); track tick.value) {
    <div class="rozie-slider-mark" [style]="{ left: pct(tick.value) + '%' }">
          @if ((markTpl ?? templates()?.['mark'])) {
    <ng-container *ngTemplateOutlet="(markTpl ?? templates()?.['mark']); context: { $implicit: { value: tick.value, label: tick.label, position: pct(tick.value) }, value: tick.value, label: tick.label, position: pct(tick.value) }" />
    } @else {

            <span class="rozie-slider-mark-label">{{ rozieDisplay(tick.label) }}</span>
          
    }
        </div>
    }
      </div>
    }@if (showValue() && !range()) {
    <div class="rozie-slider-bubbles" aria-hidden="true">
        <div class="rozie-slider-bubble" [style]="{ left: 'var(--rozie-slider-fill-end)' }">
          @if ((bubbleTpl ?? templates()?.['bubble'])) {
    <ng-container *ngTemplateOutlet="(bubbleTpl ?? templates()?.['bubble']); context: { $implicit: { value: singleValue() }, value: singleValue() }" />
    } @else {

            <span class="rozie-slider-bubble-text">{{ rozieDisplay(display(singleValue())) }}</span>
          
    }
        </div>
      </div>
    }@if (showValue() && range()) {
    <div class="rozie-slider-bubbles" aria-hidden="true">
        <div class="rozie-slider-bubble" [style]="{ left: 'var(--rozie-slider-fill-start)' }">
          @if ((bubbleTpl ?? templates()?.['bubble'])) {
    <ng-container *ngTemplateOutlet="(bubbleTpl ?? templates()?.['bubble']); context: { $implicit: { value: rangePair()[0] }, value: rangePair()[0] }" />
    } @else {

            <span class="rozie-slider-bubble-text">{{ rozieDisplay(display(rangePair()[0])) }}</span>
          
    }
        </div>
        <div class="rozie-slider-bubble" [style]="{ left: 'var(--rozie-slider-fill-end)' }">
          @if ((bubbleTpl ?? templates()?.['bubble'])) {
    <ng-container *ngTemplateOutlet="(bubbleTpl ?? templates()?.['bubble']); context: { $implicit: { value: rangePair()[1] }, value: rangePair()[1] }" />
    } @else {

            <span class="rozie-slider-bubble-text">{{ rozieDisplay(display(rangePair()[1])) }}</span>
          
    }
        </div>
      </div>
    }@if (!range()) {
    <input #inputEl class="rozie-slider-input" type="range" [min]="min()" [max]="max()" [step]="step()" [value]="singleValue()" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-label]="ariaLabel()" [attr.aria-orientation]="rozieAttr(orientation() === 'vertical' ? 'vertical' : 'horizontal')" [attr.aria-valuetext]="rozieAttr(formatValue() !== null ? display(singleValue()) : null)" (input)="onInputSingle($event)" (keydown)="onKeyDownSingle($event)" />
    }@if (range()) {
    <input #inputEl class="rozie-slider-input rozie-slider-input--lo" type="range" [min]="min()" [max]="max()" [step]="step()" [value]="rangePair()[0]" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-label]="ariaLabel()" [attr.aria-orientation]="rozieAttr(orientation() === 'vertical' ? 'vertical' : 'horizontal')" [attr.aria-valuetext]="rozieAttr(formatValue() !== null ? display(rangePair()[0]) : null)" (input)="onInputLo($event)" (keydown)="onKeyDownRange('lo', $event)" />
    }@if (range()) {
    <input class="rozie-slider-input rozie-slider-input--hi" type="range" [min]="min()" [max]="max()" [step]="step()" [value]="rangePair()[1]" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-label]="ariaLabel()" [attr.aria-orientation]="rozieAttr(orientation() === 'vertical' ? 'vertical' : 'horizontal')" [attr.aria-valuetext]="rozieAttr(formatValue() !== null ? display(rangePair()[1]) : null)" (input)="onInputHi($event)" (keydown)="onKeyDownRange('hi', $event)" />
    }</div>

  `,
  styles: [`
    .rozie-slider {
      position: relative;
      display: block;
      box-sizing: border-box;
      width: 100%;
      min-height: var(--rozie-slider-thumb-size, 1rem);
      padding: var(--rozie-slider-pad, 0.5rem 0);
      font: var(--rozie-slider-font, inherit);
    }
    .rozie-slider-track {
      position: absolute;
      top: 50%;
      left: 0;
      right: 0;
      transform: translateY(-50%);
      height: var(--rozie-slider-track-height, 0.375rem);
      border-radius: var(--rozie-slider-track-radius, 999px);
      background: var(--rozie-slider-track-bg, rgba(0, 0, 0, 0.18));
      pointer-events: none;
    }
    .rozie-slider-fill {
      position: absolute;
      top: 0;
      bottom: 0;
      left: var(--rozie-slider-fill-start, 0%);
      right: calc(100% - var(--rozie-slider-fill-end, 0%));
      border-radius: inherit;
      background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, #0066cc));
    }
    .rozie-slider-input {
      -webkit-appearance: none;
      appearance: none;
      position: absolute;
      top: 50%;
      left: 0;
      transform: translateY(-50%);
      width: 100%;
      height: var(--rozie-slider-thumb-size, 1rem);
      margin: 0;
      background: none;
      pointer-events: none;
      cursor: pointer;
      accent-color: var(--rozie-slider-accent, #0066cc);
    }
    .rozie-slider-input:focus { outline: none; z-index: 2; }
    .rozie-slider--range .rozie-slider-input { pointer-events: none; }
    .rozie-slider--disabled .rozie-slider-input { cursor: not-allowed; }
    .rozie-slider--disabled { opacity: var(--rozie-slider-disabled-opacity, 0.55); }
    .rozie-slider-input::-webkit-slider-runnable-track {
      background: none;
      height: var(--rozie-slider-track-height, 0.375rem);
    }
    .rozie-slider-input::-moz-range-track {
      background: none;
      height: var(--rozie-slider-track-height, 0.375rem);
    }
    .rozie-slider-input::-moz-range-progress {
      background: none;
    }
    .rozie-slider-input::-webkit-slider-thumb {
      -webkit-appearance: none;
      appearance: none;
      pointer-events: auto;
      width: var(--rozie-slider-thumb-size, 1rem);
      height: var(--rozie-slider-thumb-size, 1rem);
      border: var(--rozie-slider-thumb-border, 2px solid #fff);
      border-radius: 50%;
      background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
      box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
      margin-top: var(--rozie-slider-thumb-offset, calc((0.375rem - 1rem) / 2));
      cursor: pointer;
    }
    .rozie-slider-input::-moz-range-thumb {
      pointer-events: auto;
      width: var(--rozie-slider-thumb-size, 1rem);
      height: var(--rozie-slider-thumb-size, 1rem);
      border: var(--rozie-slider-thumb-border, 2px solid #fff);
      border-radius: 50%;
      background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
      box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
      cursor: pointer;
    }
    .rozie-slider--vertical {
      width: var(--rozie-slider-thickness, 2.5rem);
      height: var(--rozie-slider-length, 12rem);
      padding: 0;
    }
    .rozie-slider--vertical .rozie-slider-track,
    .rozie-slider--vertical .rozie-slider-input {
      top: 50%;
      left: 50%;
      width: var(--rozie-slider-length, 12rem);
      transform: translate(-50%, -50%) rotate(-90deg);
      transform-origin: center center;
    }
    .rozie-slider--vertical .rozie-slider-fill {
      /* The fill still spans start→end along the (now rotated) input axis. */
    }
    .rozie-slider--vertical .rozie-slider-marks,
    .rozie-slider--vertical .rozie-slider-bubbles {
      /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
      top: 50%;
      left: 50%;
      width: var(--rozie-slider-length, 12rem);
      transform: translate(-50%, -50%) rotate(-90deg);
      transform-origin: center center;
    }
    .rozie-slider-marks {
      position: absolute;
      top: 50%;
      left: 0;
      right: 0;
      height: 0;
      pointer-events: none;
    }
    .rozie-slider-mark {
      position: absolute;
      top: 0;
      transform: translateX(-50%);
      color: var(--rozie-slider-mark-color, rgba(0, 0, 0, 0.55));
    }
    .rozie-slider-mark-label {
      position: absolute;
      top: var(--rozie-slider-mark-offset, 0.75rem);
      left: 50%;
      transform: translateX(-50%);
      font-size: var(--rozie-slider-mark-font-size, 0.6875rem);
      white-space: nowrap;
    }
    .rozie-slider-bubbles {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      height: 0;
      pointer-events: none;
    }
    .rozie-slider-bubble {
      position: absolute;
      top: var(--rozie-slider-bubble-offset, -1.25rem);
      transform: translateX(-50%);
    }
    .rozie-slider-bubble-text {
      display: inline-block;
      padding: var(--rozie-slider-bubble-padding, 0.0625rem 0.375rem);
      font-size: var(--rozie-slider-bubble-font-size, 0.6875rem);
      color: var(--rozie-slider-bubble-fg, #fff);
      background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, #0066cc));
      border-radius: var(--rozie-slider-bubble-radius, 4px);
      white-space: nowrap;
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => Slider),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Slider {
  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <Slider r-model:value="volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />
   */
  value = model<(unknown) | null>(null);
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  range = input<boolean>(false);
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  min = input<number>(0);
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  max = input<number>(100);
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  step = input<number>(1);
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  orientation = input<string>('horizontal');
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled = input<boolean>(false);
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  marks = input<any[]>((() => [])());
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  ariaLabel = input<(string) | null>(null);
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  pageStep = input<(number) | null>(null);
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  formatValue = input<((...args: unknown[]) => unknown) | null>(null);
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  showValue = input<boolean>(false);
  inputEl = viewChild<ElementRef<HTMLInputElement>>('inputEl');
  change = output<unknown>();
  @ContentChild('mark', { read: TemplateRef }) markTpl?: TemplateRef<MarkCtx>;
  @ContentChild('bubble', { read: TemplateRef }) bubbleTpl?: TemplateRef<BubbleCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);

  fillStyle = computed(() => {
    const __value = this.value();
    const __min = this.min();
    let start, end;
    if (this.range()) {
      const arr = Array.isArray(__value) && __value.length === 2 ? __value : [__min, this.max()];
      start = this.pct(arr[0]);
      end = this.pct(arr[1]);
    } else {
      start = 0;
      end = this.pct(typeof __value === 'number' && Number.isFinite(__value) ? __value : __min);
    }
    return {
      '--rozie-slider-fill-start': start + '%',
      '--rozie-slider-fill-end': end + '%'
    };
  });

  pct = (v: any) => {
    const __min = this.min();
    const span = this.max() - __min;
    if (span === 0) return 0;
    const p = (v - __min) / span * 100;
    if (p < 0) return 0;
    if (p > 100) return 100;
    return p;
  };
  clampStep = (raw: any) => {
    const __min = this.min();
    const __max = this.max();
    if (!Number.isFinite(raw)) return __min;
    let v = raw;
    if (v < __min) v = __min;
    if (v > __max) v = __max;
    const step = this.step();
    if (Number.isFinite(step) && step > 0) {
      const steps = Math.round((v - __min) / step);
      v = __min + steps * step;
      if (v < __min) v = __min;
      if (v > __max) v = __max;
    }
    return v;
  };
  rangePair = () => {
    const cur = this.value();
    if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
    return [this.min(), this.max()];
  };
  singleValue = () => {
    const cur = this.value();
    return typeof cur === 'number' && Number.isFinite(cur) ? cur : this.min();
  };
  normalizedMarks = () => {
    const __marks = this.marks();
    const list = Array.isArray(__marks) ? __marks : [];
    return list.map((m: any) => {
      if (m !== null && typeof m === 'object' && 'value' in m) {
        return {
          value: m.value,
          label: 'label' in m && m.label != null ? m.label : String(m.value)
        };
      }
      return {
        value: m,
        label: String(m)
      };
    });
  };
  display = (v: any) => {
    const __formatValue = this.formatValue();
    if (__formatValue !== null) return __formatValue(v);
    return String(v);
  };
  fireChange = (value: any) => this.change.emit({
    value: this.value()
  });
  commitSingle = (raw: any) => {
    const v = this.clampStep(raw);
    this.value.set(v), this.__rozieCvaOnChange(v);
    this.fireChange(v);
  };
  commitRange = (which: any, raw: any) => {
    const pair = this.rangePair();
    let lo = pair[0];
    let hi = pair[1];
    const v = this.clampStep(raw);
    if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
    const next = [lo, hi];
    this.value.set(next), this.__rozieCvaOnChange(next);
    this.fireChange(next);
  };
  onInputSingle = ($event: any) => this.commitSingle($event.target.valueAsNumber);
  onInputLo = ($event: any) => this.commitRange('lo', $event.target.valueAsNumber);
  onInputHi = ($event: any) => this.commitRange('hi', $event.target.valueAsNumber);
  effectivePageStep = () => {
    const __step = this.step();
    const ps = this.pageStep();
    if (Number.isFinite(ps) && ps > 0) return ps;
    const step = Number.isFinite(__step) && __step > 0 ? __step : 1;
    return step * 10;
  };
  onKeyDownSingle = ($event: any) => {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? this.effectivePageStep() : -this.effectivePageStep();
    this.commitSingle(this.singleValue() + delta);
  };
  onKeyDownRange = (which: any, $event: any) => {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? this.effectivePageStep() : -this.effectivePageStep();
    const pair = this.rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    this.commitRange(which, base + delta);
  };
  focus = () => this.inputEl()?.nativeElement?.focus();
  increment = (thumb: any) => {
    const __step = this.step();
    if (this.range()) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = this.rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      this.commitRange(which, base + __step);
    } else {
      this.commitSingle(this.singleValue() + __step);
    }
  };
  decrement = (thumb: any) => {
    const __step = this.step();
    if (this.range()) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = this.rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      this.commitRange(which, base - __step);
    } else {
      this.commitSingle(this.singleValue() - __step);
    }
  };

  private __rozieCvaOnChange: (v: unknown) => void = () => {};
  private __rozieCvaOnTouchedFn: () => void = () => {};
  protected __rozieCvaDisabled = signal(false);

  writeValue(v: unknown | null): void {
    this.value.set(v ?? null);
  }
  registerOnChange(fn: (v: unknown) => void): void {
    this.__rozieCvaOnChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.__rozieCvaOnTouchedFn = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.__rozieCvaDisabled.set(isDisabled);
  }
  __rozieCvaOnTouched(): void {
    this.__rozieCvaOnTouchedFn();
  }

  static ngTemplateContextGuard(
    _dir: Slider,
    _ctx: unknown,
  ): _ctx is MarkCtx | BubbleCtx {
    return true;
  }

  private __rozieDestroyRef = inject(DestroyRef);

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = (() => {
    const renderer = inject(Renderer2);
    const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
    const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
    const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
    const parseClassTokens = (value: unknown): string[] => {
      if (typeof value !== 'string') return [];
      const out: string[] = [];
      for (const tok of value.split(/\s+/)) {
        if (tok.length > 0) out.push(tok);
      }
      return out;
    };
    const parseStyleDecls = (value: unknown): Array<[string, string]> => {
      if (typeof value !== 'string') return [];
      const out: Array<[string, string]> = [];
      for (const decl of value.split(';')) {
        const colon = decl.indexOf(':');
        if (colon < 0) continue;
        const prop = decl.slice(0, colon).trim();
        const val = decl.slice(colon + 1).trim();
        if (prop.length > 0) out.push([prop, val]);
      }
      return out;
    };
    const applyClassMerge = (el: HTMLElement, value: unknown) => {
      const next = parseClassTokens(value);
      const prev = prevClassTokensByElement.get(el) ?? [];
      const nextSet = new Set(next);
      for (const tok of prev) {
        if (!nextSet.has(tok)) el.classList.remove(tok);
      }
      for (const tok of next) el.classList.add(tok);
      prevClassTokensByElement.set(el, next);
    };
    const applyStyleMerge = (el: HTMLElement, value: unknown) => {
      const next = parseStyleDecls(value);
      const prev = prevStylePropsByElement.get(el) ?? [];
      const nextProps = next.map(([p]) => p);
      const nextSet = new Set(nextProps);
      for (const prop of prev) {
        if (!nextSet.has(prop)) el.style.removeProperty(prop);
      }
      for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
      prevStylePropsByElement.set(el, nextProps);
    };
    return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
      const safeObj: Record<string, unknown> = obj ?? {};
      const prevKeys = prevKeysByElement.get(el) ?? [];
      for (const k of prevKeys) {
        if (k === 'class' || k === 'style') continue;
        if (!(k in safeObj)) renderer.removeAttribute(el, k);
      }
      if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
        applyClassMerge(el, '');
      }
      if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
        applyStyleMerge(el, '');
      }
      for (const [k, v] of Object.entries(safeObj)) {
        if (k === 'class') {
          applyClassMerge(el, v);
        } else if (k === 'style') {
          applyStyleMerge(el, v);
        } else if (v === null || v === false) {
          renderer.removeAttribute(el, k);
        } else {
          renderer.setAttribute(el, k, String(v));
        }
      }
      prevKeysByElement.set(el, Object.keys(safeObj));
    };
  })();

  private __rozieGetHostAttrs = (() => {
    const host = inject(ElementRef);
    return () => {
      const el = host.nativeElement as HTMLElement;
      const out: Record<string, unknown> = {};
      for (const a of Array.from(el.attributes)) out[a.name] = a.value;
      return out;
    };
  })();

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default Slider;
tsx
import type { JSX } from 'solid-js';
import { For, Show, createMemo, mergeProps, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';

__rozieInjectStyle('Slider-4e6f0be6', `.rozie-slider[data-rozie-s-4e6f0be6] {
  position: relative;
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--rozie-slider-thumb-size, 1rem);
  padding: var(--rozie-slider-pad, 0.5rem 0);
  font: var(--rozie-slider-font, inherit);
}
.rozie-slider-track[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  transform: translateY(-50%);
  height: var(--rozie-slider-track-height, 0.375rem);
  border-radius: var(--rozie-slider-track-radius, 999px);
  background: var(--rozie-slider-track-bg, rgba(0, 0, 0, 0.18));
  pointer-events: none;
}
.rozie-slider-fill[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--rozie-slider-fill-start, 0%);
  right: calc(100% - var(--rozie-slider-fill-end, 0%));
  border-radius: inherit;
  background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, #0066cc));
}
.rozie-slider-input[data-rozie-s-4e6f0be6] {
  -webkit-appearance: none;
  appearance: none;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
  width: 100%;
  height: var(--rozie-slider-thumb-size, 1rem);
  margin: 0;
  background: none;
  pointer-events: none;
  cursor: pointer;
  accent-color: var(--rozie-slider-accent, #0066cc);
}
.rozie-slider-input[data-rozie-s-4e6f0be6]:focus { outline: none; z-index: 2; }
.rozie-slider--range[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { pointer-events: none; }
.rozie-slider--disabled[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { cursor: not-allowed; }
.rozie-slider--disabled[data-rozie-s-4e6f0be6] { opacity: var(--rozie-slider-disabled-opacity, 0.55); }
.rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-runnable-track {
  background: none;
  height: var(--rozie-slider-track-height, 0.375rem);
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-track {
  background: none;
  height: var(--rozie-slider-track-height, 0.375rem);
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-progress {
  background: none;
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, 1rem);
  height: var(--rozie-slider-thumb-size, 1rem);
  border: var(--rozie-slider-thumb-border, 2px solid #fff);
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
  box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
  margin-top: var(--rozie-slider-thumb-offset, calc((0.375rem - 1rem) / 2));
  cursor: pointer;
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-thumb {
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, 1rem);
  height: var(--rozie-slider-thumb-size, 1rem);
  border: var(--rozie-slider-thumb-border, 2px solid #fff);
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
  box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
  cursor: pointer;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] {
  width: var(--rozie-slider-thickness, 2.5rem);
  height: var(--rozie-slider-length, 12rem);
  padding: 0;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-track[data-rozie-s-4e6f0be6],
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] {
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, 12rem);
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-fill[data-rozie-s-4e6f0be6] {
  /* The fill still spans start→end along the (now rotated) input axis. */
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-marks[data-rozie-s-4e6f0be6],
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
  /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, 12rem);
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider-marks[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-mark[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  color: var(--rozie-slider-mark-color, rgba(0, 0, 0, 0.55));
}
.rozie-slider-mark-label[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: var(--rozie-slider-mark-offset, 0.75rem);
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--rozie-slider-mark-font-size, 0.6875rem);
  white-space: nowrap;
}
.rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-bubble[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: var(--rozie-slider-bubble-offset, -1.25rem);
  transform: translateX(-50%);
}
.rozie-slider-bubble-text[data-rozie-s-4e6f0be6] {
  display: inline-block;
  padding: var(--rozie-slider-bubble-padding, 0.0625rem 0.375rem);
  font-size: var(--rozie-slider-bubble-font-size, 0.6875rem);
  color: var(--rozie-slider-bubble-fg, #fff);
  background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, #0066cc));
  border-radius: var(--rozie-slider-bubble-radius, 4px);
  white-space: nowrap;
}`);

interface MarkSlotCtx { value: any; label: any; position: any; }

interface BubbleSlotCtx { value: any; }

interface SliderProps {
  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <Slider r-model:value="volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />
   */
  value?: (unknown) | null;
  defaultValue?: (unknown) | null;
  onValueChange?: (value: (unknown) | null) => void;
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  range?: boolean;
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  min?: number;
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  max?: number;
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  step?: number;
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  orientation?: string;
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  marks?: any[];
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  ariaLabel?: (string) | null;
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  pageStep?: (number) | null;
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  formatValue?: ((...args: unknown[]) => unknown) | null;
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  showValue?: boolean;
  onChange?: (...args: unknown[]) => void;
  markSlot?: (ctx: MarkSlotCtx) => JSX.Element;
  bubbleSlot?: (ctx: BubbleSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: SliderHandle) => void;
}

export interface SliderHandle {
  focus: (...args: any[]) => any;
  increment: (...args: any[]) => any;
  decrement: (...args: any[]) => any;
}

export default function Slider(_props: SliderProps): JSX.Element {
  const _merged = mergeProps({ range: false, min: 0, max: 100, step: 1, orientation: 'horizontal', disabled: false, marks: (() => [])(), ariaLabel: null, pageStep: null, formatValue: null, showValue: false }, _props);
  const [local, attrs] = splitProps(_merged, ['value', 'range', 'min', 'max', 'step', 'orientation', 'disabled', 'marks', 'ariaLabel', 'pageStep', 'formatValue', 'showValue', 'ref']);
  onMount(() => { local.ref?.({ focus, increment, decrement }); });

  const [value, setValue] = createControllableSignal<unknown>(_props as unknown as Record<string, unknown>, 'value', null);
  const fillStyle = createMemo(() => {
    let start, end;
    if (local.range) {
      const arr = Array.isArray(value()) && (value() as any).length === 2 ? value() : [local.min, local.max];
      start = pct(arr[0]);
      end = pct(arr[1]);
    } else {
      start = 0;
      end = pct(typeof value() === 'number' && Number.isFinite(value()) ? value() : local.min);
    }
    return {
      '--rozie-slider-fill-start': start + '%',
      '--rozie-slider-fill-end': end + '%'
    };
  });
  let inputElRef: HTMLElement | null = null;

  // ---- numeric helpers ---------------------------------------------------
  // A plain function (not `$computed`) so it reads uniformly across all six
  // targets — it is called from both the fill $computed and the keyboard augment.
  function pct(v: any) {
    const span = local.max - local.min;
    if (span === 0) return 0;
    const p = (v - local.min) / span * 100;
    if (p < 0) return 0;
    if (p > 100) return 100;
    return p;
  }

  // Clamp a raw number into [min,max] and quantize to `step` (guarding against a
  // non-finite or zero step). Returns a finite number bounded by the scale.
  function clampStep(raw: any) {
    if (!Number.isFinite(raw)) return local.min;
    let v = raw;
    if (v < local.min) v = local.min;
    if (v > local.max) v = local.max;
    const step$local = local.step;
    if (Number.isFinite(step$local) && step$local > 0) {
      const steps = Math.round((v - local.min) / step$local);
      v = local.min + steps * step$local;
      if (v < local.min) v = local.min;
      if (v > local.max) v = local.max;
    }
    return v;
  }

  // The current range pair, defaulting to the full span when `value` is not yet a
  // 2-tuple. Read into a stable local before destructuring — `$props.value`
  // lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
  function rangePair() {
    const cur = value();
    if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
    return [local.min, local.max];
  }

  // The single (scalar) value, defaulting to min when not yet a number.
  function singleValue() {
    const cur = value();
    return typeof cur === 'number' && Number.isFinite(cur) ? cur : local.min;
  }

  // ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
  // Read BARE in the template via :style="fillStyle". Returns the fill extent as a
  // % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
  // start/end vars drive the (rotated) fill — no separate vertical math.

  // The marks list, normalised to { value, label } objects. A bare value[] entry
  // becomes { value, label: String(value) }. A plain function (not $computed) so
  // it reads uniformly and can be called in the r-for.
  function normalizedMarks() {
    const list = Array.isArray(local.marks) ? local.marks : [];
    return list.map((m: any) => {
      if (m !== null && typeof m === 'object' && 'value' in m) {
        return {
          value: m.value,
          label: 'label' in m && m.label != null ? m.label : String(m.value)
        };
      }
      return {
        value: m,
        label: String(m)
      };
    });
  }

  // Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
  // reads uniformly inside it.
  function display(v: any) {
    if (local.formatValue !== null) return local.formatValue(v);
    return String(v);
  }

  // ---- write-back (single emit funnel) -----------------------------------
  // The SOLE `$emit('change')` site, called from every commit path so the React
  // prop-destructure for `onChange` hoists exactly once.
  function fireChange(value: any) {
    return _props.onChange?.({
      value
    });
  }

  // Single-mode commit: capture the fresh number, write the scalar, emit. Never
  // re-read $data after the write (ROZ138: React setState is async).
  function commitSingle(raw: any) {
    const v = clampStep(raw);
    setValue(v);
    fireChange(v);
  }

  // Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
  // neighbour, then write a FRESH array (in-place mutation is dropped on
  // React/Solid/Lit/Angular change detectors — listbox precedent).
  function commitRange(which: any, raw: any) {
    const pair = rangePair();
    let lo = pair[0];
    let hi = pair[1];
    const v = clampStep(raw);
    if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
    const next = [lo, hi];
    setValue(next);
    fireChange(next);
  }

  // ---- native input handlers ---------------------------------------------
  // Single input. `valueAsNumber` is a number (never the string `.value`).
  function onInputSingle($event: any) {
    return commitSingle($event.target.valueAsNumber);
  }
  // Range inputs (lo / hi).
  function onInputLo($event: any) {
    return commitRange('lo', $event.target.valueAsNumber);
  }
  function onInputHi($event: any) {
    return commitRange('hi', $event.target.valueAsNumber);
  }

  // ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
  // Native PageUp/PageDown uses the browser's default large step, which may not
  // equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
  // quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
  function effectivePageStep() {
    const ps = local.pageStep;
    if (Number.isFinite(ps) && ps > 0) return ps;
    const step$local = Number.isFinite(local.step) && local.step > 0 ? local.step : 1;
    return step$local * 10;
  }
  function onKeyDownSingle($event: any) {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
    commitSingle(singleValue() + delta);
  }
  function onKeyDownRange(which: any, $event: any) {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base + delta);
  }

  // ---- imperative handle (D-05) ------------------------------------------
  // `focus` reads $refs in a post-mount callback (called via the handle) — safe,
  // never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
  // (ROZ137 warns; accepted — see header).
  function focus() {
    return inputElRef?.focus();
  }

  // Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
  function increment(thumb: any) {
    if (local.range) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      commitRange(which, base + local.step);
    } else {
      commitSingle(singleValue() + local.step);
    }
  }
  function decrement(thumb: any) {
    if (local.range) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      commitRange(which, base - local.step);
    } else {
      commitSingle(singleValue() - local.step);
    }
  }

  // Shorthand keys (aliased `{ focus: fn }` keys are dropped by the React emitter)
  // — every function is named exactly as its verb. `focus` triggers the accepted
  // ROZ137 warn.

  return (
    <>
    <div style={parseInlineStyle(fillStyle())} {...attrs} class={"rozie-slider" + " " + rozieClass({ 'rozie-slider--vertical': local.orientation === 'vertical', 'rozie-slider--horizontal': local.orientation !== 'vertical', 'rozie-slider--range': local.range, 'rozie-slider--disabled': local.disabled }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-4e6f0be6="">
      
      <div class={"rozie-slider-track"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div class={"rozie-slider-fill"} data-rozie-s-4e6f0be6="" />
      </div>

      
      {<Show when={normalizedMarks().length > 0}><div class={"rozie-slider-marks"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        
        <For each={normalizedMarks()}>{(tick) => <div class={"rozie-slider-mark"} style={{ left: pct(tick.value) + '%' }} data-rozie-s-4e6f0be6="">
          {(_props.markSlot ?? _props.slots?.['mark'])?.({ value: tick.value, label: tick.label, position: pct(tick.value) }) ?? <span class={"rozie-slider-mark-label"} data-rozie-s-4e6f0be6="">{rozieDisplay(tick.label)}</span>}
        </div>}</For>
      </div></Show>}{<Show when={local.showValue && !local.range}><div class={"rozie-slider-bubbles"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div class={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-end)' }} data-rozie-s-4e6f0be6="">
          {(_props.bubbleSlot ?? _props.slots?.['bubble'])?.({ value: singleValue() }) ?? <span class={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(singleValue()))}</span>}
        </div>
      </div></Show>}{<Show when={local.showValue && local.range}><div class={"rozie-slider-bubbles"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div class={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-start)' }} data-rozie-s-4e6f0be6="">
          {(_props.bubbleSlot ?? _props.slots?.['bubble'])?.({ value: rangePair()[0] }) ?? <span class={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(rangePair()[0]))}</span>}
        </div>
        <div class={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-end)' }} data-rozie-s-4e6f0be6="">
          {(_props.bubbleSlot ?? _props.slots?.['bubble'])?.({ value: rangePair()[1] }) ?? <span class={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(rangePair()[1]))}</span>}
        </div>
      </div></Show>}{<Show when={!local.range}><input type="range" aria-label={rozieAttr(local.ariaLabel)} aria-orientation={rozieAttr(local.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(local.formatValue !== null ? display(singleValue()) : null)} ref={(el) => { inputElRef = el as HTMLElement; }} class={"rozie-slider-input"} min={local.min} max={local.max} step={local.step} value={singleValue()} disabled={!!local.disabled} onInput={($event) => { onInputSingle($event); }} onKeyDown={($event) => { onKeyDownSingle($event); }} data-rozie-s-4e6f0be6="" /></Show>}{<Show when={local.range}><input type="range" aria-label={rozieAttr(local.ariaLabel)} aria-orientation={rozieAttr(local.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(local.formatValue !== null ? display(rangePair()[0]) : null)} ref={(el) => { inputElRef = el as HTMLElement; }} class={"rozie-slider-input rozie-slider-input--lo"} min={local.min} max={local.max} step={local.step} value={rangePair()[0]} disabled={!!local.disabled} onInput={($event) => { onInputLo($event); }} onKeyDown={($event) => { onKeyDownRange('lo', $event); }} data-rozie-s-4e6f0be6="" /></Show>}{<Show when={local.range}><input type="range" aria-label={rozieAttr(local.ariaLabel)} aria-orientation={rozieAttr(local.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(local.formatValue !== null ? display(rangePair()[1]) : null)} class={"rozie-slider-input rozie-slider-input--hi"} min={local.min} max={local.max} step={local.step} value={rangePair()[1]} disabled={!!local.disabled} onInput={($event) => { onInputHi($event); }} onKeyDown={($event) => { onKeyDownRange('hi', $event); }} data-rozie-s-4e6f0be6="" /></Show>}</div>
    </>
  );
}
ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieDisplay, rozieListeners, rozieSpread, rozieStyle } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';

interface RozieMarkSlotCtx {
  value: unknown;
  label: unknown;
  position: unknown;
}

interface RozieBubbleSlotCtx {
  value: unknown;
}

@customElement('rozie-slider')
export default class Slider extends SignalWatcher(LitElement) {
  static styles = css`
.rozie-slider[data-rozie-s-4e6f0be6] {
  position: relative;
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--rozie-slider-thumb-size, 1rem);
  padding: var(--rozie-slider-pad, 0.5rem 0);
  font: var(--rozie-slider-font, inherit);
}
.rozie-slider-track[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  transform: translateY(-50%);
  height: var(--rozie-slider-track-height, 0.375rem);
  border-radius: var(--rozie-slider-track-radius, 999px);
  background: var(--rozie-slider-track-bg, rgba(0, 0, 0, 0.18));
  pointer-events: none;
}
.rozie-slider-fill[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--rozie-slider-fill-start, 0%);
  right: calc(100% - var(--rozie-slider-fill-end, 0%));
  border-radius: inherit;
  background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, #0066cc));
}
.rozie-slider-input[data-rozie-s-4e6f0be6] {
  -webkit-appearance: none;
  appearance: none;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
  width: 100%;
  height: var(--rozie-slider-thumb-size, 1rem);
  margin: 0;
  background: none;
  pointer-events: none;
  cursor: pointer;
  accent-color: var(--rozie-slider-accent, #0066cc);
}
.rozie-slider-input[data-rozie-s-4e6f0be6]:focus { outline: none; z-index: 2; }
.rozie-slider--range[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { pointer-events: none; }
.rozie-slider--disabled[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { cursor: not-allowed; }
.rozie-slider--disabled[data-rozie-s-4e6f0be6] { opacity: var(--rozie-slider-disabled-opacity, 0.55); }
.rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-runnable-track {
  background: none;
  height: var(--rozie-slider-track-height, 0.375rem);
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-track {
  background: none;
  height: var(--rozie-slider-track-height, 0.375rem);
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-progress {
  background: none;
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, 1rem);
  height: var(--rozie-slider-thumb-size, 1rem);
  border: var(--rozie-slider-thumb-border, 2px solid #fff);
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
  box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
  margin-top: var(--rozie-slider-thumb-offset, calc((0.375rem - 1rem) / 2));
  cursor: pointer;
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-thumb {
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, 1rem);
  height: var(--rozie-slider-thumb-size, 1rem);
  border: var(--rozie-slider-thumb-border, 2px solid #fff);
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, #0066cc));
  box-shadow: var(--rozie-slider-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3));
  cursor: pointer;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] {
  width: var(--rozie-slider-thickness, 2.5rem);
  height: var(--rozie-slider-length, 12rem);
  padding: 0;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-track[data-rozie-s-4e6f0be6],
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] {
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, 12rem);
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-fill[data-rozie-s-4e6f0be6] {
  /* The fill still spans start→end along the (now rotated) input axis. */
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-marks[data-rozie-s-4e6f0be6],
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
  /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, 12rem);
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider-marks[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-mark[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  color: var(--rozie-slider-mark-color, rgba(0, 0, 0, 0.55));
}
.rozie-slider-mark-label[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: var(--rozie-slider-mark-offset, 0.75rem);
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--rozie-slider-mark-font-size, 0.6875rem);
  white-space: nowrap;
}
.rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-bubble[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: var(--rozie-slider-bubble-offset, -1.25rem);
  transform: translateX(-50%);
}
.rozie-slider-bubble-text[data-rozie-s-4e6f0be6] {
  display: inline-block;
  padding: var(--rozie-slider-bubble-padding, 0.0625rem 0.375rem);
  font-size: var(--rozie-slider-bubble-font-size, 0.6875rem);
  color: var(--rozie-slider-bubble-fg, #fff);
  background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, #0066cc));
  border-radius: var(--rozie-slider-bubble-radius, 4px);
  white-space: nowrap;
}
`;

  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <Slider r-model:value="volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />
   */
  @property({ type: Object, attribute: 'value' }) _value_attr: unknown = null;
  private _valueControllable = createLitControllableProperty<unknown>({ host: this, eventName: 'value-change', defaultValue: null, initialControlledValue: undefined });
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  @property({ type: Boolean, reflect: true }) range: boolean = false;
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  @property({ type: Number, reflect: true }) min: number = 0;
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  @property({ type: Number, reflect: true }) max: number = 100;
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  @property({ type: Number, reflect: true }) step: number = 1;
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  @property({ type: String, reflect: true }) orientation: string = 'horizontal';
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  @property({ type: Boolean, reflect: true }) disabled: boolean = false;
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  @property({ type: Array }) marks: any[] = [];
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  @property({ type: String, reflect: true }) ariaLabel: string | null = null;
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  @property({ type: Number, reflect: true }) pageStep: number | null = null;
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  @property({ type: Function }) formatValue: ((...args: unknown[]) => unknown) | null = null;
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  @property({ type: Boolean, reflect: true }) showValue: boolean = false;
  @query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;

  @state() private _hasSlotMark = false;
  @queryAssignedElements({ slot: 'mark', flatten: true }) private _slotMarkElements!: Element[];
  @property({ attribute: false }) mark?: (scope: { value: unknown; label: unknown; position: unknown }) => unknown;
  @state() private _hasSlotBubble = false;
  @queryAssignedElements({ slot: 'bubble', flatten: true }) private _slotBubbleElements!: Element[];
  @property({ attribute: false }) bubble?: (scope: { value: unknown }) => unknown;

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="mark"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotMark = this._slotMarkElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="bubble"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotBubble = this._slotBubbleElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    // Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
    this._hasSlotMark = Array.from(this.children).some((el) => el.getAttribute('slot') === 'mark');
    this._hasSlotBubble = Array.from(this.children).some((el) => el.getAttribute('slot') === 'bubble');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    this._armListeners();
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'value') this._valueControllable.notifyAttributeChange(value as unknown as unknown);
  }

  render() {
    return html`
<div class="${Object.entries({ "rozie-slider": true, 'rozie-slider--vertical': this.orientation === 'vertical', 'rozie-slider--horizontal': this.orientation !== 'vertical', 'rozie-slider--range': this.range, 'rozie-slider--disabled': this.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" style=${rozieStyle(this.fillStyle)} ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-4e6f0be6>
  
  <div class="rozie-slider-track" aria-hidden="true" data-rozie-s-4e6f0be6>
    <div class="rozie-slider-fill" data-rozie-s-4e6f0be6></div>
  </div>

  
  ${this.normalizedMarks().length > 0 ? html`<div class="rozie-slider-marks" aria-hidden="true" data-rozie-s-4e6f0be6>
    
    ${repeat<any>(this.normalizedMarks(), (tick, _idx) => tick.value, (tick, _idx) => html`<div class="rozie-slider-mark" key=${rozieAttr(tick.value)} style=${styleMap({ left: this.pct(tick.value) + '%' })} data-rozie-s-4e6f0be6>
      ${this.mark !== undefined ? this.mark({value: tick.value, label: tick.label, position: this.pct(tick.value)}) : html`<slot name="mark" data-rozie-params=${(() => { try { return JSON.stringify({value: tick.value, label: tick.label, position: this.pct(tick.value)}); } catch { return '{}'; } })()}>
        <span class="rozie-slider-mark-label" data-rozie-s-4e6f0be6>${rozieDisplay(tick.label)}</span>
      </slot>`}
    </div>`)}
  </div>` : nothing}${this.showValue && !this.range ? html`<div class="rozie-slider-bubbles" aria-hidden="true" data-rozie-s-4e6f0be6>
    <div class="rozie-slider-bubble" style=${styleMap({ left: 'var(--rozie-slider-fill-end)' })} data-rozie-s-4e6f0be6>
      ${this.bubble !== undefined ? this.bubble({value: this.singleValue()}) : html`<slot name="bubble" data-rozie-params=${(() => { try { return JSON.stringify({value: this.singleValue()}); } catch { return '{}'; } })()}>
        <span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>${rozieDisplay(this.display(this.singleValue()))}</span>
      </slot>`}
    </div>
  </div>` : nothing}${this.showValue && this.range ? html`<div class="rozie-slider-bubbles" aria-hidden="true" data-rozie-s-4e6f0be6>
    <div class="rozie-slider-bubble" style=${styleMap({ left: 'var(--rozie-slider-fill-start)' })} data-rozie-s-4e6f0be6>
      ${this.bubble !== undefined ? this.bubble({value: this.rangePair()[0]}) : html`<slot name="bubble" data-rozie-params=${(() => { try { return JSON.stringify({value: this.rangePair()[0]}); } catch { return '{}'; } })()}>
        <span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>${rozieDisplay(this.display(this.rangePair()[0]))}</span>
      </slot>`}
    </div>
    <div class="rozie-slider-bubble" style=${styleMap({ left: 'var(--rozie-slider-fill-end)' })} data-rozie-s-4e6f0be6>
      ${this.bubble !== undefined ? this.bubble({value: this.rangePair()[1]}) : html`<slot name="bubble" data-rozie-params=${(() => { try { return JSON.stringify({value: this.rangePair()[1]}); } catch { return '{}'; } })()}>
        <span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>${rozieDisplay(this.display(this.rangePair()[1]))}</span>
      </slot>`}
    </div>
  </div>` : nothing}${!this.range ? html`<input class="rozie-slider-input" type="range" min=${this.min} max=${this.max} step=${this.step} .value=${this.singleValue()} ?disabled=${!!this.disabled} aria-label=${this.ariaLabel} aria-orientation=${rozieAttr(this.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext=${rozieAttr(this.formatValue !== null ? this.display(this.singleValue()) : null)} @input=${($event: Event) => { this.onInputSingle($event); }} @keydown=${($event: Event) => { this.onKeyDownSingle($event); }} data-rozie-ref="inputEl" data-rozie-s-4e6f0be6 />` : nothing}${this.range ? html`<input class="rozie-slider-input rozie-slider-input--lo" type="range" min=${this.min} max=${this.max} step=${this.step} .value=${this.rangePair()[0]} ?disabled=${!!this.disabled} aria-label=${this.ariaLabel} aria-orientation=${rozieAttr(this.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext=${rozieAttr(this.formatValue !== null ? this.display(this.rangePair()[0]) : null)} @input=${($event: Event) => { this.onInputLo($event); }} @keydown=${($event: Event) => { this.onKeyDownRange('lo', $event); }} data-rozie-ref="inputEl" data-rozie-s-4e6f0be6 />` : nothing}${this.range ? html`<input class="rozie-slider-input rozie-slider-input--hi" type="range" min=${this.min} max=${this.max} step=${this.step} .value=${this.rangePair()[1]} ?disabled=${!!this.disabled} aria-label=${this.ariaLabel} aria-orientation=${rozieAttr(this.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext=${rozieAttr(this.formatValue !== null ? this.display(this.rangePair()[1]) : null)} @input=${($event: Event) => { this.onInputHi($event); }} @keydown=${($event: Event) => { this.onKeyDownRange('hi', $event); }} data-rozie-s-4e6f0be6 />` : nothing}</div>
`;
  }

  pct = (v: any) => {
  const span = this.max - this.min;
  if (span === 0) return 0;
  const p = (v - this.min) / span * 100;
  if (p < 0) return 0;
  if (p > 100) return 100;
  return p;
};

  clampStep = (raw: any) => {
  if (!Number.isFinite(raw)) return this.min;
  let v = raw;
  if (v < this.min) v = this.min;
  if (v > this.max) v = this.max;
  const step = this.step;
  if (Number.isFinite(step) && step > 0) {
    const steps = Math.round((v - this.min) / step);
    v = this.min + steps * step;
    if (v < this.min) v = this.min;
    if (v > this.max) v = this.max;
  }
  return v;
};

  rangePair = () => {
  const cur = this.value;
  if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
  return [this.min, this.max];
};

  singleValue = () => {
  const cur = this.value;
  return typeof cur === 'number' && Number.isFinite(cur) ? cur : this.min;
};

  get fillStyle() {
    let start, end;
    if (this.range) {
      const arr = Array.isArray(this.value) && this.value.length === 2 ? this.value : [this.min, this.max];
      start = this.pct(arr[0]);
      end = this.pct(arr[1]);
    } else {
      start = 0;
      end = this.pct(typeof this.value === 'number' && Number.isFinite(this.value) ? this.value : this.min);
    }
    return {
      '--rozie-slider-fill-start': start + '%',
      '--rozie-slider-fill-end': end + '%'
    };
  }

  normalizedMarks = () => {
  const list = Array.isArray(this.marks) ? this.marks : [];
  return list.map((m: any) => {
    if (m !== null && typeof m === 'object' && 'value' in m) {
      return {
        value: m.value,
        label: 'label' in m && m.label != null ? m.label : String(m.value)
      };
    }
    return {
      value: m,
      label: String(m)
    };
  });
};

  display = (v: any) => {
  if (this.formatValue !== null) return this.formatValue(v);
  return String(v);
};

  fireChange = (value: any) => this.dispatchEvent(new CustomEvent("change", {
  detail: {
    value
  },
  bubbles: true,
  composed: true
}));

  commitSingle = (raw: any) => {
  const v = this.clampStep(raw);
  this._valueControllable.write(v);
  this.fireChange(v);
};

  commitRange = (which: any, raw: any) => {
  const pair = this.rangePair();
  let lo = pair[0];
  let hi = pair[1];
  const v = this.clampStep(raw);
  if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
  const next = [lo, hi];
  this._valueControllable.write(next);
  this.fireChange(next);
};

  onInputSingle = ($event: any) => this.commitSingle($event.target.valueAsNumber);

  onInputLo = ($event: any) => this.commitRange('lo', $event.target.valueAsNumber);

  onInputHi = ($event: any) => this.commitRange('hi', $event.target.valueAsNumber);

  effectivePageStep = () => {
  const ps = this.pageStep;
  if (Number.isFinite(ps) && ps > 0) return ps;
  const step = Number.isFinite(this.step) && this.step > 0 ? this.step : 1;
  return step * 10;
};

  onKeyDownSingle = ($event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? this.effectivePageStep() : -this.effectivePageStep();
  this.commitSingle(this.singleValue() + delta);
};

  onKeyDownRange = (which: any, $event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? this.effectivePageStep() : -this.effectivePageStep();
  const pair = this.rangePair();
  const base = which === 'lo' ? pair[0] : pair[1];
  this.commitRange(which, base + delta);
};

  focus = () => this._refInputEl?.focus();

  increment = (thumb: any) => {
  if (this.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = this.rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    this.commitRange(which, base + this.step);
  } else {
    this.commitSingle(this.singleValue() + this.step);
  }
};

  decrement = (thumb: any) => {
  if (this.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = this.rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    this.commitRange(which, base - this.step);
  } else {
    this.commitSingle(this.singleValue() - this.step);
  }
};

  get value(): unknown { return this._valueControllable.read(); }
  set value(v: unknown) { this._valueControllable.notifyPropertyWrite(v); }

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['value', 'range', 'min', 'max', 'step', 'orientation', 'disabled', 'marks', 'aria-label', 'arialabel', 'page-step', 'pagestep', 'format-value', 'formatvalue', 'show-value', 'showvalue']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component (with ControlValueAccessor), a Solid component, and a Lit custom element. Same props, same change event, same two-way value, same scoped slots, same imperative handle — all from the one source above, built on the native <input type="range"> with no third-party engine behind it.

See also

Pre-v1.0 — internal monorepo.