Range slider
A range with two thumbs after the APG multi thumb slider. The thumbs never cross, values read in the format of the language, with marks, a histogram behind the track, coupled fields and a vertical form.
A range from here to there
A range slider picks a span, a price from and to, a time window or a band of temperatures. It follows the APG multi thumb slider, the two thumbs never cross and keep a minimum gap, and number fields beside it stay in sync for people who prefer to type.
Price with histogram is the shop filter, bars behind the track show how prices spread, the ones inside the range light up and the result count below follows. Time window works in quarter hours, 10:00 or 10:00 AM by language, and keeps a minimum length so the thumbs stop before they meet. Vertical shows two upright ranges for a thermostat, degrees with a unit and humidity in percent, Page keys take big steps and the value rides beside the thumb. Scale with marks moves in whole steps on a labelled scale for years or named levels, and both thumbs may rest on one level.
Show the result count while people drag, it tells them when to stop. Format values the way people say them, money with currency, times by language. Keep typing possible next to the thumbs, exact values are faster to type than to drag.
Variants
Details
What Range slider brings along, read from the registry and the source.
- Category
- Forms and inputs
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/range-slider.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- English and German, 4 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RangeSliderProps interface from components/ui/range-slider.tsx, with the comments from the code.
labeloptionalstringhintoptionalstringminoptionalnumbermaxoptionalnumberstepoptionalnumberbigStepoptionalnumberSteps taken by Page Up and Page Down, default a tenth of the range
minGapoptionalnumberSmallest distance between the two thumbs, default one step. The thumbs never cross.
valueoptional[number, number]defaultValueoptional[number, number]onValueChangeoptional(value: [number, number]) => voidonValueCommitoptional(value: [number, number]) => voidCalled once a drag or a key press is done, for filters that load results
formatoptionalRangeFormatnumbercurrencypercenttimeunit
currencyoptionalstringISO currency for format "currency", default EUR in German and USD in English
unitoptionalstringUnit for format "unit", e.g. m², °C, km
decimalsoptionalnumbervalueLabelsoptionalstring[]A text per value (index from min), e.g. sizes XS to XXL. Wins over format.
formatValueoptional(value: number) => stringYour own formatter, wins over format
openEndoptionalbooleanThe top value reads as "and more", e.g. 5.000 €+
marksoptionalRangeMark[]histogramoptionalnumber[]Bars behind the track, one per bucket from min to max. Bars inside the range light up.
resultTextoptionalstringA line under the track counted from the histogram, with {count} and {total}, e.g. "{count} of {total} bikes"
inputsoptionalbooleanTwo number fields under the track, coupled both ways
inputLabelsoptional[string, string]Names of the two fields (or hidden inputs), default label texts Minimum and Maximum
orientationoptional'horizontal' | 'vertical'lengthoptionalnumberHeight of a vertical slider in px
nameoptionalstringForm field name, sends name-min and name-max
disabledoptionalboolean
Built-in texts
Range slider writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 4 texts
| Key | English | German |
|---|---|---|
| min | English Minimum | German Minimum |
| max | English Maximum | German Maximum |
| to | English to | German bis |
| range | English Range | German Bereich |
Accessibility
Read from the source of the component.
- Range slider handles the keys Enter and Space itself.
- Sets aria-describedby, aria-disabled, aria-label, aria-labelledby, aria-live, aria-orientation, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles group and slider.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 5 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 522 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, formatTime, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
const TEXTS = {
en: { min: 'Minimum', max: 'Maximum', to: 'to', range: 'Range' },
de: { min: 'Minimum', max: 'Maximum', to: 'bis', range: 'Bereich' },
};
/** How values read: plain number, money, percent, clock time (minutes after midnight) or a number with a unit */
export type RangeFormat = 'number' | 'currency' | 'percent' | 'time' | 'unit';
export interface RangeMark {
value: number;
label?: string;
/** Label hidden on phones, so dense scales stay readable */
minor?: boolean;
}
export interface RangeSliderProps {
label?: string;
hint?: string;
min?: number;
max?: number;
step?: number;
/** Steps taken by Page Up and Page Down, default a tenth of the range */
bigStep?: number;
/** Smallest distance between the two thumbs, default one step. The thumbs never cross. */
minGap?: number;
value?: [number, number];
defaultValue?: [number, number];
onValueChange?: (value: [number, number]) => void;
/** Called once a drag or a key press is done, for filters that load results */
onValueCommit?: (value: [number, number]) => void;
format?: RangeFormat;
/** ISO currency for format "currency", default EUR in German and USD in English */
currency?: string;
/** Unit for format "unit", e.g. m², °C, km */
unit?: string;
decimals?: number;
/** A text per value (index from min), e.g. sizes XS to XXL. Wins over format. */
valueLabels?: string[];
/** Your own formatter, wins over format */
formatValue?: (value: number) => string;
/** The top value reads as "and more", e.g. 5.000 €+ */
openEnd?: boolean;
marks?: RangeMark[];
/** Bars behind the track, one per bucket from min to max. Bars inside the range light up. */
histogram?: number[];
/** A line under the track counted from the histogram, with {count} and {total}, e.g. "{count} of {total} bikes" */
resultText?: string;
/** Two number fields under the track, coupled both ways */
inputs?: boolean;
/** Names of the two fields (or hidden inputs), default label texts Minimum and Maximum */
inputLabels?: [string, string];
orientation?: 'horizontal' | 'vertical';
/** Height of a vertical slider in px */
length?: number;
/** Form field name, sends name-min and name-max */
name?: string;
disabled?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
/** A value in the format of the language */
export function formatRange(v: number, locale: Locale, o: Pick<RangeSliderProps, 'format' | 'currency' | 'unit' | 'decimals' | 'valueLabels'> = {}, min = 0) {
if (o.valueLabels?.length) return o.valueLabels[Math.round(v - min)] ?? String(v);
const digits = o.decimals ?? 0;
switch (o.format) {
case 'currency':
return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency: o.currency ?? (locale === 'de' ? 'EUR' : 'USD'), minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v);
case 'percent':
return formatPercent(v, locale, digits || undefined);
case 'time': {
const m = Math.round(v);
return formatTime(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`, locale);
}
case 'unit':
return `${new Intl.NumberFormat(TAG[locale], { maximumFractionDigits: digits }).format(v)} ${o.unit ?? ''}`.trim();
default:
return new Intl.NumberFormat(TAG[locale], { maximumFractionDigits: digits || 2 }).format(v);
}
}
/** Reads a typed number in the format of the language ("1.250,5" or "1,250.5") */
function parseNumber(text: string, locale: Locale) {
const dec = locale === 'de' ? ',' : '.';
const clean = text.replace(new RegExp(`[^0-9\\-${dec === ',' ? ',' : '.'}]`, 'g'), '').replace(dec, '.');
const n = Number.parseFloat(clean);
return Number.isFinite(n) ? n : null;
}
const snap = (v: number, min: number, step: number) => round(min + Math.round((v - min) / step) * step, 6);
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
/**
* A range with two thumbs after the APG multi-thumb slider: arrows, Page Up and Down, Home and End,
* the thumbs never cross. Marks with labels, a histogram behind the track, two coupled fields and
* values in the format of the language (money, clock time, percent, units). Horizontal or vertical.
* <RangeSlider label="Price" min={0} max={5000} step={50} format="currency" histogram={[…]} inputs />
*/
export function RangeSlider({
label,
hint,
min = 0,
max = 100,
step = 1,
bigStep,
minGap,
value: valueProp,
defaultValue,
onValueChange,
onValueCommit,
format = 'number',
currency,
unit,
decimals,
valueLabels,
formatValue,
openEnd,
marks,
histogram,
resultText,
inputs,
inputLabels,
orientation = 'horizontal',
length = 240,
name,
disabled,
labels,
className,
}: RangeSliderProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const id = useId();
const vertical = orientation === 'vertical';
const span = max - min || 1;
const gap = minGap ?? step;
const big = bigStep ?? Math.max(step, snap(span / 10, 0, step));
const [inner, setInner] = useState<[number, number]>(defaultValue ?? [min, max]);
const value: [number, number] = valueProp ?? inner;
const latest = useRef(value);
latest.current = value;
const fmtOpts = { format, currency, unit, decimals, valueLabels };
const fmt = (v: number) => {
const s = formatValue ? formatValue(v) : formatRange(v, locale, fmtOpts, min);
return openEnd && v >= max ? `${s}+` : s;
};
const pct = (v: number) => round(((v - min) / span) * 100, 3);
const names = inputLabels ?? [t.min, t.max];
const prefix = typeof label === 'string' && label ? `${label}, ` : '';
const set = (i: 0 | 1, raw: number, commit = false) => {
const cur = latest.current;
const lo = i === 0 ? min : cur[0] + gap;
const hi = i === 0 ? cur[1] - gap : max;
const v = clamp(snap(raw, min, step), lo, hi);
const next: [number, number] = i === 0 ? [v, cur[1]] : [cur[0], v];
if (next[0] !== cur[0] || next[1] !== cur[1]) {
latest.current = next;
setInner(next);
onValueChange?.(next);
}
if (commit) onValueCommit?.(next);
};
/* ---------- pointer ---------- */
const railRef = useRef<HTMLDivElement>(null);
const thumbRefs = [useRef<HTMLSpanElement>(null), useRef<HTMLSpanElement>(null)] as const;
const [active, setActive] = useState<0 | 1 | null>(null);
const drag = useRef<0 | 1 | null>(null);
const fromPointer = (e: React.PointerEvent) => {
const r = railRef.current!.getBoundingClientRect();
const p = vertical ? (r.bottom - e.clientY) / r.height : (e.clientX - r.left) / r.width;
return min + clamp(p, 0, 1) * span;
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (disabled || e.button !== 0) return;
e.preventDefault();
const v = fromPointer(e);
const cur = latest.current;
const d0 = Math.abs(v - cur[0]);
const d1 = Math.abs(v - cur[1]);
// Equal distance: the thumb on the side the pointer is heading to
const i: 0 | 1 = d0 < d1 ? 0 : d1 < d0 ? 1 : v < cur[0] ? 0 : 1;
drag.current = i;
setActive(i);
e.currentTarget.setPointerCapture(e.pointerId);
thumbRefs[i].current?.focus({ preventScroll: true });
set(i, v);
};
const onPointerMove = (e: React.PointerEvent) => {
if (drag.current === null) return;
set(drag.current, fromPointer(e));
};
const endDrag = () => {
if (drag.current === null) return;
drag.current = null;
setActive(null);
onValueCommit?.(latest.current);
};
/* ---------- keyboard ---------- */
const onKeyDown = (i: 0 | 1) => (e: React.KeyboardEvent) => {
if (disabled) return;
const v = latest.current[i];
const map: Record<string, number> = {
ArrowRight: v + step,
ArrowUp: v + step,
ArrowLeft: v - step,
ArrowDown: v - step,
PageUp: v + big,
PageDown: v - big,
Home: i === 0 ? min : latest.current[0] + gap,
End: i === 0 ? latest.current[1] - gap : max,
};
if (!(e.key in map)) return;
e.preventDefault();
set(i, map[e.key], true);
};
/* ---------- fields ---------- */
const [drafts, setDrafts] = useState<[string | null, string | null]>([null, null]);
const isTime = format === 'time' && !valueLabels;
const fieldValue = (v: number) => {
if (isTime) {
const m = Math.round(v);
return `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
}
return new Intl.NumberFormat(TAG[locale], { maximumFractionDigits: decimals ?? 2, useGrouping: true }).format(v);
};
const commitField = (i: 0 | 1, text: string) => {
setDrafts((d) => (i === 0 ? [null, d[1]] : [d[0], null]));
let n: number | null;
if (isTime) {
const m = /^(\d{1,2}):(\d{2})$/.exec(text);
n = m ? Number(m[1]) * 60 + Number(m[2]) : null;
} else n = parseNumber(text, locale);
if (n !== null) set(i, n, true);
};
const affix = format === 'currency' ? currencyAffix(locale, currency) : format === 'percent' ? { suffix: '%' } : format === 'unit' && unit ? { suffix: unit } : {};
/* ---------- result count from the histogram ---------- */
const buckets = histogram?.length ?? 0;
const inRange = (i: number) => {
const lo = min + (i / buckets) * span;
const hi = min + ((i + 1) / buckets) * span;
return hi > value[0] && lo < value[1];
};
const total = histogram?.reduce((a, b) => a + b, 0) ?? 0;
const count = histogram?.reduce((a, b, i) => a + (inRange(i) ? b : 0), 0) ?? 0;
const peak = Math.max(1, ...(histogram ?? [1]));
const nf = new Intl.NumberFormat(TAG[locale]);
// Fill and bars grow in once the slider scrolls into view
const [viewRef, inView] = useInView<HTMLDivElement>();
// The bars rise one after another the first time, later changes only recolor them
const [settled, setSettled] = useState(false);
useEffect(() => {
if (!inView) return;
const timer = window.setTimeout(() => setSettled(true), 1400);
return () => window.clearTimeout(timer);
}, [inView]);
const readout = `${fmt(value[0])} ${t.to} ${fmt(value[1])}`;
const marksWithLabels = marks?.filter((m) => m.label) ?? [];
const thumb = (i: 0 | 1) => {
const v = value[i];
const lo = i === 0 ? min : value[0] + gap;
const hi = i === 0 ? value[1] - gap : max;
return (
<span
key={i}
ref={thumbRefs[i]}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={`${prefix}${names[i]}`}
aria-valuemin={lo}
aria-valuemax={hi}
aria-valuenow={v}
aria-valuetext={fmt(v)}
aria-orientation={orientation}
aria-disabled={disabled || undefined}
onKeyDown={onKeyDown(i)}
className={cn(
'absolute z-10 size-5 rounded-full border-[1.5px] border-primary bg-background shadow-[var(--shadow-md)] outline-none',
'transition-[scale,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none',
// 44 px to grab on phones, the visible thumb stays small
'before:absolute before:-inset-3 before:rounded-full',
vertical ? 'left-1/2 -translate-x-1/2 translate-y-1/2' : 'top-1/2 -translate-x-1/2 -translate-y-1/2',
disabled ? 'cursor-not-allowed border-border-strong' : 'cursor-grab hover:shadow-[0_0_0_5px_color-mix(in_oklab,var(--primary)_14%,transparent),var(--shadow-md)] active:cursor-grabbing',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary',
active === i && 'scale-[1.2] shadow-[0_0_0_7px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-md)] motion-reduce:scale-100',
// The upper thumb lies on top while both sit at the top end, the lower one when both sit at the bottom
i === 0 && v > min + span * 0.5 && 'z-20',
)}
style={vertical ? { bottom: `${pct(v)}%` } : { left: `${pct(v)}%` }}
>
{/* The value rides along while the thumb is held */}
<span
aria-hidden
className={cn(
'pointer-events-none absolute whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-xs font-medium tabular-nums text-background shadow-[var(--shadow-md)] transition-[opacity,scale] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none',
vertical ? 'left-[calc(100%+12px)] top-1/2 -translate-y-1/2 origin-left' : 'bottom-[calc(100%+10px)] left-1/2 -translate-x-1/2 origin-bottom',
active === i ? 'scale-100 opacity-100' : 'invisible scale-90 opacity-0',
)}
>
{fmt(v)}
</span>
</span>
);
};
const fill = (
<div
className="absolute overflow-hidden rounded-full"
style={vertical ? { left: 0, right: 0, bottom: `${pct(value[0])}%`, height: `${round(pct(value[1]) - pct(value[0]), 3)}%` } : { top: 0, bottom: 0, left: `${pct(value[0])}%`, width: `${round(pct(value[1]) - pct(value[0]), 3)}%` }}
>
<div className={cn('size-full rounded-full bg-primary transition-[scale] duration-[900ms] ease-[var(--ease-kit)] motion-reduce:transition-none', vertical ? 'origin-bottom' : 'origin-center', !inView && (vertical ? 'scale-y-0' : 'scale-x-0'), disabled && 'bg-border-strong')} />
</div>
);
const track = (
<div
ref={viewRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
className={cn('relative touch-none select-none', vertical ? 'w-11' : 'h-11 w-full', disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer')}
style={vertical ? { height: length } : undefined}
>
{/* Inset by the thumb radius, so min and max sit under the thumb centers */}
<div ref={railRef} className={cn('absolute', vertical ? 'inset-y-2.5 left-1/2 w-1.5 -translate-x-1/2' : 'inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2')}>
<div aria-hidden className="absolute inset-0 rounded-full bg-track" />
<div aria-hidden className="absolute inset-0">
{fill}
{marks?.map((m) => (
<span
key={m.value}
className={cn(
'absolute size-1 rounded-full transition-colors duration-300',
vertical ? 'left-1/2 -translate-x-1/2 translate-y-1/2' : 'top-1/2 -translate-x-1/2 -translate-y-1/2',
m.value > value[0] && m.value < value[1] ? 'bg-primary-foreground/75' : 'bg-[color-mix(in_oklab,var(--foreground)_34%,transparent)]',
)}
style={vertical ? { bottom: `${pct(m.value)}%` } : { left: `${pct(m.value)}%` }}
/>
))}
</div>
{thumb(0)}
{thumb(1)}
</div>
</div>
);
const markLabels = marksWithLabels.length > 0 && (
<div aria-hidden className={cn('relative text-xs tabular-nums text-subtle-foreground', vertical ? 'my-2.5 w-14' : 'mx-2.5 h-4')} style={vertical ? { height: length - 20 } : undefined}>
{marksWithLabels.map((m) => (
<span
key={m.value}
className={cn(
'absolute whitespace-nowrap transition-colors duration-300',
m.value >= value[0] && m.value <= value[1] && 'text-muted-foreground',
m.minor && 'max-sm:hidden',
vertical
? 'left-0 translate-y-1/2 leading-none'
: cn('top-0', m.value <= min ? 'translate-x-[-10px]' : m.value >= max ? 'translate-x-[calc(-100%+10px)]' : '-translate-x-1/2'),
)}
style={vertical ? { bottom: `${pct(m.value)}%` } : { left: `${pct(m.value)}%` }}
>
{m.label}
</span>
))}
</div>
);
const bars = histogram && histogram.length > 0 && (
<div aria-hidden className="mx-2.5 flex h-14 items-end gap-[2px] sm:h-16">
{histogram.map((h, i) => (
<span
key={i}
className={cn(
'min-w-0 flex-1 origin-bottom rounded-t-[3px] transition-[background-color,scale] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
inRange(i) ? 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--card))]' : 'bg-track',
!inView && 'scale-y-0',
)}
style={{ height: `${round(Math.max(4, (h / peak) * 100), 2)}%`, transitionDelay: settled ? undefined : `${i * 14}ms` }}
/>
))}
</div>
);
const fields = inputs && (
<div className="mt-3 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-end gap-2.5">
{([0, 1] as const).map((i) => (
<FieldBox
key={i}
id={`${id}-f${i}`}
label={names[i]}
prefix={'prefix' in affix ? affix.prefix : undefined}
suffix={'suffix' in affix ? affix.suffix : undefined}
type={isTime ? 'time' : 'text'}
step={isTime ? step * 60 : undefined}
value={drafts[i] ?? fieldValue(value[i])}
name={name ? `${name}-${i ? 'max' : 'min'}` : undefined}
disabled={disabled}
className={i === 1 ? 'col-start-3' : undefined}
onChange={(text) => {
setDrafts((d) => (i === 0 ? [text, d[1]] : [d[0], text]));
if (isTime) commitField(i, text);
}}
onCommit={(text) => commitField(i, text)}
/>
)).flatMap((el, i) => (i === 1 ? [<span key="to" aria-hidden className="col-start-2 row-start-1 grid h-11 place-items-center text-sm text-subtle-foreground">{t.to}</span>, el] : [el]))}
</div>
);
return (
<div role="group" aria-labelledby={label ? `${id}-label` : undefined} aria-label={label ? undefined : t.range} aria-describedby={hint ? `${id}-hint` : undefined} className={cn('grid w-full min-w-0 gap-1', vertical && 'w-auto justify-items-start', className)}>
{(label || !inputs) && (
<div className={cn('flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5', vertical && 'w-full flex-col items-start')}>
{label && (
<span id={`${id}-label`} className="text-sm font-medium text-foreground">
{label}
</span>
)}
<output aria-live="off" className={cn('text-sm font-medium tabular-nums text-foreground transition-colors duration-200', vertical ? 'sr-only' : 'ms-auto', active !== null && 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
{readout}
</output>
</div>
)}
{vertical ? (
// Upright like a thermometer: the top value above the track, the bottom value below it
<div className="mt-1 grid justify-items-start gap-2">
<output aria-hidden className="rounded-md bg-muted px-2 py-0.5 text-[13px] font-medium tabular-nums text-foreground">{fmt(value[1])}</output>
<div className="flex items-stretch gap-1">
{track}
{markLabels}
</div>
<output aria-hidden className="rounded-md bg-muted px-2 py-0.5 text-[13px] font-medium tabular-nums text-foreground">{fmt(value[0])}</output>
</div>
) : (
<div className={cn(bars && 'mt-3')}>
{bars}
<div className={cn(bars && '-mt-[22px]')}>{track}</div>
{markLabels}
</div>
)}
{resultText && histogram && (
<p aria-live="polite" className="mt-1 text-[13px] text-muted-foreground">
{resultText
.split(/(\{count\}|\{total\})/)
.map((part, i) => (part === '{count}' ? <strong key={i} className="font-semibold tabular-nums text-foreground">{nf.format(count)}</strong> : part === '{total}' ? <span key={i} className="tabular-nums">{nf.format(total)}</span> : part))}
</p>
)}
{fields}
{name && !inputs && (
<>
<input type="hidden" name={`${name}-min`} value={value[0]} />
<input type="hidden" name={`${name}-max`} value={value[1]} />
</>
)}
{hint && (
<p id={`${id}-hint`} className="mt-1 text-[13px] text-pretty text-muted-foreground">
{hint}
</p>
)}
</div>
);
}
/** Where the currency symbol stands in the language: "$" before, "€" after */
function currencyAffix(locale: Locale, currency?: string): { prefix?: string; suffix?: string } {
const parts = new Intl.NumberFormat(TAG[locale], { style: 'currency', currency: currency ?? (locale === 'de' ? 'EUR' : 'USD') }).formatToParts(1);
const sym = parts.find((p) => p.type === 'currency')?.value ?? '';
return parts.findIndex((p) => p.type === 'currency') < parts.findIndex((p) => p.type === 'integer') ? { prefix: sym } : { suffix: sym };
}
function FieldBox({ id, label, prefix, suffix, type, step, value, name, disabled, className, onChange, onCommit }: { id: string; label: string; prefix?: string; suffix?: string; type: 'text' | 'time'; step?: number; value: string; name?: string; disabled?: boolean; className?: string; onChange: (v: string) => void; onCommit: (v: string) => void }) {
return (
<div className={cn('grid min-w-0 gap-1.5', className)}>
<label htmlFor={id} className="text-[13px] text-muted-foreground">
{label}
</label>
<div
className={cn(
'flex h-11 min-w-0 items-center rounded-lg border border-input bg-background text-foreground shadow-[var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 ease-[var(--ease-kit)] hover:border-border-strong',
'focus-within:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-sm)]',
disabled && 'cursor-not-allowed bg-muted text-muted-foreground',
)}
>
{prefix && <span className="ps-3 text-sm text-muted-foreground">{prefix}</span>}
<input
id={id}
type={type}
step={step}
inputMode={type === 'text' ? 'decimal' : undefined}
value={value}
name={name}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
onBlur={(e) => onCommit(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onCommit(e.currentTarget.value);
}
}}
className={cn('h-full w-0 min-w-0 flex-1 bg-transparent px-3 text-base tabular-nums outline-none sm:text-sm', prefix && 'ps-1.5', suffix && 'pe-1.5', '[color-scheme:light] dark:[color-scheme:dark]')}
/>
{suffix && <span className="pe-3 text-sm text-muted-foreground">{suffix}</span>}
</div>
</div>
);
}Frequent questions
How do I install Range slider?
With aiw add @ai-website/range-slider. The command puts components/ui/range-slider.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Range slider have?
4 variants, Price with histogram, Time window, Vertical and Scale with marks.
Can I translate the texts of Range slider?
Yes. Range slider carries 4 texts of its own in English and German, such as “Minimum”, “Maximum” and “to”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Range slider?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Range slider with aiw_components and install it with aiw_add @ai-website/range-slider.