Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type SliderMark = { value: number; label?: React.ReactNode };

export interface SliderProps {
  /** One value = single thumb, two values = range. */
  value?: number[];
  defaultValue?: number[];
  onChange?: (value: number[]) => void;
  /** Fires when the user releases a thumb or commits via keyboard. */
  onCommit?: (value: number[]) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Minimum distance between the two thumbs, in steps. */
  minStepsBetween?: number;
  /** `true` = marks at every step, or pass explicit marks. */
  marks?: boolean | SliderMark[];
  /** Snap to marks only (ignores `step`). */
  snapToMarks?: boolean;
  orientation?: "horizontal" | "vertical";
  /** "auto" shows on hover/drag/focus. */
  tooltip?: "auto" | "always" | "never";
  formatValue?: (v: number) => string;
  /** Accessible names for each thumb. */
  thumbLabels?: string[];
  label?: string;
  disabled?: boolean;
  name?: string;
  className?: string;
  /** Length of a vertical slider (CSS size). */
  height?: number | string;
}

const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;

export function Slider({
  value: valueProp,
  defaultValue,
  onChange,
  onCommit,
  min = 0,
  max = 100,
  step = 1,
  minStepsBetween = 0,
  marks,
  snapToMarks = false,
  orientation = "horizontal",
  tooltip = "auto",
  formatValue = (v) => String(v),
  thumbLabels,
  label,
  disabled = false,
  name,
  className,
  height = 200,
}: SliderProps) {
  const uid = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState<number[]>(defaultValue ?? [min]);
  const values = valueProp ?? inner;
  const trackRef = React.useRef<HTMLDivElement>(null);
  const thumbRefs = React.useRef<(HTMLDivElement | null)[]>([]);
  const [dragging, setDragging] = React.useState<number | null>(null);
  const [hover, setHover] = React.useState<number | null>(null);
  const [focus, setFocus] = React.useState<number | null>(null);
  const vertical = orientation === "vertical";
  const range = values.length > 1;
  const gap = minStepsBetween * step;

  const markList: SliderMark[] = React.useMemo(() => {
    if (!marks) return [];
    if (marks === true) {
      const out: SliderMark[] = [];
      for (let v = min; v <= max + 1e-9; v += step) out.push({ value: +v.toFixed(decimals(step)) });
      return out;
    }
    return marks;
  }, [marks, min, max, step]);

  const pct = (v: number) => ((v - min) / (max - min)) * 100;

  const quantize = (raw: number) => {
    if (snapToMarks && markList.length) {
      return markList.reduce((best, m) => (Math.abs(m.value - raw) < Math.abs(best - raw) ? m.value : best), markList[0].value);
    }
    const q = Math.round((raw - min) / step) * step + min;
    return +clamp(q, min, max).toFixed(decimals(step));
  };

  const emit = (next: number[]) => {
    if (valueProp === undefined) setInner(next);
    onChange?.(next);
  };

  const setThumb = (i: number, raw: number, commit = false) => {
    let v = quantize(raw);
    if (range) {
      if (i === 0) v = Math.min(v, values[1] - gap);
      else v = Math.max(v, values[0] + gap);
    }
    const next = values.slice();
    next[i] = clamp(v, min, max);
    if (next[i] !== values[i]) emit(next);
    if (commit) onCommit?.(next);
    return next;
  };

  const valueFromPointer = (clientX: number, clientY: number) => {
    const r = trackRef.current?.getBoundingClientRect();
    if (!r) return min;
    const ratio = vertical ? 1 - (clientY - r.top) / r.height : (clientX - r.left) / r.width;
    return min + clamp(ratio, 0, 1) * (max - min);
  };

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (disabled || e.button !== 0) return;
    e.preventDefault();
    const raw = valueFromPointer(e.clientX, e.clientY);
    // nearest thumb (ties → the one that can move in that direction)
    let i = 0;
    if (range) {
      const d0 = Math.abs(values[0] - raw);
      const d1 = Math.abs(values[1] - raw);
      i = d1 < d0 || (d0 === d1 && raw > values[1]) ? 1 : 0;
    }
    trackRef.current?.setPointerCapture(e.pointerId);
    setDragging(i);
    setThumb(i, raw);
    thumbRefs.current[i]?.focus({ preventScroll: true });
  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (dragging === null) return;
    setThumb(dragging, valueFromPointer(e.clientX, e.clientY));
  };
  const endDrag = () => {
    if (dragging === null) return;
    onCommit?.(values);
    setDragging(null);
  };

  const onKeyDown = (i: number) => (e: React.KeyboardEvent) => {
    if (disabled) return;
    const big = Math.max(step, (max - min) / 10);
    const v = values[i];
    const idx = markList.findIndex((m) => m.value === v);
    const map: Record<string, number> = snapToMarks
      ? {
          ArrowRight: markList[Math.min(markList.length - 1, idx + 1)]?.value ?? v,
          ArrowUp: markList[Math.min(markList.length - 1, idx + 1)]?.value ?? v,
          ArrowLeft: markList[Math.max(0, idx - 1)]?.value ?? v,
          ArrowDown: markList[Math.max(0, idx - 1)]?.value ?? v,
          Home: min,
          End: max,
        }
      : {
          ArrowRight: v + step * (e.shiftKey ? 10 : 1),
          ArrowUp: v + step * (e.shiftKey ? 10 : 1),
          ArrowLeft: v - step * (e.shiftKey ? 10 : 1),
          ArrowDown: v - step * (e.shiftKey ? 10 : 1),
          PageUp: v + big,
          PageDown: v - big,
          Home: min,
          End: max,
        };
    if (map[e.key] === undefined) return;
    e.preventDefault();
    setThumb(i, map[e.key], true);
  };

  const lo = range ? values[0] : min;
  const hi = range ? values[1] : values[0];
  const animateMove = dragging === null && !reduce;

  const posStyle = (p: number): React.CSSProperties => (vertical ? { bottom: `${p}%` } : { left: `${p}%` });

  return (
    <div className={cn(vertical ? "inline-flex flex-col items-center" : "w-full", className)}>
      {label && (
        <div id={`${uid}-label`} className={cn("mb-3 text-sm font-medium", vertical && "text-center")}>
          {label}
        </div>
      )}
      <div
        className={cn("relative touch-none select-none", vertical ? "w-10" : "h-10 w-full", disabled && "pointer-events-none opacity-50")}
        style={vertical ? { height } : undefined}
      >
        {/* hit area */}
        <div
          ref={trackRef}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={endDrag}
          onPointerCancel={endDrag}
          className={cn("absolute cursor-pointer", vertical ? "inset-y-0 left-1/2 w-10 -translate-x-1/2" : "inset-x-0 top-1/2 h-10 -translate-y-1/2")}
        >
          {/* rail */}
          <div className={cn("absolute rounded-full bg-muted", vertical ? "inset-y-0 left-1/2 w-1.5 -translate-x-1/2" : "inset-x-0 top-1/2 h-1.5 -translate-y-1/2")}>
            <div
              className={cn(
                "absolute rounded-full bg-gradient-to-r from-primary/80 to-primary",
                vertical ? "inset-x-0 bg-gradient-to-t" : "inset-y-0",
                animateMove && "transition-[left,right,top,bottom] duration-200 ease-out",
              )}
              style={vertical ? { bottom: `${pct(lo)}%`, top: `${100 - pct(hi)}%` } : { left: `${pct(lo)}%`, right: `${100 - pct(hi)}%` }}
            />
          </div>
          {/* marks */}
          {markList.map((m) => {
            const inside = m.value >= lo && m.value <= hi;
            return (
              <span
                key={m.value}
                aria-hidden
                className={cn(
                  "absolute size-1.5 rounded-full transition-colors",
                  vertical ? "left-1/2 -translate-x-1/2 translate-y-1/2" : "top-1/2 -translate-x-1/2 -translate-y-1/2",
                  inside ? "bg-primary-foreground/80" : "bg-foreground/25",
                )}
                style={posStyle(pct(m.value))}
              />
            );
          })}
        </div>

        {/* thumbs */}
        {values.map((v, i) => {
          const show = tooltip === "always" || (tooltip === "auto" && (dragging === i || hover === i || focus === i));
          const thumbLabel = thumbLabels?.[i] ?? (range ? (i === 0 ? "Minimum" : "Maximum") : label ?? "Value");
          return (
            <div
              key={i}
              ref={(el) => {
                thumbRefs.current[i] = el;
              }}
              role="slider"
              tabIndex={disabled ? -1 : 0}
              aria-label={thumbLabel}
              aria-labelledby={label && !thumbLabels && !range ? `${uid}-label` : undefined}
              aria-valuemin={range && i === 1 ? values[0] + gap : min}
              aria-valuemax={range && i === 0 ? values[1] - gap : max}
              aria-valuenow={v}
              aria-valuetext={formatValue(v)}
              aria-orientation={orientation}
              aria-disabled={disabled || undefined}
              onKeyDown={onKeyDown(i)}
              onFocus={() => setFocus(i)}
              onBlur={() => setFocus(null)}
              onPointerEnter={() => setHover(i)}
              onPointerLeave={() => setHover(null)}
              onPointerDown={(e) => {
                // let the track handle dragging, but keep this thumb when thumbs overlap
                if (disabled || e.button !== 0) return;
                e.stopPropagation();
                e.preventDefault();
                trackRef.current?.setPointerCapture(e.pointerId);
                setDragging(i);
                thumbRefs.current[i]?.focus({ preventScroll: true });
              }}
              className={cn(
                "group absolute z-10 outline-none",
                vertical ? "left-1/2 -translate-x-1/2 translate-y-1/2" : "top-1/2 -translate-x-1/2 -translate-y-1/2",
                animateMove && (vertical ? "transition-[bottom] duration-200 ease-out" : "transition-[left] duration-200 ease-out"),
                focus === i && "z-20",
              )}
              style={posStyle(pct(v))}
            >
              <motion.span
                animate={{ scale: dragging === i ? 1.2 : 1 }}
                whileHover={reduce ? undefined : { scale: dragging === i ? 1.2 : 1.1 }}
                transition={{ type: "spring", stiffness: 600, damping: 26 }}
                className={cn(
                  "block size-5 cursor-grab rounded-full border-2 border-primary bg-background shadow-md shadow-black/15 transition-shadow",
                  "group-focus-visible:ring-4 group-focus-visible:ring-primary/25",
                  dragging === i && "cursor-grabbing ring-8 ring-primary/15",
                )}
              />
              <AnimatePresence>
                {show && (
                  <motion.span
                    initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, ...(vertical ? { x: -4 } : { y: 4 }) }}
                    animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}
                    transition={{ type: "spring", stiffness: 600, damping: 28 }}
                    className={cn(
                      "pointer-events-none absolute whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-xs font-semibold tabular-nums text-background shadow-lg",
                      vertical ? "left-[calc(100%+10px)] top-1/2 origin-left -translate-y-1/2" : "bottom-[calc(100%+10px)] left-1/2 origin-bottom -translate-x-1/2",
                    )}
                  >
                    {formatValue(v)}
                    <span
                      aria-hidden
                      className={cn("absolute size-2 rotate-45 bg-foreground", vertical ? "-left-1 top-1/2 -translate-y-1/2" : "-bottom-1 left-1/2 -translate-x-1/2")}
                    />
                  </motion.span>
                )}
              </AnimatePresence>
            </div>
          );
        })}
      </div>

      {/* mark labels */}
      {markList.some((m) => m.label !== undefined) && !vertical && (
        <div className="relative mt-1 h-5 w-full text-[11px] text-muted-foreground">
          {markList.map((m) =>
            m.label === undefined ? null : (
              <button
                key={m.value}
                type="button"
                tabIndex={-1}
                onClick={() => {
                  if (disabled) return;
                  const i = range ? (Math.abs(values[0] - m.value) <= Math.abs(values[1] - m.value) ? 0 : 1) : 0;
                  setThumb(i, m.value, true);
                }}
                className={cn(
                  "absolute -translate-x-1/2 whitespace-nowrap tabular-nums transition-colors hover:text-foreground",
                  m.value >= lo && m.value <= hi && "text-foreground",
                  pct(m.value) === 0 && "translate-x-0",
                  pct(m.value) === 100 && "-translate-x-full",
                )}
                style={{ left: `${pct(m.value)}%` }}
              >
                {m.label}
              </button>
            ),
          )}
        </div>
      )}
      {name && values.map((v, i) => <input key={i} type="hidden" name={range ? `${name}[${i}]` : name} value={v} />)}
    </div>
  );
}

More in Forms & Inputs

View all →