Back

Magnetic Pit Slider

Animated stick-bar slider with Gaussian pit effect — bars dip around the thumb on hover.

Category
SliderReact
CSS
Tailwind

Manual

Create a file and paste the following code into it.

src/stick-slider.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
"use client";

import { useState, useEffect } from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { motion, AnimatePresence } from "motion/react";
import NumberFlow from "@number-flow/react";
import { cn } from "@/lib/cn";

/* ─── Constants ─── */

const TOTAL_BARS = 44;
const IDLE_HEIGHT = 28;
const ACTIVE_HEIGHT = 64;
const SPREAD = 6;
const PIT_DEPTH = 0.72;
const TRACK_PADDING = 16;
const SPRING = { type: "spring" as const, stiffness: 400, damping: 30 };

/** Gaussian pit multiplier — bars near the thumb dip lower (0.28 at center, 1.0 at edges). */
function pitMultiplier(barIndex: number, thumbPercent: number, isEngaged: boolean): number {
  if (!isEngaged) return 1;
  const thumbPos = (thumbPercent / 100) * (TOTAL_BARS - 1);
  const dist = Math.abs(barIndex - thumbPos);
  return 1 - Math.exp(-(dist * dist) / (SPREAD * SPREAD)) * PIT_DEPTH;
}

/* ─── StickSlider ─── */

interface StickSliderProps {
  className?: string;
  value: number[];
  onValueChange: (value: number[]) => void;
  min?: number;
  max?: number;
  step?: number;
}

/**
 * Magnetic pit slider — stick bars form a Gaussian depression around the thumb.
 * @param {number[]} value - Current slider value [Required]
 * @param {Function} onValueChange - Value change callback [Required]
 * @param {number} min - Minimum value [Optional, default: 0]
 * @param {number} max - Maximum value [Optional, default: 100]
 * @param {number} step - Step increment [Optional, default: 1]
 */
export function StickSlider({
  className,
  value,
  onValueChange,
  min = 0,
  max = 100,
  step = 1,
}: StickSliderProps) {
  const [isDragging, setDragging] = useState(false);
  const [isHovering, setHovering] = useState(false);

  const v = value[0];
  const percent = ((v - min) / (max - min)) * 100;
  const isEngaged = isDragging || isHovering;
  const trackHeight = isEngaged ? ACTIVE_HEIGHT : IDLE_HEIGHT;

  useEffect(() => {
    if (!isDragging) return;
    const up = () => setDragging(false);
    document.addEventListener("pointerup", up);
    return () => document.removeEventListener("pointerup", up);
  }, [isDragging]);

  return (
    <SliderPrimitive.Root
      value={value}
      onValueChange={onValueChange}
      min={min}
      max={max}
      step={step}
      onPointerDown={() => setDragging(true)}
      onMouseEnter={() => setHovering(true)}
      onMouseLeave={() => setHovering(false)}
      className={cn(
        "group relative flex w-full touch-none select-none flex-col items-stretch",
        className,
      )}
    >
      {/* Floating value badge */}
      <AnimatePresence>
        {isEngaged && (
          <motion.div
            key="badge"
            className="pointer-events-none absolute bottom-full z-10 mb-2 -translate-x-1/2
                       rounded-lg bg-neutral-200 px-4 py-1.5 text-2xl font-bold text-black shadow-lg"
            style={{ left: `${percent}%` }}
            initial={{ opacity: 0, y: 4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 4 }}
          >
            <NumberFlow
              value={Math.round(v)}
              format={{ maximumFractionDigits: 0 }}
              spinTiming={{ duration: 200 }}
              opacityTiming={{ duration: 200 }}
              isolate
            />
          </motion.div>
        )}
      </AnimatePresence>

      {/* Animated track */}
      <SliderPrimitive.Track asChild>
        <motion.div
          className="relative w-full overflow-hidden rounded-3xl border
                     border-neutral-600/60 bg-neutral-900/80 px-4"
          animate={{ height: trackHeight }}
          transition={SPRING}
        >
          <div
            className="absolute inset-0 flex items-end justify-between px-4 py-2"
            style={{ gap: 4 }}
          >
            {Array.from({ length: TOTAL_BARS }, (_, i) => {
              const barPct = (i / (TOTAL_BARS - 1)) * 100;
              const pit = pitMultiplier(i, percent, isEngaged);
              const barH = (trackHeight - TRACK_PADDING) * pit;
              const isActive = barPct <= percent && isEngaged;

              let bg: string;
              if (isActive) {
                const t = percent > 0 ? barPct / percent : 0;
                const b = Math.round(255 - t * 135);
                bg = `rgba(${b},${b},${b},0.95)`;
              } else {
                bg = "rgba(80,80,80,0.6)";
              }

              return (
                <motion.div
                  key={i}
                  className="min-w-[4px] flex-1 rounded-full"
                  animate={{ height: barH, backgroundColor: bg }}
                  transition={SPRING}
                />
              );
            })}
          </div>
        </motion.div>
      </SliderPrimitive.Track>

      {/* Hidden accessible thumb */}
      <SliderPrimitive.Thumb
        className="absolute left-0 top-0 size-4 -translate-x-1/2 rounded-full
                   border-0 bg-transparent shadow-none outline-none ring-0 focus-visible:ring-0"
      />
    </SliderPrimitive.Root>
  );
}

Update the import paths to match your project setup.

Similar screens