shshadcn-htmx

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.json

2. Use it

components/ui/range-slider.tsx
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
components/ui/range-slider.tsx
/** @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

templates/components/range-slider.html
{% 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
templates/components/range-slider.html
{# 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

components/range-slider.tmpl
{{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
components/range-slider.tmpl
{{/*
  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

lib/my_app_web/components/range_slider.ex
<.range_slider min={0} max={500} step={10}
  min_value={120} max_value={380}
  min_label="Minimum price" max_label="Maximum price" />
View source
lib/my_app_web/components/range_slider.ex
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

snippets/range-slider.html
<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
snippets/range-slider.html
<!--
  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 [&amp;::-webkit-slider-runnable-track]:h-1.5 [&amp;::-webkit-slider-runnable-track]:bg-transparent [&amp;::-moz-range-track]:h-1.5 [&amp;::-moz-range-track]:bg-transparent [&amp;::-webkit-slider-thumb]:pointer-events-auto [&amp;::-webkit-slider-thumb]:appearance-none [&amp;::-webkit-slider-thumb]:-mt-1 [&amp;::-webkit-slider-thumb]:size-4 [&amp;::-webkit-slider-thumb]:rounded-full [&amp;::-webkit-slider-thumb]:border-2 [&amp;::-webkit-slider-thumb]:border-primary [&amp;::-webkit-slider-thumb]:bg-background [&amp;::-webkit-slider-thumb]:shadow-sm [&amp;::-moz-range-thumb]:pointer-events-auto [&amp;::-moz-range-thumb]:size-4 [&amp;::-moz-range-thumb]:rounded-full [&amp;::-moz-range-thumb]:border-2 [&amp;::-moz-range-thumb]:border-primary [&amp;::-moz-range-thumb]:bg-background [&amp;::-moz-range-thumb]:shadow-sm focus-visible:[&amp;::-webkit-slider-thumb]:ring-[3px] focus-visible:[&amp;::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&amp;::-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 [&amp;::-webkit-slider-runnable-track]:h-1.5 [&amp;::-webkit-slider-runnable-track]:bg-transparent [&amp;::-moz-range-track]:h-1.5 [&amp;::-moz-range-track]:bg-transparent [&amp;::-webkit-slider-thumb]:pointer-events-auto [&amp;::-webkit-slider-thumb]:appearance-none [&amp;::-webkit-slider-thumb]:-mt-1 [&amp;::-webkit-slider-thumb]:size-4 [&amp;::-webkit-slider-thumb]:rounded-full [&amp;::-webkit-slider-thumb]:border-2 [&amp;::-webkit-slider-thumb]:border-primary [&amp;::-webkit-slider-thumb]:bg-background [&amp;::-webkit-slider-thumb]:shadow-sm [&amp;::-moz-range-thumb]:pointer-events-auto [&amp;::-moz-range-thumb]:size-4 [&amp;::-moz-range-thumb]:rounded-full [&amp;::-moz-range-thumb]:border-2 [&amp;::-moz-range-thumb]:border-primary [&amp;::-moz-range-thumb]:bg-background [&amp;::-moz-range-thumb]:shadow-sm focus-visible:[&amp;::-webkit-slider-thumb]:ring-[3px] focus-visible:[&amp;::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&amp;::-moz-range-thumb]:shadow-[0_0_0_3px_color-mix(in_oklch,var(--color-ring)_50%,transparent)] disabled:cursor-not-allowed"/>
  </span>
</div>

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 [&amp;::-webkit-slider-runnable-track]:h-1.5 [&amp;::-webkit-slider-runnable-track]:bg-transparent [&amp;::-moz-range-track]:h-1.5 [&amp;::-moz-range-track]:bg-transparent [&amp;::-webkit-slider-thumb]:pointer-events-auto [&amp;::-webkit-slider-thumb]:appearance-none [&amp;::-webkit-slider-thumb]:-mt-1 [&amp;::-webkit-slider-thumb]:size-4 [&amp;::-webkit-slider-thumb]:rounded-full [&amp;::-webkit-slider-thumb]:border-2 [&amp;::-webkit-slider-thumb]:border-primary [&amp;::-webkit-slider-thumb]:bg-background [&amp;::-webkit-slider-thumb]:shadow-sm [&amp;::-moz-range-thumb]:pointer-events-auto [&amp;::-moz-range-thumb]:size-4 [&amp;::-moz-range-thumb]:rounded-full [&amp;::-moz-range-thumb]:border-2 [&amp;::-moz-range-thumb]:border-primary [&amp;::-moz-range-thumb]:bg-background [&amp;::-moz-range-thumb]:shadow-sm focus-visible:[&amp;::-webkit-slider-thumb]:ring-[3px] focus-visible:[&amp;::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&amp;::-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 [&amp;::-webkit-slider-runnable-track]:h-1.5 [&amp;::-webkit-slider-runnable-track]:bg-transparent [&amp;::-moz-range-track]:h-1.5 [&amp;::-moz-range-track]:bg-transparent [&amp;::-webkit-slider-thumb]:pointer-events-auto [&amp;::-webkit-slider-thumb]:appearance-none [&amp;::-webkit-slider-thumb]:-mt-1 [&amp;::-webkit-slider-thumb]:size-4 [&amp;::-webkit-slider-thumb]:rounded-full [&amp;::-webkit-slider-thumb]:border-2 [&amp;::-webkit-slider-thumb]:border-primary [&amp;::-webkit-slider-thumb]:bg-background [&amp;::-webkit-slider-thumb]:shadow-sm [&amp;::-moz-range-thumb]:pointer-events-auto [&amp;::-moz-range-thumb]:size-4 [&amp;::-moz-range-thumb]:rounded-full [&amp;::-moz-range-thumb]:border-2 [&amp;::-moz-range-thumb]:border-primary [&amp;::-moz-range-thumb]:bg-background [&amp;::-moz-range-thumb]:shadow-sm focus-visible:[&amp;::-webkit-slider-thumb]:ring-[3px] focus-visible:[&amp;::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&amp;::-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 [&amp;::-webkit-slider-runnable-track]:h-1.5 [&amp;::-webkit-slider-runnable-track]:bg-transparent [&amp;::-moz-range-track]:h-1.5 [&amp;::-moz-range-track]:bg-transparent [&amp;::-webkit-slider-thumb]:pointer-events-auto [&amp;::-webkit-slider-thumb]:appearance-none [&amp;::-webkit-slider-thumb]:-mt-1 [&amp;::-webkit-slider-thumb]:size-4 [&amp;::-webkit-slider-thumb]:rounded-full [&amp;::-webkit-slider-thumb]:border-2 [&amp;::-webkit-slider-thumb]:border-primary [&amp;::-webkit-slider-thumb]:bg-background [&amp;::-webkit-slider-thumb]:shadow-sm [&amp;::-moz-range-thumb]:pointer-events-auto [&amp;::-moz-range-thumb]:size-4 [&amp;::-moz-range-thumb]:rounded-full [&amp;::-moz-range-thumb]:border-2 [&amp;::-moz-range-thumb]:border-primary [&amp;::-moz-range-thumb]:bg-background [&amp;::-moz-range-thumb]:shadow-sm focus-visible:[&amp;::-webkit-slider-thumb]:ring-[3px] focus-visible:[&amp;::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&amp;::-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 [&amp;::-webkit-slider-runnable-track]:h-1.5 [&amp;::-webkit-slider-runnable-track]:bg-transparent [&amp;::-moz-range-track]:h-1.5 [&amp;::-moz-range-track]:bg-transparent [&amp;::-webkit-slider-thumb]:pointer-events-auto [&amp;::-webkit-slider-thumb]:appearance-none [&amp;::-webkit-slider-thumb]:-mt-1 [&amp;::-webkit-slider-thumb]:size-4 [&amp;::-webkit-slider-thumb]:rounded-full [&amp;::-webkit-slider-thumb]:border-2 [&amp;::-webkit-slider-thumb]:border-primary [&amp;::-webkit-slider-thumb]:bg-background [&amp;::-webkit-slider-thumb]:shadow-sm [&amp;::-moz-range-thumb]:pointer-events-auto [&amp;::-moz-range-thumb]:size-4 [&amp;::-moz-range-thumb]:rounded-full [&amp;::-moz-range-thumb]:border-2 [&amp;::-moz-range-thumb]:border-primary [&amp;::-moz-range-thumb]:bg-background [&amp;::-moz-range-thumb]:shadow-sm focus-visible:[&amp;::-webkit-slider-thumb]:ring-[3px] focus-visible:[&amp;::-webkit-slider-thumb]:ring-ring/50 focus-visible:[&amp;::-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>

PropTypeDefaultDescription
liststring
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
minnumber0
Track minimum (shared by both thumbs).
maxnumber100
Track maximum (shared by both thumbs).
stepnumber
Increment per arrow press, applied to both thumbs.
minValuenumbermin
Initial value of the lower thumb.
maxValuenumbermax
Initial value of the upper thumb.
minNamestring"min"
Form field name submitted for the lower thumb.
maxNamestring"max"
Form field name submitted for the upper thumb.
minLabelstring"Minimum"
Accessible name for the lower thumb (aria-label).APGSlider (Multi-Thumb)
maxLabelstring"Maximum"
Accessible name for the upper thumb (aria-label).
minValuetextstring
Human-readable value for the lower thumb (e.g. "$120").MDNaria-valuetext
maxValuetextstring
Human-readable value for the upper thumb (e.g. "$380").
ariaLabelledbystring
Id of a visible element naming the whole control; applied to both thumbs in place of the per-thumb labels.
ariaDescribedbystring
Id of an element describing the control; applied to both thumbs.
idstring
Base id; the thumbs become id-min / id-max.
formstring
Associate both inputs with a <form> by id.
disabledbooleanfalse
Disable both thumbs — unfocusable, not submitted.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference