Number Range
Two-thumb slider paired with synced number inputs. Min/max/step, currency or unit formatting, keyboard-first.
Example
Installation
npx shadcn@latest add https://hirael.com/r/number-range.jsonUsage
import {
NumberRange,
NumberRangeSlider,
NumberRangeInput,
NumberRangeInputs,
} from "@/components/number-range"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<NumberRange />
+ native element props| Prop | Type | Default |
|---|---|---|
value | NumberRangeValue | none |
defaultValue | NumberRangeValue | none |
onValueChange | ((value: NumberRangeValue) => void) | none |
min | number | 0 |
max | number | 100 |
step | number | 1 |
disabled | boolean | none |
format | NumberFormatter | defaultFormat |
parse | NumberParser | defaultParse |
prefix | string | none |
suffix | string | none |
<NumberRangeSlider />
+ native element propsNo props of its own; forwards everything to the underlying element.
<NumberRangeInput />
+ native element props| Prop | Type | Default |
|---|---|---|
bound* | "min" | "max" | none |
<NumberRangeInputs />
+ native element props| Prop | Type | Default |
|---|---|---|
separator | React.ReactNode | '–' |
Component source
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { Slider } from '@/registry/hirael/bases/radix/ui/slider';
export type NumberRangeValue = [number, number];
export type NumberFormatter = (n: number) => string;
export type NumberParser = (s: string) => number;
interface NumberRangeContextValue {
value: NumberRangeValue;
setValue: (v: NumberRangeValue) => void;
min: number;
max: number;
step: number;
disabled?: boolean;
format: NumberFormatter;
parse: NumberParser;
prefix?: string;
suffix?: string;
}
const NumberRangeContext = React.createContext<NumberRangeContextValue | null>(null);
const useNumberRange = () => {
const ctx = React.useContext(NumberRangeContext);
if (!ctx) {
throw new Error('NumberRange compound components must be used inside <NumberRange>');
}
return ctx;
};
const clamp = (n: number, lo: number, hi: number) => {
return Math.min(hi, Math.max(lo, n));
};
const clampPair = ([lo, hi]: NumberRangeValue, min: number, max: number): NumberRangeValue => {
const a = clamp(lo, min, max);
const b = clamp(hi, min, max);
return a <= b ? [a, b] : [b, a];
};
const defaultFormat: NumberFormatter = (n) => String(n);
const defaultParse: NumberParser = (s) => {
const cleaned = s.replace(/[^\d.-]/g, '');
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
};
export interface NumberRangeProps extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'prefix'> {
value?: NumberRangeValue;
defaultValue?: NumberRangeValue;
onValueChange?: (value: NumberRangeValue) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
format?: NumberFormatter;
parse?: NumberParser;
prefix?: string;
suffix?: string;
}
const NumberRange = ({
value: valueProp,
defaultValue,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled,
format = defaultFormat,
parse = defaultParse,
prefix,
suffix,
className,
children,
...props
}: NumberRangeProps) => {
const [internal, setInternal] = React.useState<NumberRangeValue>(defaultValue ?? [min, max]);
const value = valueProp ?? internal;
const setValue = React.useCallback(
(next: NumberRangeValue) => {
const clamped = clampPair(next, min, max);
if (valueProp === undefined) setInternal(clamped);
onValueChange?.(clamped);
},
[valueProp, onValueChange, min, max],
);
const ctx = React.useMemo<NumberRangeContextValue>(
() => ({
value,
setValue,
min,
max,
step,
disabled,
format,
parse,
prefix,
suffix,
}),
[value, setValue, min, max, step, disabled, format, parse, prefix, suffix],
);
return (
<NumberRangeContext.Provider value={ctx}>
<div data-slot="number-range" className={cn('flex flex-col gap-3', className)} {...props}>
{children}
</div>
</NumberRangeContext.Provider>
);
};
const NumberRangeSlider = ({
className,
...props
}: Omit<React.ComponentProps<typeof Slider>, 'value' | 'onValueChange' | 'min' | 'max' | 'step' | 'defaultValue'>) => {
const ctx = useNumberRange();
return (
<Slider
min={ctx.min}
max={ctx.max}
step={ctx.step}
value={ctx.value as number[]}
disabled={ctx.disabled}
onValueChange={(v) => ctx.setValue([v[0], v[1]] as NumberRangeValue)}
data-slot="number-range-slider"
className={className}
{...props}
/>
);
};
interface NumberRangeInputProps extends Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange' | 'type'> {
bound: 'min' | 'max';
}
const NumberRangeInput = ({ bound, className, ...props }: NumberRangeInputProps) => {
const ctx = useNumberRange();
const i = bound === 'min' ? 0 : 1;
const current = ctx.value[i];
const format = ctx.format;
const [draft, setDraft] = React.useState<string>(format(current));
const [editing, setEditing] = React.useState(false);
React.useEffect(() => {
if (!editing) setDraft(format(current));
}, [current, format, editing]);
const commit = (raw: string) => {
const parsed = ctx.parse(raw);
const next: NumberRangeValue = bound === 'min' ? [parsed, ctx.value[1]] : [ctx.value[0], parsed];
ctx.setValue(next);
};
return (
<div data-slot="number-range-input" className="relative">
{ctx.prefix && (
<span className="pointer-events-none absolute start-2 top-1/2 -translate-y-1/2 font-mono text-xs text-muted-foreground">
{ctx.prefix}
</span>
)}
<Input
inputMode="decimal"
value={draft}
disabled={ctx.disabled}
onFocus={() => setEditing(true)}
onChange={(e) => setDraft(e.target.value)}
onBlur={(e) => {
setEditing(false);
commit(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const delta = e.key === 'ArrowUp' ? ctx.step : -ctx.step;
const mult = e.shiftKey ? 10 : 1;
const next =
bound === 'min'
? ([ctx.value[0] + delta * mult, ctx.value[1]] as NumberRangeValue)
: ([ctx.value[0], ctx.value[1] + delta * mult] as NumberRangeValue);
ctx.setValue(next);
}
}}
className={cn('font-mono tabular-nums', ctx.prefix && 'ps-6', ctx.suffix && 'pe-8', className)}
{...props}
/>
{ctx.suffix && (
<span className="pointer-events-none absolute end-2 top-1/2 -translate-y-1/2 font-mono text-xs text-muted-foreground">
{ctx.suffix}
</span>
)}
</div>
);
};
interface NumberRangeInputsProps extends React.ComponentProps<'div'> {
separator?: React.ReactNode;
}
const NumberRangeInputs = ({ className, separator = '–', ...props }: NumberRangeInputsProps) => {
return (
<div
data-slot="number-range-inputs"
className={cn('grid grid-cols-[1fr_auto_1fr] items-center gap-2', className)}
{...props}
>
<NumberRangeInput bound="min" aria-label="Minimum value" />
<span className="font-mono text-xs text-muted-foreground select-none">{separator}</span>
<NumberRangeInput bound="max" aria-label="Maximum value" />
</div>
);
};
export { NumberRange, NumberRangeSlider, NumberRangeInput, NumberRangeInputs };
Dependencies
shadcn registry
sliderinput