Skip to content
UI Kit
Accent color

Slider

A styled native range: one value or a range with two thumbs, formatted values and marks.

One of 9 components in Forms and inputs

2 variants

Variants

Values

The value in the format of the language, read out the same way to screen readers. The fill grows in as it scrolls into view, the thumb grows while you drag and a bubble follows it.

Steps and marks

Fixed steps with labelled marks for choices that are really a scale.

Details

What Slider brings along, read from the registry and the source.

Variants
2
Version
1.0.1 · since 1.0.0
File
components/ui/slider.tsx
Brings along
utilskit
npm packages
None
Languages
English and German, 2 texts of its own
Studio
Adjust in code

Built-in texts

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

Show all 2 texts
minEnglish MinimumGerman Minimum
maxEnglish MaximumGerman Maximum

Accessibility

Read from the source of the component.

  • Slider handles the keys Home, End and Space itself.
  • Sets aria-describedby, aria-label, aria-labelledby and aria-valuetext, so screen readers announce purpose and state.
  • Uses the role props.
  • Every field has a label, visible or for screen readers.
  • Shows a visible focus ring when used with the keyboard.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 2 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, 221 lines
components/ui/slider.tsx
'use client';

import { useId, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';

const TEXTS = { en: { min: 'Minimum', max: 'Maximum' }, de: { min: 'Minimum', max: 'Maximum' } };

/* The thumb and track of a native range input, in both engines */
const RANGE = cn(
  'h-11 w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-not-allowed',
  '[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent',
  '[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent',
  '[&::-webkit-slider-thumb]:-mt-[7px] [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-[1.5px] [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-[var(--shadow-md)] [&::-webkit-slider-thumb]:transition-[box-shadow,scale] [&::-webkit-slider-thumb]:duration-300 [&::-webkit-slider-thumb]:ease-[cubic-bezier(0.34,1.56,0.64,1)]',
  '[&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-[1.5px] [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-[var(--shadow-md)] [&::-moz-range-thumb]:transition-[box-shadow,scale] [&::-moz-range-thumb]:duration-300',
  // The thumb grows while it is held, with a soft ring around it
  'hover:[&::-webkit-slider-thumb]:shadow-[0_0_0_5px_color-mix(in_oklab,var(--primary)_14%,transparent),var(--shadow-md)] active:[&::-webkit-slider-thumb]:scale-[1.25] active:[&::-webkit-slider-thumb]:shadow-[0_0_0_7px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-md)] active:[&::-moz-range-thumb]:scale-[1.25]',
  'focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_5px_color-mix(in_oklab,var(--primary)_24%,transparent),var(--shadow-md)] focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_5px_color-mix(in_oklab,var(--primary)_24%,transparent)]',
  // Keyboard focus: a solid ring on the thumb itself, the part that moves (the soft halo alone was too faint)
  'focus-visible:outline-none',
  'focus-visible:[&::-webkit-slider-thumb]:outline-2 focus-visible:[&::-webkit-slider-thumb]:outline-offset-2 focus-visible:[&::-webkit-slider-thumb]:outline-solid focus-visible:[&::-webkit-slider-thumb]:outline-primary',
  'focus-visible:[&::-moz-range-thumb]:outline-2 focus-visible:[&::-moz-range-thumb]:outline-offset-2 focus-visible:[&::-moz-range-thumb]:outline-solid focus-visible:[&::-moz-range-thumb]:outline-primary',
);

function Track({ from, to, marks, min, max, disabled }: { from: number; to: number; marks?: number[]; min: number; max: number; disabled?: boolean }) {
  const pct = (v: number) => round(((v - min) / (max - min)) * 100);
  // The fill grows to its value once the slider scrolls into view
  const [ref, inView] = useInView<HTMLDivElement>();
  return (
    // Inset by the thumb radius, so 0 % and 100 % sit under the thumb center
    <div ref={ref} aria-hidden className={cn('pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_11%,var(--background))]', disabled && 'opacity-60')}>
      <div className="absolute inset-y-0 overflow-hidden rounded-full" style={{ left: `${pct(from)}%`, width: `${round(pct(to) - pct(from))}%` }}>
        <div className={cn('size-full origin-left rounded-full bg-primary transition-[scale] duration-[900ms] ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'scale-x-0')} />
      </div>
      {marks?.map((m) => (
        <span
          key={m}
          className={cn(
            'absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-[background-color,scale] duration-300',
            m >= from && m <= to ? 'scale-125 bg-primary-foreground/70' : 'bg-[color-mix(in_oklab,var(--foreground)_30%,transparent)]',
          )}
          style={{ left: `${pct(m)}%` }}
        />
      ))}
    </div>
  );
}

/** The value in a small bubble over the thumb while it is dragged or moved with the keyboard */
function Bubble({ value, min, max, show, children }: { value: number; min: number; max: number; show: boolean; children: React.ReactNode }) {
  const p = (value - min) / (max - min);
  return (
    <span
      aria-hidden
      className={cn(
        'pointer-events-none absolute bottom-[calc(50%+18px)] z-20 -translate-x-1/2 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,translate] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none',
        'after:absolute after:left-1/2 after:top-full after:-translate-x-1/2 after:border-4 after:border-transparent after:border-t-foreground after:content-[""]',
        show ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-1 scale-90 opacity-0',
      )}
      style={{ left: `calc(10px + (100% - 20px) * ${round(p, 4)})`, transformOrigin: '50% 100%' }}
    >
      {children}
    </span>
  );
}

interface Base {
  label?: React.ReactNode;
  hint?: React.ReactNode;
  min?: number;
  max?: number;
  step?: number;
  /** Format a value for display and screen readers, e.g. (v) => `${v} €` */
  format?: (value: number) => string;
  /** Tick marks with labels under the track */
  marks?: { value: number; label?: string }[];
  /** Show min and max under the track */
  showRange?: boolean;
  disabled?: boolean;
  name?: string;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

export type SliderProps =
  | (Base & { range?: false; value?: number; defaultValue?: number; onValueChange?: (v: number) => void })
  | (Base & { range: true; value?: [number, number]; defaultValue?: [number, number]; onValueChange?: (v: [number, number]) => void });

/**
 * A styled native range input: keyboard, touch and forms for free. One value or a range with two thumbs.
 *   <Slider label="Budget" min={500} max={10000} step={100} format={(v) => `${v} €`} />
 *   <Slider range label="Price" defaultValue={[20, 80]} />
 */
export function Slider(props: SliderProps) {
  const { label, hint, min = 0, max = 100, step = 1, format, marks, showRange, disabled, name, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const id = useId();
  const fmt = format ?? ((v: number) => v.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US'));
  const initial: [number, number] = props.range
    ? props.value ?? props.defaultValue ?? [min, max]
    : [min, (props.value as number | undefined) ?? (props.defaultValue as number | undefined) ?? round((min + max) / 2)];
  const [inner, setInner] = useState<[number, number]>(initial);
  const value: [number, number] = props.range ? props.value ?? inner : [min, (props.value as number | undefined) ?? inner[1]];

  const set = (next: [number, number]) => {
    setInner(next);
    if (props.range) props.onValueChange?.(next);
    else (props.onValueChange as ((v: number) => void) | undefined)?.(next[1]);
  };

  // Which thumb shows its bubble: the one held down, or the one moved with the keyboard
  const [held, setHeld] = useState<'a' | 'b' | null>(null);
  const bubble = (which: 'a' | 'b') => ({
    onPointerDown: () => setHeld(which),
    onPointerUp: () => setHeld(null),
    onPointerCancel: () => setHeld(null),
    onKeyDown: (e: React.KeyboardEvent) => e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End' || e.key.startsWith('Page') ? setHeld(which) : undefined,
    onKeyUp: () => setHeld(null),
    onBlur: () => setHeld(null),
  });

  const readout = props.range ? `${fmt(value[0])} ${locale === 'de' ? 'bis' : 'to'} ${fmt(value[1])}` : fmt(value[1]);
  const markValues = marks?.map((m) => m.value);

  return (
    <div className={cn('grid w-full gap-1', className)} role={props.range ? 'group' : undefined} aria-labelledby={props.range && label ? `${id}-label` : undefined}>
      {(
        <div className="flex items-baseline justify-between gap-4">
          {label && (
            <label id={`${id}-label`} htmlFor={props.range ? undefined : `${id}-a`} className="text-sm font-medium text-foreground">
              {label}
            </label>
          )}
          <output htmlFor={props.range ? `${id}-a ${id}-b` : `${id}-a`} className={cn('ms-auto text-sm font-medium tabular-nums text-foreground transition-colors duration-200', held && 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
            {readout}
          </output>
        </div>
      )}
      <div className="relative">
        {props.range && <Bubble value={value[0]} min={min} max={max} show={held === 'a'}>{fmt(value[0])}</Bubble>}
        <Bubble value={value[1]} min={min} max={max} show={held === 'b'}>{fmt(value[1])}</Bubble>
        <Track from={props.range ? value[0] : min} to={value[1]} marks={markValues} min={min} max={max} disabled={disabled} />
        {props.range ? (
          <>
            {/* Two inputs on one track. Only the thumbs take pointer events, so both stay reachable. */}
            <input
              id={`${id}-a`}
              type="range"
              min={min}
              max={max}
              step={step}
              value={value[0]}
              disabled={disabled}
              name={name ? `${name}-min` : undefined}
              {...bubble('a')}
              aria-label={`${t.min}${typeof label === 'string' ? `, ${label}` : ''}`}
              aria-valuetext={fmt(value[0])}
              onChange={(e) => set([Math.min(Number(e.target.value), value[1] - step), value[1]])}
              className={cn(RANGE, 'pointer-events-none absolute inset-0 [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:pointer-events-auto', value[0] > max - (max - min) * 0.1 && 'z-10')}
            />
            <input
              id={`${id}-b`}
              type="range"
              min={min}
              max={max}
              step={step}
              value={value[1]}
              disabled={disabled}
              name={name ? `${name}-max` : undefined}
              {...bubble('b')}
              aria-label={`${t.max}${typeof label === 'string' ? `, ${label}` : ''}`}
              aria-valuetext={fmt(value[1])}
              onChange={(e) => set([value[0], Math.max(Number(e.target.value), value[0] + step)])}
              className={cn(RANGE, 'pointer-events-none relative [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:pointer-events-auto')}
            />
          </>
        ) : (
          <input
            id={`${id}-a`}
            type="range"
            min={min}
            max={max}
            step={step}
            value={value[1]}
            disabled={disabled}
            name={name}
            {...bubble('b')}
            aria-valuetext={fmt(value[1])}
            aria-describedby={hint ? `${id}-hint` : undefined}
            onChange={(e) => set([min, Number(e.target.value)])}
            className={cn(RANGE, 'relative')}
          />
        )}
      </div>
      {marks && marks.some((m) => m.label) && (
        <div aria-hidden className="relative mx-2.5 h-4 text-xs text-subtle-foreground">
          {marks.map((m, i) => (
            <span
              key={m.value}
              className={cn('absolute top-0 whitespace-nowrap', i === 0 ? 'translate-x-[-10px]' : i === marks.length - 1 ? 'translate-x-[calc(-100%+10px)]' : '-translate-x-1/2')}
              style={{ left: `${round(((m.value - min) / (max - min)) * 100)}%` }}
            >
              {m.label}
            </span>
          ))}
        </div>
      )}
      {showRange && !marks && (
        <div aria-hidden className="flex justify-between text-xs tabular-nums text-subtle-foreground">
          <span>{fmt(min)}</span>
          <span>{fmt(max)}</span>
        </div>
      )}
      {hint && (
        <p id={`${id}-hint`} className="mt-1 text-[13px] text-muted-foreground">
          {hint}
        </p>
      )}
    </div>
  );
}

Frequent questions

How do I install Slider?

With aiw add @ai-website/slider. The command puts components/ui/slider.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Slider have?

2 variants, Values and Steps and marks.

Can I translate the texts of Slider?

Yes. Slider carries 2 texts of its own in English and German, such as “Minimum” and “Maximum”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Slider?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Slider with aiw_components and install it with aiw_add @ai-website/slider.

Often searched

Jump to

Copied to the clipboard