Components
Slider
Native <input type="range"> styled with Tailwind. ARIA role / value attributes + the full keyboard contract (arrows, Home/End, PageUp/Down) come from the platform.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/slider.json2. Use it
import { Slider } from "@/components/ui/slider"
<Slider name="volume" value={50} ariaLabel="Volume" />Or copy the source manually
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"
// Slider — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Built on native <input type="range">. The platform gives us:
// - role="slider" implicit
// - aria-valuemin / aria-valuemax / aria-valuenow auto-managed from
// the min/max/value attributes — no manual ARIA needed
// - Arrow keys, Home/End, PageUp/Down keyboard contract
// - Focus ring, disabled state
//
// We restyle the track + thumb via Tailwind v4's [&::-webkit-slider-thumb]
// and [&::-moz-range-thumb] selectors (cross-browser). Both Chromium
// (-webkit-) and Firefox (-moz-) need separate rules.
//
// Refs:
// repos/mdn/files/en-us/web/html/reference/elements/input/range/index.md
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/slider_role/
type SliderProps = {
id?: string
name?: string
value?: number
min?: number
max?: number
// step="any" means no stepping — any value is allowed (barring min/max),
// giving a continuous range. See MDN input/range "Setting step to any".
step?: number | "any"
disabled?: boolean
required?: boolean
// The full <input> form-attr family.
form?: string
list?: string
// ARIA / labelling.
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
// Tooltip-ish announcement for AT — e.g. "$24 / month" instead of "24".
ariaValuetext?: string
class?: ClassValue
// htmx passthrough (e.g. push the new value to the server on change).
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
}
export function Slider(props: SliderProps) {
const {
id,
name,
value,
min = 0,
max = 100,
step,
disabled,
required,
form,
list,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
ariaValuetext,
class: className,
...rest
} = props
return (
<span
data-slot="slider"
data-disabled={disabled ? "true" : undefined}
class={cn(
"relative flex w-full touch-none items-center select-none",
disabled && "opacity-50",
className,
)}
>
<input
type="range"
id={id}
name={name}
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
required={required}
form={form}
list={list}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-valuetext={ariaValuetext}
class={cn(
// Hide the default platform appearance so we can style the
// track + thumb ourselves.
"h-2 w-full cursor-pointer appearance-none bg-transparent outline-none",
"[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-muted",
"[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted",
// Thumb: round, sized, bordered. -webkit needs margin-top to
// recentre on the track; -moz centers automatically.
"[&::-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]: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 ring on the thumb when keyboard-focused.
"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
"disabled:cursor-not-allowed",
)}
{...rest}
/>
</span>
)
}
1. Save the file
Copy slider.html into templates/components/.
2. Use it
{% from "components/slider.html" import slider %}
{{ slider(name="volume", value=50, aria_label="Volume") }}View source
{# Slider macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/slider.tsx. Native <input type="range"> styled. #}
{% macro slider(
id=none, name=none, value=none, min=0, max=100, step=none,
disabled=false, required=false, form=none, list=none,
aria_label=none, aria_labelledby=none, aria_describedby=none, aria_valuetext=none,
extra_class="", **attrs
) %}
<span data-slot="slider"
{%- if disabled %} data-disabled="true"{% endif %}
class="relative flex w-full touch-none items-center select-none {% if disabled %}opacity-50{% endif %} {{ extra_class }}">
<input type="range"
{%- if id %} id="{{ id }}"{% endif %}
{%- if name %} name="{{ name }}"{% endif %}
{%- if value is not none %} value="{{ value }}"{% endif %}
min="{{ min }}" max="{{ max }}"
{# step may be a number or the special string "any" (continuous range) — MDN input/range. #}
{%- if step %} step="{{ step }}"{% endif %}
{%- if disabled %} disabled{% endif %}
{%- if required %} required{% endif %}
{%- if form %} form="{{ form }}"{% endif %}
{%- if list %} list="{{ list }}"{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
{%- if aria_valuetext %} aria-valuetext="{{ aria_valuetext }}"{% endif %}
class="h-2 w-full cursor-pointer appearance-none bg-transparent outline-none
[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-muted
[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted
[&::-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]: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"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
</span>
{% endmacro %}
1. Save the file
Add slider.tmpl alongside button.tmpl.
2. Use it
{{template "slider" (dict "Name" "volume" "Value" (ptr 50) "AriaLabel" "Volume")}}View source
{{/*
Slider template — shadcn-htmx, htmx v4 + Tailwind v4.
type SliderArgs struct {
ID, Name, AriaLabel string
AriaLabelledby, AriaDescribedby, AriaValuetext string
Form, List string
Value, Min, Max *int
// Step is a string so it can hold a number ("0.5") or the special
// value "any" (continuous range) — MDN input/range "Setting step to any".
Step string
Disabled, Required bool
// Everything else (hx-get, hx-target, data-*, …) goes here.
Attrs map[string]string
}
*/}}
{{define "slider"}}
{{- $min := or .Min 0 -}}{{- $max := or .Max 100 -}}
<span data-slot="slider" {{if .Disabled}}data-disabled="true"{{end}}
class="relative flex w-full touch-none items-center select-none {{if .Disabled}}opacity-50{{end}}">
<input type="range"
{{if .ID}}id="{{.ID}}"{{end}} {{if .Name}}name="{{.Name}}"{{end}}
{{if .Value}}value="{{deref .Value}}"{{end}}
min="{{$min}}" max="{{$max}}"
{{if .Step}}step="{{.Step}}"{{end}}
{{if .Disabled}}disabled{{end}} {{if .Required}}required{{end}}
{{if .Form}}form="{{.Form}}"{{end}} {{if .List}}list="{{.List}}"{{end}}
{{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{if .AriaDescribedby}}aria-describedby="{{.AriaDescribedby}}"{{end}}
{{if .AriaValuetext}}aria-valuetext="{{.AriaValuetext}}"{{end}}
class="h-2 w-full cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-muted [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted [&::-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]: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 disabled:cursor-not-allowed"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}>
</span>
{{end}}
1. Save the file
Drop slider.ex into lib/my_app_web/components/.
2. Use it
<.slider name="volume" value={50} aria-label="Volume" />View source
defmodule ShadcnHtmx.Components.Slider do
@moduledoc """
Slider — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Native `<input type="range">` styled via Tailwind. The platform handles
role=slider, aria-valuemin/max/now, arrow/Home/End/PageUp/Down — we
just provide the visual track + thumb.
## Examples
<.slider name="volume" value={50} min={0} max={100} aria-label="Volume" />
"""
use Phoenix.Component
attr :id, :string, default: nil
attr :name, :string, default: nil
attr :value, :integer, default: nil
attr :min, :integer, default: 0
attr :max, :integer, default: 100
# :any so step accepts a number or the special string "any" (continuous
# range) — MDN input/range "Setting step to any".
attr :step, :any, default: nil
attr :disabled, :boolean, default: false
attr :required, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global,
include: ~w(form list aria-label aria-labelledby aria-describedby aria-valuetext)
def slider(assigns) do
~H"""
<span
data-slot="slider"
data-disabled={@disabled && "true"}
class={[
"relative flex w-full touch-none items-center select-none",
@disabled && "opacity-50",
@class
]}
>
<input
type="range"
id={@id}
name={@name}
value={@value}
min={@min}
max={@max}
step={@step}
disabled={@disabled}
required={@required}
class={[
"h-2 w-full cursor-pointer appearance-none bg-transparent outline-none",
"[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-muted",
"[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted",
"[&::-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]: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",
"disabled:cursor-not-allowed"
]}
{@rest}
/>
</span>
"""
end
end
1. Save the file
Tailwind utilities only; no JS.
2. Use it
<span data-slot="slider" class="…">
<input type="range" name="volume" min="0" max="100" value="50"
aria-label="Volume" class="…">
</span>View source
<!--
shadcn-htmx — raw HTML slider snippet.
Native <input type="range"> with Tailwind utilities to style the
track + thumb cross-browser. ARIA + keyboard are handled by the
platform — no JS required.
-->
<span data-slot="slider" class="relative flex w-full touch-none items-center select-none">
<input type="range" name="volume" min="0" max="100" value="50"
aria-label="Volume"
class="h-2 w-full cursor-pointer appearance-none bg-transparent outline-none
[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-muted
[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted
[&::-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]: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
disabled:cursor-not-allowed">
</span>
Examples
Basic — keyboard works out of the box
Tab to focus, then ←/→ increments by step, Home/End jump to ends, PageUp/Down move in bigger steps.
Native <input type="range"> ships with the full APG slider keyboard contract. We add styling, not behaviour. If you need a vertical slider, set orient="vertical" (Firefox) or rotate visually via CSS transform.
<Label htmlFor="vol">Volume</Label>
<Slider id="vol" name="volume" value={50} ariaLabel="Volume" />{{ label("Volume", for_="vol") }}
{{ slider(id="vol", name="volume", value=50, aria_label="Volume") }}{{template "label" (dict "Text" "Volume" "For" "vol")}}
{{template "slider" (dict "ID" "vol" "Name" "volume" "Value" (ptr 50) "AriaLabel" "Volume")}}<.label for="vol">Volume</.label>
<.slider id="vol" name="volume" value={50} aria-label="Volume" /><div class="grid w-full max-w-md gap-2">
<label for="ex-slider-vol" 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">Volume</label>
<span data-slot="slider" class="relative flex w-full touch-none items-center select-none">
<input type="range" id="ex-slider-vol" name="volume" value="50" min="0" max="100" aria-label="Volume" class="h-2 w-full cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-muted [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted [&::-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]: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>Custom range + step
Set min/max/step to constrain the slider. step also controls how much each Arrow press moves the value.
Use aria-valuetext when the visible value isn't self-explanatory. AT users hearing "24" don't know if that's dollars, months, or decibels — "$24 per month" is unambiguous.
<Slider id="price" min={0} max={500} step={25} value={250}
ariaLabel="Monthly budget" ariaValuetext="$250 per month" />{{ slider(id="price", min=0, max=500, step=25, value=250,
aria_label="Monthly budget", aria_valuetext="$250 per month") }}{{template "slider" (dict "ID" "price" "Min" (ptr 0) "Max" (ptr 500) "Step" (ptr 25) "Value" (ptr 250) "AriaLabel" "Monthly budget")}}<.slider id="price" min={0} max={500} step={25} value={250}
aria-label="Monthly budget" aria-valuetext="$250 per month" /><div class="grid w-full max-w-md gap-2">
<label for="ex-slider-price" 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">Monthly budget</label>
<span data-slot="slider" class="relative flex w-full touch-none items-center select-none">
<input type="range" id="ex-slider-price" name="budget" value="250" min="0" max="500" step="25" aria-label="Monthly budget" aria-valuetext="$250 per month" class="h-2 w-full cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-muted [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted [&::-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]: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
Disabled sliders are non-focusable and not draggable.
Disabled is the native attribute — the platform handles keyboard exclusion, mouse cursor, and removes the element from the tab order. We just dim the wrapper.
<Slider value={40} disabled ariaLabel="Disabled slider" />{{ slider(value=40, disabled=true, aria_label="Disabled slider") }}{{template "slider" (dict "Value" (ptr 40) "Disabled" true "AriaLabel" "Disabled slider")}}<.slider value={40} disabled aria-label="Disabled slider" /><div class="grid w-full max-w-md gap-2">
<label for="ex-slider-disabled" 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="slider" data-disabled="true" class="relative flex w-full touch-none items-center select-none opacity-50">
<input type="range" id="ex-slider-disabled" value="40" min="0" max="100" disabled="" aria-label="Disabled slider" class="h-2 w-full cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-muted [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted [&::-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]: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" data-test="disabled"/>
</span>
</div>API Reference
<Slider>
| Prop | Type | Default | Description |
|---|---|---|---|
list | string | — | Id of a <datalist> whose <option> values render tick marks on the track (e.g. temperature presets).MDNrange tick marks |
min | number | 0 | Minimum value. |
max | number | 100 | Maximum value. |
step | number | — | Increment per arrow press. |
value | number | — | Current value. |
ariaValuetext | string | — | Human-readable value (e.g. "$24 per month") for AT.MDNaria-valuetext |
id | string | — | Pairs the input with a <label for>. |
name | string | — | Form field name on submit. |
required | boolean | false | Native HTML required for form validation. |
disabled | boolean | false | Disable — unfocusable, not submitted. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |