Inputs & Forms

Slider

A Radix Slider with a tabular-nums value tooltip that fades in while dragging or focused, plus optional tick marks the fill swallows as it passes.

Install

npx shadcn@latest add @paragon/slider

slider.tsx

"use client";

import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";

export interface SliderProps
  extends React.ComponentProps<typeof SliderPrimitive.Root> {
  /** Show a value tooltip above the thumb while dragging or focused. */
  showTooltip?: boolean;
  /** Format the tooltip value, e.g. `(v) => \`${v} req/min\``. */
  formatValue?: (value: number) => string;
  /** Track values to render tick marks at. Passed ticks sit under the fill. */
  ticks?: number[];
}

/**
 * Radix Slider with a tabular-nums value tooltip that fades in while
 * dragging (or while the thumb has keyboard focus) and optional tick marks.
 */
export function Slider({
  className,
  showTooltip = true,
  formatValue = (value) => String(value),
  ticks,
  min = 0,
  max = 100,
  value,
  defaultValue,
  onValueChange,
  "aria-label": ariaLabel,
  ...props
}: SliderProps) {
  const [internal, setInternal] = React.useState<number[]>(
    value ?? defaultValue ?? [min],
  );
  const values = value ?? internal;

  const [dragging, setDragging] = React.useState(false);
  React.useEffect(() => {
    if (!dragging) return;
    const stop = () => setDragging(false);
    window.addEventListener("pointerup", stop);
    window.addEventListener("pointercancel", stop);
    return () => {
      window.removeEventListener("pointerup", stop);
      window.removeEventListener("pointercancel", stop);
    };
  }, [dragging]);

  const percent = (v: number) =>
    max === min ? 0 : ((v - min) / (max - min)) * 100;

  return (
    <SliderPrimitive.Root
      data-slot="slider"
      min={min}
      max={max}
      value={value}
      defaultValue={defaultValue}
      onValueChange={(next) => {
        setInternal(next);
        onValueChange?.(next);
      }}
      onPointerDown={() => setDragging(true)}
      className={cn(
        "relative flex w-full touch-none items-center select-none",
        "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
        className,
      )}
      {...props}
    >
      <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary">
        {/* Ticks render under the range, so the fill swallows passed marks. */}
        {ticks?.map((tick) => (
          <span
            key={tick}
            aria-hidden
            className="pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/40"
            style={{ left: `${percent(tick)}%` }}
          />
        ))}
        <SliderPrimitive.Range className="absolute h-full bg-primary" />
      </SliderPrimitive.Track>
      {values.map((thumbValue, index) => (
        <SliderPrimitive.Thumb
          key={index}
          // The root is a span, so the accessible name belongs on the thumb.
          aria-label={
            values.length > 1 && ariaLabel
              ? `${ariaLabel} (${index + 1} of ${values.length})`
              : ariaLabel
          }
          className={cn(
            "group relative block size-4 shrink-0 rounded-full border border-ring/40 bg-background shadow-sm",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          )}
        >
          {showTooltip && (
            <span
              aria-hidden
              className={cn(
                "pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2",
                "rounded-md bg-foreground px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-background tabular-nums shadow-overlay",
                "origin-bottom transition-[opacity,scale] duration-150 ease-out motion-reduce:transition-[opacity]",
                "group-focus-visible:scale-100 group-focus-visible:opacity-100",
                dragging ? "scale-100 opacity-100" : "scale-95 opacity-0",
              )}
            >
              {formatValue(thumbValue)}
            </span>
          )}
        </SliderPrimitive.Thumb>
      ))}
    </SliderPrimitive.Root>
  );
}