Skip to content
Brok UI

Loading…

No results

Slider

Open source Core

Numeric range controls — a draggable track (single or two-thumb range, with discrete pips and scrubber variants), an angle dial, an exposure ticker, a media scrubber, or a springy elastic slider that stretches past its bounds.

Version
v1.5.1
Stability
stable
License
MIT
Related
Angle Slider
Elastic Slider
Exposure Slider
Scrubber
1 of 5

Slider — A draggable, keyboard-accessible numeric control — single or two-thumb range, optional live value readout (top/bottom/left/right/tooltip), step ticks, and the discrete pips and large-thumb scrubber variants.

Preview

Volume 40%
Variant
previews.components.slider.default.blade.php Blade
<div class="w-80 max-w-full space-y-6">
    <x-ui.slider
        name="volume"
        label="{{ __('Volume') }}"
        :value="40"
        :step="10"
        show-value
        show-steps
        suffix="%"
    />

    <x-ui.slider
        name="price"
        label="{{ __('Price range') }}"
        range
        :value="20"
        :value-end="80"
        show-value
        value-position="tooltip"
        prefix="$"
    />
</div>
Track Current
Pips Current
Scrubber Current

Installation

terminal
php artisan ui:add slider

Note

This component ships an Alpine behavior module at resources/js/ui/slider.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './slider.js';

Registry contract

php artisan ui:add slider writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/slider.blade.php
  • js resources/js/ui/slider.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

slider.md
# Brok UI: Slider (`slider`)

A draggable, keyboard-accessible numeric control — single or two-thumb range, optional live value readout (top/bottom/left/right/tooltip), step ticks, and the discrete pips and large-thumb scrubber variants.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add slider
```

## Usage

```blade
<div class="w-80 max-w-full space-y-6">
    <x-ui.slider
        name="volume"
        label="{{ __('Volume') }}"
        :value="40"
        :step="10"
        show-value
        show-steps
        suffix="%"
    />

    <x-ui.slider
        name="price"
        label="{{ __('Price range') }}"
        range
        :value="20"
        :value-end="80"
        show-value
        value-position="tooltip"
        prefix="$"
    />
</div>
```

## Props

- `name` (mixed|null, default `null`) — Hidden input name(s); range mode posts name[min]/name[max].
- `min` (int, default `0`) — Minimum value.
- `max` (int, default `100`) — Maximum value.
- `step` (int, default `1`) — Snap increment.
- `value` (int, default `0`) — Current value, or the low value in range mode.
- `valueEnd` (mixed|null, default `null`) — High value in range mode; defaults to max.
- `range` (bool, default `false`) — Renders two thumbs for a min/max range (track variant only).
- `showValue` (bool, default `false`) — Shows a live formatted readout.
- `valuePosition` (string, default `top`) — top, bottom, left, right or tooltip — where the readout renders.
- `showSteps` (bool, default `false`) — Shows tick marks over the track when the step count is legible (<=40).
- `variant` (track|pips|scrubber, default `track`) — track (thumb on a filled track), pips (discrete dot stops, no thumb) or scrubber (continuous fill with one large thumb).
- `label` (mixed|null, default `null`) — Accessible label for the thumb and readout heading.
- `prefix` (string, default ``) — Text prepended to the formatted readout.
- `suffix` (string, default ``) — Text appended to the formatted readout.
- `decimals` (int, default `0`) — Decimal places shown in the readout.
- `legendStart` (mixed|null, default `null`) — Optional word under the start end of the track.
- `legendEnd` (mixed|null, default `null`) — Optional word under the end end of the track.

## Use when

- Use when continuous adjustment or immediate visual feedback helps users choose a value.
- Collecting a single numeric value or a two-thumb range with a draggable, keyboard-accessible control.
- Needing the discrete pips look or the large-thumb scrubber look via variant, instead of building a separate component.

## Avoid when

- Do not use for precise values or broad ranges without good landmarks; direct entry is better when accuracy matters.
- Only use when approximate continuous adjustment is meaningful; pair it with direct entry when precision matters.
- Wanting a rotational dial; use angle-slider instead.
- Wanting a springy elastic drag feel; use elastic-slider instead.
- Wanting drag-anywhere-on-track scrubbing rather than a fixed thumb; use scrubber instead.

## Anti-patterns

- Using sliders for precise or unbounded values

## Rules

- Use the `<brok:slider>` tag (or `<x-ui.slider>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/slider
- Registry JSON (files, props, contract): https://brokui.dev/r/open/slider.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

