Split Slider
Slider with a split handle that parts around text labels with spring physics.
Afhængigheder:
@number-flow/reactForhåndsvisning
Volume6464
Brightness8080
Opacity4545
Installer
npx shadcn@latest add https://byhartvig.dk/r/split-slider.jsonBrug
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>
);
}