Range slider
StableThe lux-range finish for a native range input: a hairline track that fills to the thumb and a lit pearl thumb.
There is no component: put lux-range on an <input type="range"> and set --pct to how far along the value is. The browser keeps the keyboard, touch and screen reader support. Pair it with a visible label and an <output>, and say the value with its unit in aria-valuetext.
Focus length
import { useId, useState, type CSSProperties } from 'react'
const MIN = 5const MAX = 90
/** * A native range input with the lux-range finish. --pct fills the track up to the thumb, the * <output> shows the value, and aria-valuetext says it with its unit (“25 minutes”). */export default function RangeFocusLength() { const id = useId() const [minutes, setMinutes] = useState(25) const pct = ((minutes - MIN) / (MAX - MIN)) * 100
return ( <div className="flex w-full max-w-sm flex-col gap-1.5"> <div className="flex items-baseline justify-between gap-3"> <label htmlFor={id} className="text-[13px] font-medium text-fg"> Focus length </label>Usage
import '@rchursin/product.css' // once, in your app entry: lux-range ships with the finishing layer<input id={id} type="range" min={5} max={90} step={5} value={minutes} onChange={(e) => setMinutes(Number(e.target.value))} aria-valuetext={`${minutes} minutes`} className="lux-range w-full text-fg" style={{ '--pct': `${((minutes - 5) / 85) * 100}%` } as CSSProperties}/>Examples
In a settings panel
Two sliders with different ranges. One small helper turns any value, min and max into --pct.
import { useId, useState, type CSSProperties } from 'react'
/** --pct for any min and max: how far along the track the value sits, as a percentage */const fill = (value: number, min: number, max: number) => ({ '--pct': `${((value - min) / (max - min)) * 100}%` }) as CSSProperties
/** * Two sliders in a settings panel. The track takes its colour from the text colour * (text-fg here), so it follows the theme. Home and End jump to the ends. */export default function RangeBreaks() { const id = useId() const [shortBreak, setShortBreak] = useState(5) const [rounds, setRounds] = useState(4)
return ( <div className="lux-card flex w-full max-w-sm flex-col gap-5 rounded-2xl bg-raised p-5"> <div className="flex flex-col gap-1"> <div className="flex items-baseline justify-between gap-3"> <label htmlFor={`${id}-break`} className="text-[13px] font-medium text-fg"> Short breakAPI
Attributes to set on the <input>
| Prop | Type | Default | Description |
|---|---|---|---|
typerequired | 'range' | – | The native slider. lux-range only restyles it. |
min, max, step | number | 0, 100, 1 | The scale, as on any range input. Use the same min and max to compute --pct. |
value / onChange | number / ChangeEventHandler | – | Controlled value. e.target.value is a string: convert it with Number(). |
aria-valuetext | string | – | The value in words with its unit, e.g. “25 minutes”. Without it, screen readers say “25”. |
style | { '--pct': string } | – | How much of the track to fill: ((value − min) / (max − min)) × 100, with a % sign. |
className | string | – | "lux-range w-full text-fg": the track takes its colour from the text colour. |
CSS
| Class or variable | What it does |
|---|---|
lux-range | Removes the native look. The input is 44px tall, so the whole strip is the touch target; the track is 4px with round ends. |
--pct | The filled share of the track, as a percentage. Defaults to 50% when unset. |
currentColor | The filled part is the text colour and the rest is 14% of it, so text-fg makes it follow the theme. |
thumb | A 22px pearl with a hairline ring and a soft shadow. It grows to 1.12× while pressed. On keyboard focus it gets a 2px ring in the text colour outside a 2px gap in the surface colour. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Right arrowUp arrow | Increases the value by one step. |
| Left arrowDown arrow | Decreases the value by one step. |
| Page UpPage Down | Moves by a larger amount (about a tenth of the range in most browsers). |
| HomeEnd | Jumps to the minimum or the maximum. |
- A native range input, so it is announced as a slider with its value, and every browser handles keys, touch and drag.
- Name it with a visible <label for>. aria-valuetext adds the unit; aria-describedby can point at a hint.
- The <output for> shows the value next to the label. The ends of the scale are aria-hidden in the example because the slider already reports its range.
- The focus ring is drawn around the thumb (the default outline is removed): 2px of the text colour beyond a 2px gap in the surface colour. The gap keeps it apart from the pale thumb, and against the page it is well over 3:1 in either theme.
Guidelines
Do
- Use a slider when the exact number matters less than the rough amount.
- Show the current value next to the label, with its unit.
- Set aria-valuetext whenever the number needs a unit to make sense.
Don’t
- Don’t use a slider for exact values people type: use NumberField.
- Don’t leave out --pct: the track would always look half full.
In use
Related
- Number fieldA number field with − and + steppers, following the APG spinbutton: arrows step, Page keys step ×10, Home and End jump.
- Cut calculatorTwo sliders estimate the tokens idle skills add to every turn, beside a grid with one square per skill.
- Focus sessionA Pomodoro session: choose a task, say what you’ll finish, then focus and break in phases, with overtime and an inline Abandon.
Source
The component’s own files, exactly as they run on this site.
/* RChursin_ finishing layer ("luxe"), ported from the RChursin_ UI kit (rchursin/styles/luxe.css). Needs the --ease-lux tokens in brand.css. Site changes would be marked "Site change:"; there are none, so `node .claude/skills/porting-ui-kit/scripts/kit.ts diff` should report this file in sync. */
/* RChursin_ · the finishing layer ("luxe")
The details that make the kit feel made, not generated: layered light on buttons, hairlines that catch the light, surfaces that lift, a whisper of film grain, and motion that is quick to answer and slow to arrive.
Rules this file follows - One light source, straight above: highlights on top edges, shades below. - Depth comes from hairlines and surface tone first; shadows stay faint. - Clicks answer in ≤200ms. Ambient arrivals take 600–1100ms. Exits are faster than entrances. - Movement only runs when the visitor allows motion; colour and opacity changes stay. - Nothing loops forever on its own. Everything sits in @layer components, so Tailwind utilities on the same element still win.*/