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
With Step
Adjust in 5 kg increments
Adjust in 5 kg increments
With Hint
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.
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.
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.
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.
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"witharia-valuenow,aria-valuemin,aria-valuemax, andaria-label("Minimum value" / "Maximum value") - The group is wrapped with
role="group"andaria-labelfrom thelabelprop - Value changes are announced via
aria-valuenowupdates (native slider behavior) - Both handles have visible focus rings via
focus-visiblestyling - The filled track between handles is
aria-hidden="true"— purely decorative - Disabled state sets native
disabledon hidden inputs andaria-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],
],