Range Slider
Eine Spanne mit zwei Griffen nach dem APG-Muster für mehrere Griffe. Die Griffe kreuzen sich nie, Werte stehen im Format der Sprache, dazu Marken, ein Histogramm hinter der Spur, gekoppelte Felder und eine senkrechte Form.
Ein Bereich von hier bis dort
Ein Bereichsregler wählt eine Spanne, einen Preis von und bis, ein Zeitfenster oder einen Temperaturbereich. Er folgt dem APG-Muster für Regler mit mehreren Griffen, die beiden Griffe kreuzen sich nie und halten einen Mindestabstand, und Zahlenfelder daneben bleiben gleich für alle, die lieber tippen.
„Preis mit Histogramm“ ist der Shop-Filter, Balken hinter der Spur zeigen, wie sich die Preise verteilen, die im Bereich leuchten auf und die Trefferzahl darunter folgt. „Zeitfenster“ arbeitet in Viertelstunden, je nach Sprache 10:00 oder 10:00 AM, und hält eine Mindestlänge, die Griffe halten also vor einander an. „Senkrecht“ zeigt zwei aufrechte Spannen für ein Thermostat, Grad mit Einheit und Luftfeuchte in Prozent, Bildtasten machen große Schritte und der Wert fährt neben dem Griff mit. „Skala mit Marken“ geht in ganzen Stufen über eine beschriftete Skala für Jahre oder benannte Stufen, beide Griffe dürfen auf einer Stufe liegen.
Zeig die Trefferzahl beim Ziehen, sie sagt, wann man aufhören kann. Formatiere Werte so, wie Menschen sie sagen, Geld mit Währung, Uhrzeiten je Sprache. Lass Tippen neben den Griffen zu, genaue Werte sind schneller getippt als gezogen.
Variants
Details
Was Bereichsregler mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Forms und Inputs
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/range-slider.tsx
- Bringt mit
- utilskit
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 4 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle RangeSliderProps aus components/ui/range-slider.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Bereichsregler selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 4 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| min | Englisch Minimum | Deutsch Minimum |
| max | Englisch Maximum | Deutsch Maximum |
| to | Englisch to | Deutsch bis |
| range | Englisch Range | Deutsch Bereich |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Bereichsregler reagiert selbst auf die Tasten Enter und Leertaste.
- Setzt aria-describedby, aria-disabled, aria-label, aria-labelledby, aria-live, aria-orientation, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group und slider.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 5 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 522 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Bereichsregler?
Mit aiw add @ai-website/range-slider. Der Befehl legt die Datei components/ui/range-slider.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Bereichsregler?
4 Varianten, Preis mit Histogramm, Zeitfenster, Senkrecht und Skala mit Marken.
Kann ich die Texte von Bereichsregler übersetzen?
Ja. Bereichsregler bringt 4 eigene Texte auf Deutsch und Englisch mit, etwa „Minimum“, „Maximum“ und „bis“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Bereichsregler einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bereichsregler mit aiw_components und installieren sie mit aiw_add @ai-website/range-slider.