Components
range-slider
Two-thumb range built from two native <input type="range"> on one track. Each thumb is form-submittable and gets role=slider plus the full keyboard contract from the platform; a tiny script stops them crossing and paints the fill between.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/range-slider.json2. Use it
import { RangeSlider } from "@/components/ui/range-slider"
<RangeSlider min={0} max={500} step={10}
minValue={120} maxValue={380}
minLabel="Minimum price" maxLabel="Maximum price" />Or copy the source manually
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"
// Range Slider (two-thumb) — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn-ui ships a multi-thumb slider built on Radix's <Slider> with N
// <SliderThumb> children, each a div with role="slider" and hand-rolled
// pointer/keyboard handling. We do NOT copy that. Instead we stack TWO real
// <input type="range"> on a single track. The platform then gives us, per
// thumb, for free:
// - role="slider" (implicit on type=range)
// - aria-valuemin / aria-valuemax / aria-valuenow auto-managed from
// each input's min/max/value — no manual ARIA bookkeeping
// - the full APG slider keyboard contract: Arrow keys, Home/End,
// PageUp/PageDown — see the (Multi-Thumb) Slider Pattern, which says
// "Each thumb is in the page tab sequence and has the keyboard
// interactions described in the Slider Pattern."
// - focus ring + disabled handling
// Each input is form-submittable (name=min / name=max).
//
// What the platform does NOT give us, and what public/site.js layers on
// (keyed off data-slot="range-slider"):
// - thumbs must not cross. The APG (Multi-Thumb) pattern: "the maximum
// value of the thumb that sets the lower end of the range is limited
// by the current value of the thumb that sets the upper end". Native
// range inputs don't know about each other, so on `input` we clamp.
// - the coloured fill BETWEEN the thumbs. We publish --range-min /
// --range-max as percentages on the root; a pseudo-track div paints
// the segment between them.
//
// We reuse Slider's exact track/thumb Tailwind so the two look identical.
// Both Chromium (-webkit-) and Firefox (-moz-) need separate thumb rules.
//
// Refs:
// repos/aria-practices/content/patterns/slider-multithumb/slider-multithumb-pattern.html
// repos/aria-practices/content/patterns/slider-multithumb/examples/slider-multithumb.html
// repos/mdn/files/en-us/web/html/reference/elements/input/range/index.md
// (note: `required`/`readonly` are ignored on type=range per MDN, so we omit them)
const ROOT_CLASS =
"relative flex h-4 w-full touch-none items-center select-none"
const TRACK_CLASS =
"pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted"
// The active segment between the two thumbs. site.js sets --range-min /
// --range-max (percentages) on the root; we read them here.
const FILL_CLASS =
"pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary [left:var(--range-min,0%)] [right:calc(100%-var(--range-max,100%))]"
// Each native <input type="range">. Stacked on the same track; transparent
// track so only the thumb shows. pointer-events:none on the bar but auto on
// the thumb so both thumbs stay grabbable even where they overlap.
const INPUT_CLASS = cn(
"pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none",
"[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent",
"[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent",
"[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm",
"[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm",
"focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50",
"focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)]",
"disabled:cursor-not-allowed",
)
type RangeSliderProps = {
id?: string
// Form field names for the two ends. Each input submits independently.
minName?: string
maxName?: string
// Current values of the lower / upper thumbs.
minValue?: number
maxValue?: number
// Track bounds + step (shared by both thumbs).
min?: number
max?: number
step?: number
// Id of a <datalist> rendering tick marks on the track. Native to
// <input type="range"> (MDN range reference, "Supported common
// attributes" + "Adding tick marks"). One datalist applies to both
// thumbs since they share min/max/step.
list?: string
disabled?: boolean
form?: string
// ARIA / labelling. Each thumb needs its own accessible name (APG: a
// multi-thumb slider's thumbs are distinct controls — "Minimum" / "Maximum").
minLabel?: string
maxLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
// Human-readable values for AT, e.g. "$120" instead of "120".
minValuetext?: string
maxValuetext?: string
class?: ClassValue
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
}
export function RangeSlider(props: RangeSliderProps) {
const {
id,
minName = "min",
maxName = "max",
minValue,
maxValue,
min = 0,
max = 100,
step,
list,
disabled,
form,
minLabel = "Minimum",
maxLabel = "Maximum",
ariaLabelledby,
ariaDescribedby,
minValuetext,
maxValuetext,
class: className,
...rest
} = props
const lo = minValue ?? min
const hi = maxValue ?? max
const span = max - min || 1
// Initial fill (site.js keeps it in sync after interaction).
const minPct = `${((lo - min) / span) * 100}%`
const maxPct = `${((hi - min) / span) * 100}%`
return (
<span
data-slot="range-slider"
data-disabled={disabled ? "true" : undefined}
style={`--range-min:${minPct};--range-max:${maxPct}`}
class={cn(ROOT_CLASS, disabled && "opacity-50", className)}
{...rest}
>
<span class={TRACK_CLASS} aria-hidden="true"></span>
<span class={FILL_CLASS} aria-hidden="true"></span>
<input
type="range"
data-range="min"
id={id ? `${id}-min` : undefined}
name={minName}
value={lo}
min={min}
max={max}
step={step}
list={list}
disabled={disabled}
form={form}
aria-label={ariaLabelledby ? undefined : minLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-valuetext={minValuetext}
class={INPUT_CLASS}
/>
<input
type="range"
data-range="max"
id={id ? `${id}-max` : undefined}
name={maxName}
value={hi}
min={min}
max={max}
step={step}
list={list}
disabled={disabled}
form={form}
aria-label={ariaLabelledby ? undefined : maxLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-valuetext={maxValuetext}
class={INPUT_CLASS}
/>
</span>
)
}
1. Save the file
Copy range-slider.html into templates/components/.
2. Use it
{% from "components/range-slider.html" import range_slider %}
{{ range_slider(min=0, max=500, step=10, min_value=120, max_value=380,
min_label="Minimum price", max_label="Maximum price") }}View source
{# Range Slider (two-thumb) macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/range-slider.tsx. Two native <input type="range">
overlaid on one track; site.js (data-slot="range-slider") clamps the
thumbs so they can't cross and keeps --range-min/--range-max in sync. #}
{% macro range_slider(
id=none, min_name="min", max_name="max",
min_value=none, max_value=none, min=0, max=100, step=none,
list=none, disabled=false, form=none,
min_label="Minimum", max_label="Maximum",
aria_labelledby=none, aria_describedby=none,
min_valuetext=none, max_valuetext=none,
extra_class="", **attrs
) %}
{% set lo = min_value if min_value is not none else min %}
{% set hi = max_value if max_value is not none else max %}
{% set span = (max - min) if (max - min) != 0 else 1 %}
{% set min_pct = ((lo - min) / span * 100) ~ "%" %}
{% set max_pct = ((hi - min) / span * 100) ~ "%" %}
<span data-slot="range-slider"
{%- if disabled %} data-disabled="true"{% endif %}
style="--range-min:{{ min_pct }};--range-max:{{ max_pct }}"
class="relative flex h-4 w-full touch-none items-center select-none {% if disabled %}opacity-50{% endif %} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor %}>
<span class="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" aria-hidden="true"></span>
<span class="pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary [left:var(--range-min,0%)] [right:calc(100%-var(--range-max,100%))]" aria-hidden="true"></span>
<input type="range" data-range="min"
{%- if id %} id="{{ id }}-min"{% endif %}
name="{{ min_name }}" value="{{ lo }}"
min="{{ min }}" max="{{ max }}"
{%- if step %} step="{{ step }}"{% endif %}
{# list: native <datalist> tick marks on type=range (MDN range ref) #}
{%- if list %} list="{{ list }}"{% endif %}
{%- if disabled %} disabled{% endif %}
{%- if form %} form="{{ form }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% else %} aria-label="{{ min_label }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
{%- if min_valuetext %} aria-valuetext="{{ min_valuetext }}"{% endif %}
class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none
[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent
[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent
[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm
[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm
focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)]
disabled:cursor-not-allowed">
<input type="range" data-range="max"
{%- if id %} id="{{ id }}-max"{% endif %}
name="{{ max_name }}" value="{{ hi }}"
min="{{ min }}" max="{{ max }}"
{%- if step %} step="{{ step }}"{% endif %}
{# list: native <datalist> tick marks on type=range (MDN range ref) #}
{%- if list %} list="{{ list }}"{% endif %}
{%- if disabled %} disabled{% endif %}
{%- if form %} form="{{ form }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% else %} aria-label="{{ max_label }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
{%- if max_valuetext %} aria-valuetext="{{ max_valuetext }}"{% endif %}
class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none
[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent
[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent
[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm
[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm
focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)]
disabled:cursor-not-allowed">
</span>
{% endmacro %}
1. Save the file
Add range-slider.tmpl alongside your other templates.
2. Use it
{{template "range-slider" (dict "Min" (ptr 0) "Max" (ptr 500) "Step" (ptr 10) "MinValue" (ptr 120) "MaxValue" (ptr 380) "MinLabel" "Minimum price" "MaxLabel" "Maximum price")}}View source
{{/*
Range Slider (two-thumb) template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/range-slider.tsx. Two native <input type="range">
overlaid on one track; site.js (data-slot="range-slider") clamps the
thumbs so they can't cross and keeps --range-min/--range-max in sync.
type RangeSliderArgs struct {
ID, MinName, MaxName string
MinValue, MaxValue, Min, Max, Step *int
List string // id of a <datalist> for tick marks (MDN range ref)
Disabled bool
Form string
MinLabel, MaxLabel string
AriaLabelledby, AriaDescribedby string
MinValuetext, MaxValuetext string
// Everything else (hx-get, data-*, …) goes here.
Attrs map[string]string
}
*/}}
{{define "range-slider"}}
{{- $min := or .Min 0 -}}{{- $max := or .Max 100 -}}
{{- $minName := or .MinName "min" -}}{{- $maxName := or .MaxName "max" -}}
{{- $minLabel := or .MinLabel "Minimum" -}}{{- $maxLabel := or .MaxLabel "Maximum" -}}
{{- $lo := $min -}}{{- if .MinValue}}{{- $lo = deref .MinValue -}}{{end -}}
{{- $hi := $max -}}{{- if .MaxValue}}{{- $hi = deref .MaxValue -}}{{end -}}
{{- $span := sub $max $min -}}{{- if eq $span 0}}{{- $span = 1 -}}{{end -}}
<span data-slot="range-slider" {{if .Disabled}}data-disabled="true"{{end}}
style="--range-min:{{div (mul (sub $lo $min) 100) $span}}%;--range-max:{{div (mul (sub $hi $min) 100) $span}}%"
class="relative flex h-4 w-full touch-none items-center select-none {{if .Disabled}}opacity-50{{end}}"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end}}>
<span class="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" aria-hidden="true"></span>
<span class="pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary [left:var(--range-min,0%)] [right:calc(100%-var(--range-max,100%))]" aria-hidden="true"></span>
<input type="range" data-range="min"
{{if .ID}}id="{{.ID}}-min"{{end}} name="{{$minName}}" value="{{$lo}}"
min="{{$min}}" max="{{$max}}" {{if .Step}}step="{{deref .Step}}"{{end}}
{{/* list: native <datalist> tick marks on type=range (MDN range ref) */}}{{if .List}}list="{{.List}}"{{end}}
{{if .Disabled}}disabled{{end}} {{if .Form}}form="{{.Form}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{else}}aria-label="{{$minLabel}}"{{end}}
{{if .AriaDescribedby}}aria-describedby="{{.AriaDescribedby}}"{{end}}
{{if .MinValuetext}}aria-valuetext="{{.MinValuetext}}"{{end}}
class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed">
<input type="range" data-range="max"
{{if .ID}}id="{{.ID}}-max"{{end}} name="{{$maxName}}" value="{{$hi}}"
min="{{$min}}" max="{{$max}}" {{if .Step}}step="{{deref .Step}}"{{end}}
{{/* list: native <datalist> tick marks on type=range (MDN range ref) */}}{{if .List}}list="{{.List}}"{{end}}
{{if .Disabled}}disabled{{end}} {{if .Form}}form="{{.Form}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{else}}aria-label="{{$maxLabel}}"{{end}}
{{if .AriaDescribedby}}aria-describedby="{{.AriaDescribedby}}"{{end}}
{{if .MaxValuetext}}aria-valuetext="{{.MaxValuetext}}"{{end}}
class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed">
</span>
{{end}}
1. Save the file
Drop range_slider.ex into lib/my_app_web/components/.
2. Use it
<.range_slider min={0} max={500} step={10}
min_value={120} max_value={380}
min_label="Minimum price" max_label="Maximum price" />View source
defmodule ShadcnHtmx.Components.RangeSlider do
@moduledoc """
Range Slider (two-thumb) — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Two native `<input type="range">` overlaid on one track. The platform
gives each thumb role=slider, aria-valuemin/max/now and the full
arrow/Home/End/PageUp/Down keyboard contract for free (WAI-ARIA APG
Multi-Thumb Slider pattern). Each input is form-submittable
(name=min / name=max). site.js (keyed on data-slot="range-slider")
clamps the thumbs so they can't cross and keeps the --range-min /
--range-max CSS variables in sync for the coloured fill.
## Examples
<.range_slider min={0} max={500} step={10}
min_value={120} max_value={380}
min_label="Minimum price" max_label="Maximum price" />
"""
use Phoenix.Component
attr :id, :string, default: nil
attr :min_name, :string, default: "min"
attr :max_name, :string, default: "max"
attr :min_value, :integer, default: nil
attr :max_value, :integer, default: nil
attr :min, :integer, default: 0
attr :max, :integer, default: 100
attr :step, :integer, default: nil
# list: id of a <datalist> for native tick marks on type=range (MDN range ref)
attr :list, :string, default: nil
attr :disabled, :boolean, default: false
attr :form, :string, default: nil
attr :min_label, :string, default: "Minimum"
attr :max_label, :string, default: "Maximum"
attr :min_valuetext, :string, default: nil
attr :max_valuetext, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-labelledby aria-describedby)
@input_class "pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed"
def range_slider(assigns) do
lo = assigns.min_value || assigns.min
hi = assigns.max_value || assigns.max
span = if assigns.max - assigns.min == 0, do: 1, else: assigns.max - assigns.min
assigns =
assigns
|> assign(:lo, lo)
|> assign(:hi, hi)
|> assign(:min_pct, (lo - assigns.min) / span * 100)
|> assign(:max_pct, (hi - assigns.min) / span * 100)
|> assign(:input_class, @input_class)
~H"""
<span
data-slot="range-slider"
data-disabled={@disabled && "true"}
style={"--range-min:#{@min_pct}%;--range-max:#{@max_pct}%"}
class={[
"relative flex h-4 w-full touch-none items-center select-none",
@disabled && "opacity-50",
@class
]}
{@rest}
>
<span
class="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted"
aria-hidden="true"
>
</span>
<span
class="pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary [left:var(--range-min,0%)] [right:calc(100%-var(--range-max,100%))]"
aria-hidden="true"
>
</span>
<input
type="range"
data-range="min"
id={@id && "#{@id}-min"}
name={@min_name}
value={@lo}
min={@min}
max={@max}
step={@step}
list={@list}
disabled={@disabled}
form={@form}
aria-label={@min_label}
aria-valuetext={@min_valuetext}
class={@input_class}
/>
<input
type="range"
data-range="max"
id={@id && "#{@id}-max"}
name={@max_name}
value={@hi}
min={@min}
max={@max}
step={@step}
list={@list}
disabled={@disabled}
form={@form}
aria-label={@max_label}
aria-valuetext={@max_valuetext}
class={@input_class}
/>
</span>
"""
end
end
1. Save the file
Paste the markup; it relies only on the theme tokens in styles.css.
2. Use it
<span data-slot="range-slider" style="--range-min:20%;--range-max:80%" class="…">
<span class="… bg-muted" aria-hidden="true"></span>
<span class="… bg-primary [left:var(--range-min)] …" aria-hidden="true"></span>
<input type="range" data-range="min" name="min" value="20" min="0" max="100" aria-label="Minimum" class="…">
<input type="range" data-range="max" name="max" value="80" min="0" max="100" aria-label="Maximum" class="…">
</span>View source
<!--
shadcn-htmx — raw HTML range slider (two-thumb) snippet.
Two native <input type="range"> overlaid on one track, each
form-submittable (name="min" / name="max"). The platform gives each
thumb role=slider, aria-valuemin/max/now and the arrow/Home/End/
PageUp/Down keyboard contract for free (WAI-ARIA APG Multi-Thumb
Slider pattern).
The inline IIFE below is the only JS needed: it stops the thumbs
crossing (APG: the lower thumb's max is bounded by the upper thumb,
and vice versa) and keeps --range-min / --range-max in sync so the
coloured fill paints between the thumbs. It relies only on the theme
tokens in styles.css.
-->
<span data-slot="range-slider" style="--range-min:20%;--range-max:80%"
class="relative flex h-4 w-full touch-none items-center select-none">
<span class="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" aria-hidden="true"></span>
<span class="pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary [left:var(--range-min,0%)] [right:calc(100%-var(--range-max,100%))]" aria-hidden="true"></span>
<!-- list: optional native tick marks via a <datalist> (MDN range ref,
"Adding tick marks"). One datalist applies to both thumbs since they
share min/max/step. Remove list="ticks" + the <datalist> to omit. -->
<input type="range" data-range="min" name="min" value="20" min="0" max="100"
list="ticks" aria-label="Minimum"
class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none
[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent
[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent
[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm
[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm
focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)]
disabled:cursor-not-allowed">
<input type="range" data-range="max" name="max" value="80" min="0" max="100"
list="ticks" aria-label="Maximum"
class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none
[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent
[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent
[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm
[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm
focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)]
disabled:cursor-not-allowed">
<datalist id="ticks">
<option value="0"></option>
<option value="25"></option>
<option value="50"></option>
<option value="75"></option>
<option value="100"></option>
</datalist>
</span>
<script>
// Minimal standalone boot for the snippet. In the full library this lives
// in public/site.js (keyed on data-slot="range-slider").
document.querySelectorAll('[data-slot="range-slider"]').forEach(function (root) {
var lo = root.querySelector('input[data-range="min"]')
var hi = root.querySelector('input[data-range="max"]')
if (!lo || !hi) return
var pct = function (input) {
var min = +input.min, max = +input.max
return ((+input.value - min) / ((max - min) || 1)) * 100
}
var sync = function () {
// Thumbs must not cross (APG Multi-Thumb Slider).
if (+lo.value > +hi.value) {
if (document.activeElement === lo) lo.value = hi.value
else hi.value = lo.value
}
root.style.setProperty('--range-min', pct(lo) + '%')
root.style.setProperty('--range-max', pct(hi) + '%')
}
lo.addEventListener('input', sync)
hi.addEventListener('input', sync)
sync()
})
</script>
Examples
Price range
Drag either thumb. Tab focuses each thumb in turn, then ←/→ moves it; the thumbs can't cross.
Two native <input type="range"> submit as min and max. The APG Multi-Thumb Slider pattern says each thumb keeps its own place in the tab sequence and the lower thumb's value is bounded by the upper one — a small script in site.js enforces the clamp and paints the fill.
<Label>Price range</Label>
<RangeSlider id="price" min={0} max={500} step={10}
minValue={120} maxValue={380}
minLabel="Minimum price" maxLabel="Maximum price" />{{ label("Price range") }}
{{ range_slider(id="price", min=0, max=500, step=10, min_value=120, max_value=380,
min_label="Minimum price", max_label="Maximum price") }}{{template "label" (dict "Text" "Price range")}}
{{template "range-slider" (dict "ID" "price" "Min" (ptr 0) "Max" (ptr 500) "Step" (ptr 10) "MinValue" (ptr 120) "MaxValue" (ptr 380) "MinLabel" "Minimum price" "MaxLabel" "Maximum price")}}<.label>Price range</.label>
<.range_slider id="price" min={0} max={500} step={10}
min_value={120} max_value={380}
min_label="Minimum price" max_label="Maximum price" /><div class="grid w-full max-w-md gap-2">
<label class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Price range</label>
<span data-slot="range-slider" style="--range-min:24%;--range-max:76%" class="relative flex h-4 w-full touch-none items-center select-none">
<span class="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" aria-hidden="true">
</span>
<span class="pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary [left:var(--range-min,0%)] [right:calc(100%-var(--range-max,100%))]" aria-hidden="true">
</span>
<input type="range" data-range="min" id="ex-rs-price-min" name="min" value="120" min="0" max="500" step="10" aria-label="Minimum price" class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed"/>
<input type="range" data-range="max" id="ex-rs-price-max" name="max" value="380" min="0" max="500" step="10" aria-label="Maximum price" class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed"/>
</span>
</div>Further reading
Custom min/max/step + value text
min/max/step constrain both thumbs. aria-valuetext gives AT a human-readable value per thumb.
When the raw number isn't self-explanatory, set minValuetext / maxValuetext so screen readers announce "$120" rather than "120".
<RangeSlider id="budget" min={0} max={2000} step={50}
minValue={400} maxValue={1500}
minLabel="Minimum budget" maxLabel="Maximum budget"
minValuetext="$400" maxValuetext="$1500" />{{ range_slider(id="budget", min=0, max=2000, step=50, min_value=400, max_value=1500,
min_label="Minimum budget", max_label="Maximum budget",
min_valuetext="$400", max_valuetext="$1500") }}{{template "range-slider" (dict "ID" "budget" "Min" (ptr 0) "Max" (ptr 2000) "Step" (ptr 50) "MinValue" (ptr 400) "MaxValue" (ptr 1500) "MinLabel" "Minimum budget" "MaxLabel" "Maximum budget" "MinValuetext" "$400" "MaxValuetext" "$1500")}}<.range_slider id="budget" min={0} max={2000} step={50}
min_value={400} max_value={1500}
min_label="Minimum budget" max_label="Maximum budget"
min_valuetext="$400" max_valuetext="$1500" /><div class="grid w-full max-w-md gap-2">
<label class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Budget (USD)</label>
<span data-slot="range-slider" style="--range-min:20%;--range-max:75%" class="relative flex h-4 w-full touch-none items-center select-none">
<span class="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" aria-hidden="true">
</span>
<span class="pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary [left:var(--range-min,0%)] [right:calc(100%-var(--range-max,100%))]" aria-hidden="true">
</span>
<input type="range" data-range="min" id="ex-rs-budget-min" name="min" value="400" min="0" max="2000" step="50" aria-label="Minimum budget" aria-valuetext="$400" class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed"/>
<input type="range" data-range="max" id="ex-rs-budget-max" name="max" value="1500" min="0" max="2000" step="50" aria-label="Maximum budget" aria-valuetext="$1500" class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed"/>
</span>
</div>Further reading
Disabled
Both thumbs are non-focusable and not draggable.
Disabled is the native attribute on each input — the platform removes them from the tab order and blocks keyboard + pointer. We just dim the wrapper.
<RangeSlider minValue={30} maxValue={70} disabled
minLabel="Minimum" maxLabel="Maximum" />{{ range_slider(min_value=30, max_value=70, disabled=true,
min_label="Minimum", max_label="Maximum") }}{{template "range-slider" (dict "MinValue" (ptr 30) "MaxValue" (ptr 70) "Disabled" true "MinLabel" "Minimum" "MaxLabel" "Maximum")}}<.range_slider min_value={30} max_value={70} disabled
min_label="Minimum" max_label="Maximum" /><div class="grid w-full max-w-md gap-2">
<label class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Disabled</label>
<span data-slot="range-slider" data-disabled="true" style="--range-min:30%;--range-max:70%" class="relative flex h-4 w-full touch-none items-center select-none opacity-50" data-test="disabled">
<span class="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" aria-hidden="true">
</span>
<span class="pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary [left:var(--range-min,0%)] [right:calc(100%-var(--range-max,100%))]" aria-hidden="true">
</span>
<input type="range" data-range="min" id="ex-rs-disabled-min" name="min" value="30" min="0" max="100" disabled="" aria-label="Minimum" class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed"/>
<input type="range" data-range="max" id="ex-rs-disabled-max" name="max" value="70" min="0" max="100" disabled="" aria-label="Maximum" class="pointer-events-none absolute inset-x-0 top-1/2 m-0 h-4 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed"/>
</span>
</div>API Reference
<RangeSlider>
| Prop | Type | Default | Description |
|---|---|---|---|
list | string | — | Id of a <datalist> rendering tick marks / snap points on the track. Applied to both thumbs since they share min/max/step.MDN<input type=range> list |
min | number | 0 | Track minimum (shared by both thumbs). |
max | number | 100 | Track maximum (shared by both thumbs). |
step | number | — | Increment per arrow press, applied to both thumbs. |
minValue | number | min | Initial value of the lower thumb. |
maxValue | number | max | Initial value of the upper thumb. |
minName | string | "min" | Form field name submitted for the lower thumb. |
maxName | string | "max" | Form field name submitted for the upper thumb. |
minLabel | string | "Minimum" | Accessible name for the lower thumb (aria-label).APGSlider (Multi-Thumb) |
maxLabel | string | "Maximum" | Accessible name for the upper thumb (aria-label). |
minValuetext | string | — | Human-readable value for the lower thumb (e.g. "$120").MDNaria-valuetext |
maxValuetext | string | — | Human-readable value for the upper thumb (e.g. "$380"). |
ariaLabelledby | string | — | Id of a visible element naming the whole control; applied to both thumbs in place of the per-thumb labels. |
ariaDescribedby | string | — | Id of an element describing the control; applied to both thumbs. |
id | string | — | Base id; the thumbs become id-min / id-max. |
form | string | — | Associate both inputs with a <form> by id. |
disabled | boolean | false | Disable both thumbs — unfocusable, not submitted. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |