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, and anaria-labelthat carries the group's own name —"Price range minimum"/"Price range maximum"when you passlabel="Price range", and"Minimum"/"Maximum"when you pass no name at all - The group is wrapped with
role="group", named byaria-labelledbypointing at the visiblelabel(so the name on screen and the name announced are the same string) - Value changes are announced via
aria-valuenowupdates (native slider behavior) - Each handle's announced range is the range it can actually hold, not the whole track. The two handles never meet, so the lower one reports
aria-valuemaxonestepbelow the upper handle's current value and the upper one reportsaria-valueminonestepabove the lower — both live, so they track as the handles move. On a0–100slider withstep="10"the lower handle announces a maximum of90, and pressing End lands it exactly there - 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 the hidden inputs (so the range is left out of a submitted form),aria-disabled="true"on the group and on both handles, andtabindex="-1"on the handles — they leave the tab order, the pointer and keyboard handlers are not rendered at all, and the whole group dims via--opacity-wk-disabled
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 one step above the lower handle (upper) |
| End | Move handle to one step below the upper handle (lower) or to max (upper) |
Handles cannot cross each other, and they cannot meet: the lower handle stops one step below the upper handle's value, and the upper handle stops one step above the lower one. Home and End land on the ends of the handle's own travel, which is the same bound the arrow keys clamp to.
Dragging a handle with a pointer also focuses it, so a drag can be followed immediately by an arrow key to fine-tune the value.
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],
],