legend.blade.php Blade
{{-- A labelled slider with a readout and legend words under the track — the
     shape range-field used to wrap. --}}
<div class="mx-auto w-full max-w-sm">
    <x-ui.field name="temperature" label="Temperature" hint="Lower is more precise; higher is more creative.">
        <x-ui.slider name="temperature" :min="0" :max="2" :step="0.1" :value="0.7" :decimals="1" show-value legend-start="Precise" legend-end="Creative" />
    </x-ui.field>
</div>
variants.blade.php Blade
{{-- variant="pips": discrete dot stops with no thumb, for a settings panel.
     variant="scrubber": the continuous fill with one large thumb. --}}
<div class="w-80 max-w-full space-y-6">
    <x-ui.slider
        variant="pips"
        name="quality"
        label="{{ __('Quality') }}"
        :min="0"
        :max="100"
        :step="25"
        :value="50"
        suffix="%"
        show-value
    />

    <x-ui.slider
        variant="scrubber"
        name="volume"
        label="{{ __('Volume') }}"
        :min="0"
        :max="11"
        :step="1"
        :value="7"
        show-value
    />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
name mixed | null null Hidden input name(s); range mode posts name[min]/name[max].
min int 0 Minimum value.
max int 100 Maximum value.
step int 1 Snap increment.
value int 0 Current value, or the low value in range mode.
valueEnd mixed | null null High value in range mode; defaults to max.
range bool false Renders two thumbs for a min/max range (track variant only).
showValue bool false Shows a live formatted readout.
valuePosition string top top, bottom, left, right or tooltip — where the readout renders.
showSteps bool false Shows tick marks over the track when the step count is legible (<=40).
variant track | pips | scrubber track track (thumb on a filled track), pips (discrete dot stops, no thumb) or scrubber (continuous fill with one large thumb).
label mixed | null null Accessible label for the thumb and readout heading.
prefix string Text prepended to the formatted readout.
suffix string Text appended to the formatted readout.
decimals int 0 Decimal places shown in the readout.
legendStart mixed | null null Optional word under the start end of the track.
legendEnd mixed | null null Optional word under the end end of the track.

Slots

  • default — Not used by this component; present for attribute-bag compatibility.

Data slots

Stable hooks for CSS overrides and browser tests.

slider-control slider-legend slider-pip slider-range slider-thumb slider-tick slider-track {{ $slotName }}

Behavior

  • Dragging a thumb (or the pips control) or pressing arrow keys sets the value snapped to step; range=true renders two thumbs that clamp against each other; every change dispatches a bubbling slider-change event.
  • showValue with valuePosition (top/bottom/left/right/tooltip) renders a live formatted readout; showSteps adds tick marks over the track when the step count is legible.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Continuous value control

Adjust an intuitive continuous value.

Use when

  • Use when continuous adjustment or immediate visual feedback helps users choose a value.
  • Collecting a single numeric value or a two-thumb range with a draggable, keyboard-accessible control.
  • Needing the discrete pips look or the large-thumb scrubber look via variant, instead of building a separate component.

Avoid when

  • Do not use for precise values or broad ranges without good landmarks; direct entry is better when accuracy matters.
  • Only use when approximate continuous adjustment is meaningful; pair it with direct entry when precision matters.
  • Wanting a rotational dial; use angle-slider instead.
  • Wanting a springy elastic drag feel; use elastic-slider instead.
  • Wanting drag-anywhere-on-track scrubbing rather than a fixed thumb; use scrubber instead.

Use instead

  • Number field for precise entry
  • Select for discrete values

Anti-patterns

  • Using sliders for precise or unbounded values
