Skip to main content
WireKit
Copy for LLM

Range Slider

The <x-wirekit::range-slider> component provides a dual-handle slider for selecting a numeric range. Both handles are draggable via pointer and keyboard, and the filled track between them visualizes the selected range.

Basic Usage

Price Range
0 1000
Range: 200 to 800

With Step

Custom Step
0 100
Range: 20 to 60

Adjust in 5 kg increments

Adjust in 5 kg increments

With Hint

Hint Text
18 65
Range: 25 to 45

Filter candidates by age

Filter candidates by age

Without Value Bubbles

Set :show-values="false" to hide the floating numeric badges above each handle. The min/max bounds under the track and the aria-live region for screen readers stay in place — only the tooltip-style bubbles are removed.

No Bubbles
0 1000
Range: 200 to 800

To hide the bubbles globally for every <x-wirekit::range-slider> in your app, set the default in config/wirekit.php:

'components' => [
    'range-slider' => ['show_values' => false],
],

Livewire Integration

Pass a single wire:model[.modifiers]="propName" directive on the component tag — the slider re-emits it on each hidden input as propName.min / propName.max so a Livewire array property gets live two-way binding:

// In your Livewire component class
public array $budget = ['min' => 1000, 'max' => 5000];
<x-wirekit::range-slider
    label="Budget"
    name="budget"
    :min="0"
    :max="10000"
    :step="100"
    wire:model.live="budget"
/>

Dragging either handle updates $budget['min'] / $budget['max'] on the server.

All wire:model modifiers (.live, .lazy, .debounce.500ms, .blur) flow through unchanged — the component re-emits them on both hidden inputs identically.

For form submission without Livewire, the component still renders two hidden inputs ({name}[min] and {name}[max]) so a $_POST['budget'] (or Request::input('budget')) lands as a nested array.

Width

The range slider fills its parent width. Constrain it via the parent element:

<div class="max-w-md">
    <x-wirekit::range-slider name="price" :min="0" :max="500" />
</div>

Mobile & Touch

On touch devices the thumb grows to a comfortable 28px (via @media (pointer: coarse)) and touch-action: none keeps a horizontal drag from scrolling the page. Discrete sliders (step > 1) with a readable step count render snap tick-marks.

Discrete slider with snap ticks
0 10
Range: 3 to 7

Form Submission

Two hidden inputs are rendered:

  • <input type="hidden" name="{name}[min]"> — lower handle value
  • <input type="hidden" name="{name}[max]"> — upper handle value

Reading the values server-side

The two thumbs submit as one array rather than two flat keys:

// 1. The request body a range-slider named "price" produces:
//    price%5Bmin%5D=10&price%5Bmax%5D=250

// 2. Read either half with dot notation — NOT `price_min`.
$request->input('price.min');   // "10"
$request->input('price.max');   // "250"

// 3. Or take the pair at once.
$request->input('price');       // ['min' => '10', 'max' => '250']

Validate it the same way:

// 1. Each half is its own rule path.
$request->validate([
    'price.min' => ['required', 'integer', 'min:0'],
    'price.max' => ['required', 'integer', 'gte:price.min'],
]);

This page documented {name}_min and {name}_max for a long time, and those keys never existed in the payload. $request->input('price_min') returns null — which a validator reads as a missing optional field rather than an error, so the wrong value reaches the database without anything failing. If you have _min / _max in a controller or a form request, that is the shape to change.

Named values

A range handle reads as its number. For a price that is exactly right; for a tier it is meaningless — "0 to 100" says nothing about Free or Enterprise. value-text-map gives each stop the word it deserves, and the word is what everyone gets: the handle badges show it, the bounds beneath the track show it, and a screen reader announces it.

A range slider at a fixed width
Free Enterprise
Range: 25 to 75

Stops the map does not name fall back to the number, so a partial map is safe — inventing a word for the gaps would be worse than showing the digit.

Optimistic UI

Pass the name of the Livewire method the slider should call and the range is sent the moment the gesture ends, then confirms or undoes itself when the server answers:

<x-wirekit::range-slider
    name="budget"
    label="Budget"
    :min="0"
    :max="1000"
    :min-value="$from"
    :max-value="$to"
    optimistic="saveBudget"
/>

The method receives the pair, not the handle that moved:

public function saveBudget(array $range): void
{
    [$this->from, $this->to] = $range;
}

Load wirekit-optimistic.js alongside whichever bundle you already use — below it, in your layout:

@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}"></script>

Try it

