Skip to content
UI Kit
Accent color

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.

4 variantsOne of 11 components in Forms and inputs

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

Price with histogramNew in 1.2

The shop filter. Bars behind the track show how prices spread, the ones inside the range light up and the count below follows. Type into the fields or drag, both stay in sync.

Time windowNew in 1.2

Clock times in quarter hours, 10:00 or 10:00 AM by language. The window keeps a minimum length, so the thumbs stop before they meet. Marks every two hours, the odd ones rest on phones.

VerticalNew in 1.2

Two upright ranges for a thermostat, degrees with a unit and humidity in percent. Arrow up raises a thumb, Page keys take big steps, the value rides beside the thumb while you drag.

Scale with marksNew in 1.2

Whole steps on a labelled scale, for years or named levels. A label per value reads aloud the same as it shows, both thumbs may rest on one level.

Details

What Range slider brings along, read from the registry and the source.

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

Props

The RangeSliderProps interface from components/ui/range-slider.tsx, with the comments from the code.

  • labeloptional
    string
  • hintoptional
    string
  • minoptional
    number
  • maxoptional
    number
  • stepoptional
    number
  • bigStepoptional
    number

    Steps taken by Page Up and Page Down, default a tenth of the range

  • minGapoptional
    number

    Smallest distance between the two thumbs, default one step. The thumbs never cross.

  • valueoptional
    [number, number]
  • defaultValueoptional
    [number, number]
  • onValueChangeoptional
    (value: [number, number]) => void
  • onValueCommitoptional
    (value: [number, number]) => void

    Called once a drag or a key press is done, for filters that load results

  • formatoptional
    RangeFormat

    numbercurrencypercenttimeunit

  • currencyoptional
    string

    ISO currency for format "currency", default EUR in German and USD in English

  • unitoptional
    string

    Unit for format "unit", e.g. m², °C, km

  • decimalsoptional
    number
  • valueLabelsoptional
    string[]

    A text per value (index from min), e.g. sizes XS to XXL. Wins over format.

  • formatValueoptional
    (value: number) => string

    Your own formatter, wins over format

  • openEndoptional
    boolean

    The top value reads as "and more", e.g. 5.000 €+

  • marksoptional
    RangeMark[]
  • histogramoptional
    number[]

    Bars behind the track, one per bucket from min to max. Bars inside the range light up.

  • resultTextoptional
    string

    A line under the track counted from the histogram, with {count} and {total}, e.g. "{count} of {total} bikes"

  • inputsoptional
    boolean

    Two 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'
  • lengthoptional
    number

    Height of a vertical slider in px

  • nameoptional
    string

    Form field name, sends name-min and name-max

  • disabledoptional
    boolean

Built-in texts

Range slider writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 4 texts
minEnglish MinimumGerman Minimum
maxEnglish MaximumGerman Maximum
toEnglish toGerman bis
rangeEnglish RangeGerman 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
components/ui/range-slider.tsx
'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.

Often searched

Jump to

Copied to the clipboard