Split Slider

Slider with a split handle that parts around text labels with spring physics.

Afhængigheder:@number-flow/react

Forhåndsvisning

Volume64
Brightness80
Opacity45

Installer

npx shadcn@latest add https://byhartvig.dk/r/split-slider.json

Brug

import { SplitSlider } from "@/components/ui/split-slider"

const [value, setValue] = useState(50)

<SplitSlider
  label="Volume"
  value={value}
  onChange={setValue}
/>

Kildekode

"use client";

import {
  useRef,
  useState,
  useCallback,
  useEffect,
  useMemo,
} from "react";
import NumberFlow from "@number-flow/react";

interface SplitSliderProps {
  label: string;
  min?: number;
  max?: number;
  value: number;
  onChange: (value: number) => void;
  className?: string;
}

export function SplitSlider({
  label,
  min = 0,
  max = 100,
  value,
  onChange,
  className,
}: SplitSliderProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const valueRef = useRef<HTMLSpanElement>(null);
  const [dragging, setDragging] = useState(false);
  const [dragX, setDragX] = useState<number | null>(null);
  const [textZones, setTextZones] = useState<{ left: number; right: number }[]>(
    []
  );
  const [containerWidth, setContainerWidth] = useState(0);

  const fraction = (value - min) / (max - min);

  const measureZones = useCallback(() => {
    const container = containerRef.current;
    if (!container) return;
    const rect = container.getBoundingClientRect();
    setContainerWidth(rect.width);

    const zones: { left: number; right: number }[] = [];
    for (const ref of [labelRef, valueRef]) {
      if (ref.current) {
        const r = ref.current.getBoundingClientRect();
        zones.push({
          left: r.left - rect.left - 2,
          right: r.right - rect.left + 2,
        });
      }
    }
    setTextZones(zones);
  }, []);

  useEffect(() => {
    measureZones();
    window.addEventListener("resize", measureZones);
    return () => window.removeEventListener("resize", measureZones);
  }, [measureZones, label, value]);

  const handlePointerDown = useCallback(
    (e: React.PointerEvent) => {
      e.preventDefault();
      const container = containerRef.current;
      if (!container) return;
      (e.target as HTMLElement).setPointerCapture(e.pointerId);
      const rect = container.getBoundingClientRect();
      const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
      setDragging(true);
      setDragX(x);
      onChange(Math.round(min + (x / rect.width) * (max - min)));
    },
    [onChange, min, max]
  );

  const handlePointerMove = useCallback(
    (e: React.PointerEvent) => {
      if (!dragging) return;
      const container = containerRef.current;
      if (!container) return;
      const rect = container.getBoundingClientRect();
      const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
      setDragX(x);
      onChange(Math.round(min + (x / rect.width) * (max - min)));
    },
    [dragging, onChange, min, max]
  );

  const handlePointerUp = useCallback(() => {
    setDragging(false);
    setDragX(null);
  }, []);

  const edgePad = 10;
  const rawThumbX = dragX !== null ? dragX : fraction * containerWidth;
  const thumbX =
    containerWidth > 0
      ? Math.max(edgePad, Math.min(rawThumbX, containerWidth - edgePad))
      : rawThumbX;

  const targetSplit = useMemo(() => {
    const leadIn = 6;
    for (const zone of textZones) {
      if (thumbX >= zone.left && thumbX <= zone.right) return 1;
      if (thumbX > zone.left - leadIn && thumbX < zone.left) {
        return (thumbX - (zone.left - leadIn)) / leadIn;
      }
      if (thumbX > zone.right && thumbX < zone.right + leadIn) {
        return 1 - (thumbX - zone.right) / leadIn;
      }
    }
    return 0;
  }, [thumbX, textZones]);

  const springRef = useRef({ value: 0, velocity: 0 });
  const rafRef = useRef(0);
  const [splitAmount, setSplitAmount] = useState(0);

  useEffect(() => {
    const animate = () => {
      const s = springRef.current;
      const force = (targetSplit - s.value) * 0.2 - s.velocity * 0.5;
      s.velocity += force;
      s.value = Math.max(0, s.value + s.velocity);

      if (
        Math.abs(targetSplit - s.value) > 0.001 ||
        Math.abs(s.velocity) > 0.001
      ) {
        setSplitAmount(s.value);
        rafRef.current = requestAnimationFrame(animate);
      } else {
        s.value = targetSplit;
        s.velocity = 0;
        setSplitAmount(targetSplit);
      }
    };

    cancelAnimationFrame(rafRef.current);
    rafRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(rafRef.current);
  }, [targetSplit]);

  const containerHeight = 64;
  const barW = dragging ? 5 : 4;
  const barPadClosed = 14;
  const barPadOpen = 8;
  const barPad = barPadClosed - splitAmount * (barPadClosed - barPadOpen);
  const minGap = 1;
  const maxGap = 26;
  const gap = minGap + splitAmount * (maxGap - minGap);
  const halfBar =
    containerWidth > 0 ? Math.max(0, (containerHeight - barPad * 2 - gap) / 2) : 0;

  return (
    <div
      ref={containerRef}
      className={`relative rounded-2xl overflow-hidden cursor-grab active:cursor-grabbing select-none touch-none border border-current/5 bg-current/[0.03] ${className ?? ""}`}
      style={{ height: containerHeight }}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
    >
      {/* Fill bar */}
      <div
        className={`absolute top-1 left-1 bottom-1 rounded-xl pointer-events-none transition-colors duration-150 ${dragging ? "bg-current/[0.07]" : "bg-current/[0.05]"}`}
        style={{
          width: Math.max(
            0,
            Math.min(
              thumbX + 2 + 6 * Math.min(fraction * 5, (1 - fraction) * 5, 1),
              containerWidth - 8
            )
          ),
        }}
      />

      {/* Handle — top half */}
      <div
        className={`absolute z-20 pointer-events-none rounded-full transition-colors duration-150 ${dragging ? "bg-neutral-400" : "bg-neutral-300"}`}
        style={{
          left: thumbX - barW / 2,
          top: barPad,
          width: barW,
          height: halfBar,
        }}
      />

      {/* Handle — bottom half */}
      <div
        className={`absolute z-20 pointer-events-none rounded-full transition-colors duration-150 ${dragging ? "bg-neutral-400" : "bg-neutral-300"}`}
        style={{
          left: thumbX - barW / 2,
          bottom: barPad,
          width: barW,
          height: halfBar,
        }}
      />

      {/* Label */}
      <span
        ref={labelRef}
        className="absolute left-5 top-1/2 -translate-y-1/2 text-sm font-medium z-10 pointer-events-none text-current/40"
      >
        {label}
      </span>

      {/* Value */}
      <span
        ref={valueRef}
        className="absolute right-5 top-1/2 -translate-y-1/2 text-sm font-medium z-10 pointer-events-none tabular-nums text-current/40"
      >
        <NumberFlow value={value} />
      </span>
    </div>
  );
}