The demo below runs the real path: the change shows immediately, the outline says it is provisional, and the server's answer either confirms it silently or takes it back.

Optimistic range — accepted, refused, and a slow answer
0 1000
Range: 200 to 700
0 1000
Range: 200 to 700
0 1000
Range: 200 to 700

The <livewire:demos.…> wrapper above exists only on this site — it supplies the demo methods so the page can show a real round trip. The block under it is what you write.

When the value is sent is the part worth knowing. A drag produces a value on every frame, and sending each one would be a request storm; sending only the last would let the thumb run ahead of anything the server was ever told, so an undo would take back a value that was never on its way. So the send happens when the gesture is over — on release for a drag, and on each keypress for the keyboard, where one press is already a finished decision. Nothing is timed; the boundary is the event that ends the input.

The range is one value. If only one handle moved, an undo still restores both — the untouched one to what it already holds, which changes nothing. There is no per-handle bookkeeping to get out of step.

What a screen reader hears The thumbs are tracked by the slider's own polite region while you drag, exactly as before — that is not the optimistic layer talking.

At the commit, the layer announces once, hedged — "Saving" — so the new range is audible as provisional. Confirmation is silent: what was announced is what happened. Only a deviation speaks a second time, which is what makes an undo recognizable as an undo.

An aborted request announces nothing at all — nothing was refused.

Focus stays exactly where you put it. An undo arrives on the server's schedule, and moving focus then would take you out of your place for a reason you could not predict.

Props

Prop Type Default Description
label string|null null Label text above the slider
error string|null null Validation message. Rendered below the control, announced politely, and wired with aria-invalid + aria-describedby
hint string|null null Help text below the slider
name string|null null Base form field name. Submits array syntax{name}[min] and {name}[max] — so the pair arrives as one array. See Reading the values server-side
id string|null auto-generated Base element id
min int|float 0 Absolute minimum of the range
max int|float 100 Absolute maximum of the range
step int|float 1 Increment step for both handles
minValue int|float|null min Initial position of the lower handle
maxValue int|float|null max Initial position of the upper handle
optimistic string|null null Livewire method to call when the gesture ends, showing the new range before the server confirms it. Receives [min, max]. See Optimistic UI.
optimisticArgs array [] Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to.
showValues bool|null null (falls back to config('wirekit.components.range-slider.show_values', true)) Toggle the floating value bubbles above each handle
disabled bool false Disabled state
valueTextMap array|null null Spoken value per stop, e.g. [0 => 'Free', 100 => 'Enterprise']. Each handle announces its own value.
scope string|null null Scoped personalization key

Accessibility

  • Each handle: role="slider" with aria-valuenow, aria-valuemin, aria-valuemax, and aria-label ("Minimum value" / "Maximum value")
  • The group is wrapped with role="group" and aria-label from the label prop
  • Value changes are announced via aria-valuenow updates (native slider behavior)
  • Both handles have visible focus rings via focus-visible styling
  • The filled track between handles is aria-hidden="true" — purely decorative
  • Disabled state sets native disabled on hidden inputs and aria-disabled="true" on handles

Keyboard Interaction

Each handle is independently focusable via Tab:

Key Action
Tab Move focus between the two handles
ArrowRight / ArrowUp Move handle up by step
ArrowLeft / ArrowDown Move handle down by step
PageUp Move handle up by step * 10
PageDown Move handle down by step * 10
Home Move handle to min (lower) or to lower handle position (upper)
End Move handle to upper handle position (lower) or max (upper)

Handles cannot cross each other — the lower handle stops at the upper handle's position and vice versa.

Pitfalls

  • Don't use range-slider for two unrelated values. It's specifically for a min/max range over a shared scale. For two independent values pair two <x-wirekit::slider> instances.

Design Tokens

Token Purpose
--color-wk-accent Filled track and handle background
--color-wk-accent-fg Handle inner dot or icon color
--color-wk-bg-muted Unfilled track background
--color-wk-ring Focus ring color on handles
--color-wk-text Value label text color
--color-wk-text-muted Hint text color
--radius-wk-full Handle border radius (circle)
--radius-wk-sm Track border radius
--shadow-wk-sm Handle shadow
--transition-wk-duration Hover/focus transitions
--opacity-wk-disabled Dimmed state when disabled

Customization

Override defaults in config/wirekit.php:

'components' => [
    'range-slider' => ['min' => 0, 'max' => 100, 'step' => 1],
],

Further Reading

Was this page helpful?

Thanks — that helps.

Voting requires cookies or local storage. What we store