Anatomy
root slider-control slider-track slider-range slider-pip slider-tick slider-thumb slider-legend
Theming hooks
slider

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
ArrowUp ArrowDown ArrowLeft ArrowRight Home End
Focus
managed
  • Each thumb (or the pips control) is a role="slider" with aria-valuemin/max/now, positioned with logical inset-inline-start so it mirrors under dir="rtl".
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="slider-{{ $record->id }}">
    <div class="w-80 max-w-full space-y-6">
        <x-ui.slider
            name="volume"
            label="{{ __('Volume') }}"
            :value="40"
            :step="10"
            show-value
            show-steps
            suffix="%"
        />
    
        <x-ui.slider
            name="price"
            label="{{ __('Price range') }}"
            range
            :value="20"
            :value-end="80"
            show-value
            value-position="tooltip"
            prefix="$"
        />
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:slider
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/slider.blade.php Blade
@props([
    'name' => null,
    'min' => 0,
    'max' => 100,
    'step' => 1,
    'value' => 0,
    'valueEnd' => null,
    'range' => false,
    'showValue' => false,
    'valuePosition' => 'top',
    'showSteps' => false,
    // track (default): a thumb on a filled track. pips: discrete dot stops on
    // a thin rail, no thumb — the settings-panel selector (single value).
    // scrubber: the track with one large thumb (single value).
    'variant' => 'track',
    'label' => null,
    'prefix' => '',
    'suffix' => '',
    'decimals' => 0,
    // Optional words under the two ends of the track ("Precise" … "Creative").
    'legendStart' => null,
    'legendEnd' => null,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $variant = in_array($variant, ['track', 'pips', 'scrubber'], true) ? $variant : 'track';
    $recipe = $styles['slider'];
    $min = (float) $min;
    $max = (float) $max;
    $step = (float) $step;
    // pips and scrubber are single-value looks.
    $range = $variant === 'track' && filter_var($range, FILTER_VALIDATE_BOOL);
    $showValue = filter_var($showValue, FILTER_VALIDATE_BOOL);
    $showSteps = filter_var($showSteps, FILTER_VALIDATE_BOOL);
    $decimals = max(0, (int) $decimals);

    $value = max($min, min($max, (float) $value));
    $valueEnd = $valueEnd === null ? $max : max($min, min($max, (float) $valueEnd));

    $positions = ['top', 'bottom', 'left', 'right', 'tooltip'];
    $pos = in_array($valuePosition, $positions, true) ? $valuePosition : 'top';
    $hasReadout = $showValue && in_array($pos, ['top', 'bottom', 'left', 'right'], true);
    $isTooltip = $showValue && $pos === 'tooltip';
    $inline = $hasReadout && in_array($pos, ['left', 'right'], true);
    $readoutFirst = in_array($pos, ['top', 'left'], true);

    // Step pips are only legible for a modest number of stops; skip otherwise.
    $stepCount = $step > 0 ? (int) round(($max - $min) / $step) : 0;
    $pips = ($showSteps && $stepCount > 0 && $stepCount <= 40) ? $stepCount : 0;
    // The discrete variant needs its stops; clamp to something legible.
    $stops = $variant === 'pips' ? max(1, min(40, $stepCount ?: 1)) : 0;
    $thumbClass = $recipe['thumb'].' '.$recipe['thumbCentered'].' '.$recipe['thumbSizes'][$variant === 'scrubber' ? 'lg' : 'md'];
    // slider-pips renders this component under its own name; the browser
    // keeps only the first of two identical attributes, so read the override
    // out of the bag and print exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'slider');

    // No-JS fallback formatting for the readout / tooltip text.
    $fmt = fn ($v) => $prefix.($decimals > 0 ? number_format((float) $v, $decimals) : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.')).$suffix;
    $fallback = $range ? $fmt($value).' – '.$fmt($valueEnd) : $fmt($value);

    $config = [
        'min' => $min, 'max' => $max, 'step' => $step,
        'value' => $value, 'valueEnd' => $valueEnd, 'range' => $range,
        'decimals' => $decimals, 'prefix' => (string) $prefix, 'suffix' => (string) $suffix,
    ];
@endphp

{{--
    Slider — a draggable, keyboard-accessible numeric control. Supports a single
    thumb (default) or a two-thumb `range`, an optional live value readout
    (`showValue` + `valuePosition`: top / bottom / left / right / tooltip), and
    optional step ticks over the track. `variant="pips"` is the discrete
    settings-panel look (dot stops, no thumb) and `variant="scrubber"` the
    continuous fill with one large thumb; both are single-value. The behaviour layer (`uiSlider`) keeps every value snapped to
    `step`, clamps thumbs against each other in range mode, and positions the fill /
    thumbs with the logical `inset-inline-start` so it mirrors under dir="rtl". Every
    value change dispatches a bubbling `slider-change` event for wrappers/consumers.
--}}
<div
    data-slot="{{ $slotName }}"
    data-variant="{{ $variant }}"
    @if ($range) data-range="true" @endif
    x-data="uiSlider({{ \Illuminate\Support\Js::from($config) }})"
    {{-- Modelable: `x-model` (Alpine) and `wire:model` (Livewire binds through
         the same x-model contract) attach to the root. Inert without either. --}}
    x-modelable="model"
    {{ $attributes->except('data-slot')->merge(['class' => 'w-full '.($inline ? 'flex items-center gap-3' : 'flex flex-col gap-2')]) }}
>
    @if ($hasReadout && $readoutFirst)
        @if ($inline)
            <div class="shrink-0 text-sm">
                @if ($label) <span class="block font-medium text-foreground">{{ __($label) }}</span> @endif
                <output class="{{ $recipe['readout'] }}" x-text="readout()">{{ $fallback }}</output>
            </div>
        @else
            <div class="flex items-baseline justify-between gap-2 text-sm">
                <span class="font-medium text-foreground">{{ $label ? __($label) : '' }}</span>
                <output class="{{ $recipe['readout'] }}" x-text="readout()">{{ $fallback }}</output>
            </div>
        @endif
    @endif

    @if ($variant === 'pips')
        {{-- Discrete stops: the control itself is the slider (no thumb); each
             dot is a click target and the fill runs up to the current stop. --}}
        <div
            data-slot="slider-control"
            x-ref="track"
            role="slider"
            tabindex="0"
            aria-orientation="horizontal"
            :aria-valuemin="min"
            :aria-valuemax="max"
            :aria-valuenow="value"
            aria-label="{{ __($label ?? 'Value') }}"
            @keydown="onKey($event)"
            @pointerdown="startDrag($event)"
            class="{{ $recipe['control'] }} {{ $inline ? 'flex-1' : 'w-full' }} justify-between py-2 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background"
        >
            <div aria-hidden="true" data-slot="slider-track" class="{{ $recipe['rail'] }}"></div>
            <div aria-hidden="true" data-slot="slider-range" class="{{ $recipe['railFill'] }}" :style="`width:${percent(value)}%`"></div>

            @for ($i = 0; $i <= $stops; $i++)
                @php $stop = $min + $i * (($max - $min) / $stops); @endphp
                <span
                    aria-hidden="true"
                    data-slot="slider-pip"
                    @click="setActiveValue({{ $stop }})"
                    class="{{ $recipe['pip'] }}"
                    :class="value >= {{ $stop }} ? '{{ $recipe['pipOn'] }}' : '{{ $recipe['pipOff'] }}'"
                ></span>
            @endfor
        </div>
    @else
    <div
        data-slot="slider-control"
        @pointerdown="startDrag($event)"
        class="{{ $recipe['control'] }} {{ $inline ? 'flex-1' : 'w-full' }} py-2"
    >
        <div
            x-ref="track"
            data-slot="slider-track"
            class="{{ $recipe['track'] }}"
        >
            <div data-slot="slider-range" class="{{ $recipe['fill'] }}" :style="`inset-inline-start:${fillStart()}%; width:${fillWidth()}%`"></div>
        </div>

        @if ($pips)
            @for ($i = 0; $i <= $pips; $i++)
                <span
                    aria-hidden="true"
                    data-slot="slider-tick"
                    class="{{ $recipe['tick'] }}"
                    style="inset-inline-start: {{ rtrim(rtrim(number_format($i / $pips * 100, 4, '.', ''), '0'), '.') }}%"
                ></span>
            @endfor
        @endif

        {{-- Low / single thumb. Keeps the bare onKey($event) hook the a11y guard asserts. --}}
        <span
            data-slot="slider-thumb"
            role="slider"
            tabindex="0"
            aria-orientation="horizontal"
            :aria-valuemin="min"
            :aria-valuemax="max"
            :aria-valuenow="value"
            aria-label="{{ $range ? __('Minimum') : __($label ?? 'Value') }}"
            @keydown="onKey($event)"
            @pointerdown.stop="startDrag($event, 'low')"
            class="group/thumb {{ $thumbClass }}"
            :style="`inset-inline-start:${percent(value)}%`"
        >
            @if ($isTooltip)
                <span
                    aria-hidden="true"
                    class="{{ $recipe['tooltip'] }}"
                    x-text="display(value)"
                >{{ $fmt($value) }}</span>
            @endif
        </span>

        @if ($range)
            <span
                data-slot="slider-thumb"
                role="slider"
                tabindex="0"
                aria-orientation="horizontal"
                :aria-valuemin="min"
                :aria-valuemax="max"
                :aria-valuenow="valueEnd"
                aria-label="{{ __('Maximum') }}"
                @keydown="onKey($event, 'high')"
                @pointerdown.stop="startDrag($event, 'high')"
                class="group/thumb {{ $thumbClass }}"
                :style="`inset-inline-start:${percent(valueEnd)}%`"
            >
                @if ($isTooltip)
                    <span
                        aria-hidden="true"
                        class="{{ $recipe['tooltip'] }}"
                        x-text="display(valueEnd)"
                    >{{ $fmt($valueEnd) }}</span>
                @endif
            </span>
        @endif
    </div>
    @endif

    @if ($hasReadout && ! $readoutFirst)
        @if ($inline)
            <div class="shrink-0 text-sm">
                @if ($label) <span class="block font-medium text-foreground">{{ __($label) }}</span> @endif
                <output class="{{ $recipe['readout'] }}" x-text="readout()">{{ $fallback }}</output>
            </div>
        @else
            <div class="flex items-baseline justify-between gap-2 text-sm">
                <span class="font-medium text-foreground">{{ $label ? __($label) : '' }}</span>
                <output class="{{ $recipe['readout'] }}" x-text="readout()">{{ $fallback }}</output>
            </div>
        @endif
    @endif

    @if ($legendStart !== null || $legendEnd !== null)
        <div data-slot="slider-legend" class="flex justify-between text-xs text-muted-foreground" aria-hidden="true">
            <span>{{ __($legendStart ?? '') }}</span>
            <span>{{ __($legendEnd ?? '') }}</span>
        </div>
    @endif

    @if ($name)
        @if ($range)
            <input type="hidden" name="{{ $name }}[min]" :value="value" />
            <input type="hidden" name="{{ $name }}[max]" :value="valueEnd" />
        @else
            <input type="hidden" name="{{ $name }}" :value="value" />
        @endif
    @endif
</div>
resources/js/ui/slider.js JS
/**
 * Slider behavior (spec §19 behavior layer).
 *
 * Holds one numeric `value` — or, in `range` mode, a `value`/`valueEnd` pair —
 * each clamped to [min, max] and snapped to `step`. Thumbs and the range fill are
 * positioned with the logical `inset-inline-start`, so the component mirrors
 * automatically under `dir="rtl"`; pointer and keyboard input account for direction
 * so dragging and arrow keys always feel correct. In range mode the two thumbs clamp
 * against each other so the low value can never cross the high value.
 *
 * Whenever a value changes (drag, keyboard, or programmatic set) the root element
 * dispatches a bubbling `slider-change` CustomEvent. For a single slider the detail is
 * `{ value }`; in range mode it is `{ value, valueEnd }`, so wrappers (e.g.
 * range-field) and consumers can react without reaching into Alpine internals.
 *
 * Self-registers on `alpine:init` so import order does not matter — import this
 * file once from your bundle (e.g. resources/js/ui/index.js).
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiSlider', (config = {}) => ({
        min: config.min ?? 0,
        max: config.max ?? 100,
        step: config.step ?? 1,
        range: config.range ?? false,
        value: config.value ?? 0,
        valueEnd: config.valueEnd ?? config.max ?? 100,
        decimals: config.decimals ?? 0,
        prefix: config.prefix ?? '',
        suffix: config.suffix ?? '',
        active: 'low',

        // Bound listener refs so we can remove exactly what we added.
        _move: null,
        _up: null,

        init() {
            this.value = this.clamp(this.value);
            this.valueEnd = this.clamp(this.valueEnd);
            if (this.range) this.enforceOrder();

            // Emit a bubbling event on every value change so wrappers (e.g.
            // range-field) and consumers can mirror the value live.
            this.$watch('value', () => this.emit());
            this.$watch('valueEnd', () => this.emit());
        },

        /**
         * Modelable value (`x-modelable="model"` on the root): the number, or a
         * `[low, high]` pair for a range slider. `x-model` and Livewire's
         * `wire:model` read and write it through the same contract.
         */
        get model() {
            return this.range ? [this.value, this.valueEnd] : this.value;
        },
        set model(v) {
            if (this.range) {
                const pair = Array.isArray(v) ? v : (v && typeof v === 'object' ? [v.min ?? v.value, v.max ?? v.valueEnd] : [v, v]);
                this.value = this.clamp(Number(pair[0] ?? this.min));
                this.valueEnd = this.clamp(Number(pair[1] ?? this.max));
                this.enforceOrder();
                return;
            }
            this.value = this.clamp(Number(v ?? this.min));
        },

        destroy() {
            // Remove any listeners still attached if unmounted mid-drag.
            window.removeEventListener('pointermove', this._move);
            window.removeEventListener('pointerup', this._up);
        },

        emit() {
            this.$el.dispatchEvent(
                new CustomEvent('slider-change', {
                    detail: this.range
                        ? { value: this.value, valueEnd: this.valueEnd }
                        : { value: this.value },
                    bubbles: true,
                }),
            );
        },

        clamp(v) {
            v = Math.round(v / this.step) * this.step;
            v = Math.min(this.max, Math.max(this.min, v));
            // Round away binary-float dust (e.g. 0.30000000000000004) at the readout.
            const fromStep = String(this.step).split('.')[1]?.length ?? 0;
            const d = Math.max(this.decimals, fromStep);
            return d ? Number(v.toFixed(d)) : v;
        },

        percent(v) {
            return this.max > this.min
                ? ((v - this.min) / (this.max - this.min)) * 100
                : 0;
        },

        fillStart() {
            return this.range ? this.percent(this.value) : 0;
        },

        fillWidth() {
            return this.range
                ? this.percent(this.valueEnd) - this.percent(this.value)
                : this.percent(this.value);
        },

        // Keep the low thumb at or below the high thumb by pushing whichever thumb
        // is being moved up against the other one.
        enforceOrder() {
            if (this.value > this.valueEnd) {
                if (this.active === 'low') this.value = this.valueEnd;
                else this.valueEnd = this.value;
            }
        },

        setActiveValue(v) {
            v = this.clamp(v);
            if (this.active === 'high') this.valueEnd = v;
            else this.value = v;
            if (this.range) this.enforceOrder();
        },

        nearest(v) {
            if (!this.range) return 'low';
            return Math.abs(v - this.value) <= Math.abs(v - this.valueEnd) ? 'low' : 'high';
        },

        valueFromClientX(clientX) {
            const r = this.$refs.track.getBoundingClientRect();
            let ratio = (clientX - r.left) / r.width;
            if (getComputedStyle(this.$el).direction === 'rtl') {
                ratio = 1 - ratio;
            }
            return this.min + ratio * (this.max - this.min);
        },

        startDrag(e, which) {
            const v = this.valueFromClientX(e.clientX);
            this.active = which ?? this.nearest(v);
            this.setActiveValue(v);

            // Bind once so the same refs can be removed later (here or in destroy()).
            this._move = (ev) => this.setActiveValue(this.valueFromClientX(ev.clientX));
            this._up = () => {
                window.removeEventListener('pointermove', this._move);
                window.removeEventListener('pointerup', this._up);
            };
            window.addEventListener('pointermove', this._move);
            window.addEventListener('pointerup', this._up);
        },

        onKey(e, which) {
            this.active = which ?? 'low';
            const cur = this.active === 'high' ? this.valueEnd : this.value;
            const rtl = getComputedStyle(this.$el).direction === 'rtl';
            const dec = () => this.setActiveValue(cur - this.step);
            const inc = () => this.setActiveValue(cur + this.step);

            switch (e.key) {
                case 'ArrowLeft':
                case 'ArrowDown':
                    e.preventDefault();
                    rtl && e.key === 'ArrowLeft' ? inc() : dec();
                    break;
                case 'ArrowRight':
                case 'ArrowUp':
                    e.preventDefault();
                    rtl && e.key === 'ArrowRight' ? dec() : inc();
                    break;
                case 'Home':
                    e.preventDefault();
                    this.setActiveValue(this.min);
                    break;
                case 'End':
                    e.preventDefault();
                    this.setActiveValue(this.max);
                    break;
            }
        },

        display(v) {
            const n = this.decimals > 0 ? Number(v).toFixed(this.decimals) : String(v);
            return `${this.prefix}${n}${this.suffix}`;
        },

        readout() {
            return this.range
                ? `${this.display(this.value)} – ${this.display(this.valueEnd)}`
                : this.display(this.value);
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.5.1
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0