Skip to content

Commit b648b37

Browse files
committed
add: cool slider from reactbits, thanks david
1 parent 48c22a6 commit b648b37

File tree

2 files changed

+236
-16
lines changed

2 files changed

+236
-16
lines changed

apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useForm } from 'react-hook-form';
77
import { toast } from 'sonner';
88
import { z } from 'zod';
99
import { Button } from '@/components/ui/button';
10+
import { Slider } from '@/components/ui/elastic-slider';
1011
import {
1112
Form,
1213
FormControl,
@@ -426,23 +427,14 @@ export function FlagSheet({
426427

427428
return (
428429
<FormItem>
429-
<div className="flex items-center justify-between">
430-
<FormLabel>Rollout Percentage</FormLabel>
431-
<span className="font-mono font-semibold text-sm">
432-
{currentValue}%
433-
</span>
434-
</div>
430+
<FormLabel>Rollout Percentage</FormLabel>
435431
<FormControl>
436-
<div className="space-y-3">
437-
<input
438-
className="slider h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700"
439-
max="100"
440-
min="0"
441-
onChange={(e) =>
442-
field.onChange(Number(e.target.value))
443-
}
444-
step="5"
445-
type="range"
432+
<div className="space-y-4">
433+
<Slider
434+
max={100}
435+
min={0}
436+
onValueChange={field.onChange}
437+
step={5}
446438
value={currentValue}
447439
/>
448440
<div className="flex justify-center gap-2">
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
'use client';
2+
3+
import { MinusIcon, PlusIcon } from '@phosphor-icons/react';
4+
import {
5+
motion,
6+
useMotionValue,
7+
useMotionValueEvent,
8+
useTransform,
9+
} from 'motion/react';
10+
import { useEffect, useRef, useState } from 'react';
11+
import { cn } from '@/lib/utils';
12+
13+
const MAX_OVERFLOW = 30;
14+
15+
interface SliderProps {
16+
value?: number;
17+
onValueChange?: (value: number) => void;
18+
min?: number;
19+
max?: number;
20+
step?: number;
21+
className?: string;
22+
leftIcon?: React.ReactNode;
23+
rightIcon?: React.ReactNode;
24+
showValue?: boolean;
25+
disabled?: boolean;
26+
}
27+
28+
export function Slider({
29+
value = 0,
30+
onValueChange,
31+
min = 0,
32+
max = 100,
33+
step = 1,
34+
className,
35+
leftIcon = <MinusIcon size={16} />,
36+
rightIcon = <PlusIcon size={16} />,
37+
showValue = true,
38+
disabled = false,
39+
}: SliderProps) {
40+
const [internalValue, setInternalValue] = useState(value);
41+
const sliderRef = useRef<HTMLDivElement>(null);
42+
const [region, setRegion] = useState<'left' | 'middle' | 'right'>('middle');
43+
const [isDragging, setIsDragging] = useState(false);
44+
45+
const clientX = useMotionValue(0);
46+
const overflow = useMotionValue(0);
47+
48+
useEffect(() => {
49+
setInternalValue(value);
50+
}, [value]);
51+
52+
useMotionValueEvent(clientX, 'change', (latest: number) => {
53+
if (sliderRef.current && isDragging) {
54+
const { left, right } = sliderRef.current.getBoundingClientRect();
55+
let newOverflow: number;
56+
57+
if (latest < left) {
58+
setRegion('left');
59+
newOverflow = left - latest;
60+
} else if (latest > right) {
61+
setRegion('right');
62+
newOverflow = latest - right;
63+
} else {
64+
setRegion('middle');
65+
newOverflow = 0;
66+
}
67+
68+
overflow.jump(decay(newOverflow, MAX_OVERFLOW));
69+
}
70+
});
71+
72+
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
73+
if (!isDragging || disabled || !sliderRef.current) {
74+
return;
75+
}
76+
77+
const { left, width } = sliderRef.current.getBoundingClientRect();
78+
let newValue = min + ((e.clientX - left) / width) * (max - min);
79+
80+
// Apply step
81+
if (step > 0) {
82+
newValue = Math.round(newValue / step) * step;
83+
}
84+
85+
// Clamp to bounds
86+
newValue = Math.min(Math.max(newValue, min), max);
87+
88+
setInternalValue(newValue);
89+
onValueChange?.(newValue);
90+
clientX.jump(e.clientX);
91+
};
92+
93+
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
94+
if (disabled) {
95+
return;
96+
}
97+
98+
setIsDragging(true);
99+
handlePointerMove(e);
100+
e.currentTarget.setPointerCapture(e.pointerId);
101+
};
102+
103+
const handlePointerUp = () => {
104+
setIsDragging(false);
105+
setRegion('middle');
106+
overflow.jump(0);
107+
};
108+
109+
const getPercentage = (): number => {
110+
const range = max - min;
111+
if (range === 0) {
112+
return 0;
113+
}
114+
return ((internalValue - min) / range) * 100;
115+
};
116+
117+
return (
118+
<div className={cn('space-y-3', className)}>
119+
<div
120+
className={cn(
121+
'flex items-center gap-4',
122+
disabled && 'cursor-not-allowed opacity-50'
123+
)}
124+
>
125+
{/* Left Icon */}
126+
<motion.div
127+
className="flex-shrink-0 text-muted-foreground"
128+
style={{
129+
x: useTransform(() =>
130+
region === 'left' ? -overflow.get() / 2 : 0
131+
),
132+
scale: region === 'left' ? 1.3 : 1,
133+
}}
134+
>
135+
{leftIcon}
136+
</motion.div>
137+
138+
{/* Slider Track */}
139+
<div
140+
className={cn(
141+
'relative flex-1 cursor-pointer touch-none',
142+
disabled && 'cursor-not-allowed'
143+
)}
144+
onPointerDown={handlePointerDown}
145+
onPointerLeave={handlePointerUp}
146+
onPointerMove={handlePointerMove}
147+
onPointerUp={handlePointerUp}
148+
ref={sliderRef}
149+
>
150+
<motion.div
151+
className="relative"
152+
style={{
153+
scaleX: useTransform(() => {
154+
if (sliderRef.current) {
155+
const { width } = sliderRef.current.getBoundingClientRect();
156+
return 1 + overflow.get() / width;
157+
}
158+
return 1;
159+
}),
160+
scaleY: useTransform(overflow, [0, MAX_OVERFLOW], [1, 0.7]),
161+
transformOrigin: useTransform(() => {
162+
if (sliderRef.current) {
163+
const { left, width } =
164+
sliderRef.current.getBoundingClientRect();
165+
return clientX.get() < left + width / 2 ? 'right' : 'left';
166+
}
167+
return 'center';
168+
}),
169+
}}
170+
>
171+
{/* Track Background */}
172+
<div className="h-2 w-full rounded-full bg-secondary">
173+
{/* Progress */}
174+
<div
175+
className="h-full rounded-full bg-primary"
176+
style={{ width: `${getPercentage()}%` }}
177+
/>
178+
</div>
179+
180+
{/* Thumb */}
181+
<motion.div
182+
className="-translate-y-1/2 absolute top-1/2 h-4 w-4 rounded-full border-2 border-primary bg-background shadow-sm"
183+
style={{
184+
left: `${getPercentage()}%`,
185+
x: '-50%',
186+
}}
187+
whileHover={{ scale: 1.1 }}
188+
whileTap={{ scale: 0.95 }}
189+
/>
190+
</motion.div>
191+
</div>
192+
193+
{/* Right Icon */}
194+
<motion.div
195+
className="flex-shrink-0 text-muted-foreground"
196+
style={{
197+
x: useTransform(() =>
198+
region === 'right' ? overflow.get() / 2 : 0
199+
),
200+
scale: region === 'right' ? 1.3 : 1,
201+
}}
202+
>
203+
{rightIcon}
204+
</motion.div>
205+
</div>
206+
207+
{/* Value Display */}
208+
{showValue && (
209+
<div className="text-center">
210+
<span className="font-medium font-mono text-sm">
211+
{Math.round(internalValue)}
212+
{max === 100 && '%'}
213+
</span>
214+
</div>
215+
)}
216+
</div>
217+
);
218+
}
219+
220+
// Decay function for smooth overflow animation
221+
function decay(value: number, max: number): number {
222+
if (max === 0) {
223+
return 0;
224+
}
225+
const entry = value / max;
226+
const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5);
227+
return sigmoid * max;
228+
}

0 commit comments

Comments
 (0)