Curseur de volume élastique
Un curseur de volume élastique : tirez au-delà du bout et la barre s’étire avec résistance, puis revient en place au relâchement.
Réglages
Code
import { useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
import { Volume, Volume2, VolumeX } from "lucide-react";
interface ElasticSliderProps {
label?: string;
color?: string;
stretch?: number;
}
export default function ElasticSlider({
label = "Volume",
color = "#5e5ce6",
stretch = 28,
}: ElasticSliderProps) {
const [value, setValue] = useState(60);
const [active, setActive] = useState(false);
const [hover, setHover] = useState(false);
const track = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const last = useRef(60);
const overflow = useMotionValue(0);
const lowPulse = useMotionValue(1);
const highPulse = useMotionValue(1);
const reduce = useReducedMotion();
const scaleX = useTransform(overflow, (o) => 1 + Math.abs(o) / (track.current?.offsetWidth || 200));
const scaleY = useTransform(overflow, (o) => 1 - Math.min(Math.abs(o) / 90, 0.3));
const originX = useTransform(overflow, (o) => (o < 0 ? 1 : 0));
const lowX = useTransform(overflow, (o) => (o < 0 ? o : 0));
const highX = useTransform(overflow, (o) => (o > 0 ? o : 0));
const commit = (next: number) => {
const v = Math.min(100, Math.max(0, Math.round(next)));
if (!reduce && v !== last.current) {
if (v === 0) animate(lowPulse, [1, 1.3, 1], { duration: 0.35 });
if (v === 100) animate(highPulse, [1, 1.3, 1], { duration: 0.35 });
}
last.current = v;
setValue(v);
};
const follow = (clientX: number) => {
const el = track.current;
if (!el) return;
const r = el.getBoundingClientRect();
commit(((clientX - r.left) / r.width) * 100);
const raw = clientX < r.left ? clientX - r.left : clientX > r.right ? clientX - r.right : 0;
const max = Math.max(0, stretch);
overflow.set(reduce || max === 0 ? 0 : Math.sign(raw) * max * (1 - Math.exp(-Math.abs(raw) / (max * 2.5))));
};
const release = () => {
dragging.current = false;
setActive(false);
animate(overflow, 0, { type: "spring", stiffness: 420, damping: 18 });
};
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
dragging.current = true;
setActive(true);
follow(e.clientX);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 10 : 5;
const map: Record<string, number> = {
ArrowRight: value + step,
ArrowUp: value + step,
ArrowLeft: value - step,
ArrowDown: value - step,
Home: 0,
End: 100,
};
const target = map[e.key];
if (target === undefined) return;
e.preventDefault();
commit(target);
};
const big = active || hover;
return (
<div className="flex w-[17rem] max-w-full select-none items-center gap-3 text-[#86868b]">
<motion.span style={{ x: lowX, scale: lowPulse }} className="grid h-5 w-5 shrink-0 place-items-center">
{value === 0 ? <VolumeX className="h-[18px] w-[18px]" strokeWidth={1.75} /> : <Volume className="h-[18px] w-[18px]" strokeWidth={1.75} />}
</motion.span>
<div
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
onPointerDown={onPointerDown}
onPointerMove={(e) => dragging.current && follow(e.clientX)}
onPointerUp={release}
onPointerCancel={release}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onKeyDown={onKeyDown}
className="relative flex h-10 flex-1 cursor-grab touch-none items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-black/20 active:cursor-grabbing dark:focus-visible:ring-white/30"
>
<motion.div
ref={track}
className="relative w-full overflow-hidden rounded-full bg-black/[0.08] dark:bg-white/[0.14]"
style={{ scaleX, scaleY, originX }}
initial={false}
animate={{ height: big ? 12 : 6 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
<motion.div
className="absolute inset-y-0 left-0"
style={{ backgroundColor: color }}
initial={false}
animate={{ width: value + "%" }}
transition={active ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 36 }}
/>
</motion.div>
</div>
<motion.span style={{ x: highX, scale: highPulse }} className="grid h-5 w-5 shrink-0 place-items-center">
<Volume2 className="h-[18px] w-[18px]" strokeWidth={1.75} />
</motion.span>
</div>
);
}
Prompt pour l’IA
Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.
Plus dans Micro-interactions
Où l’utiliser
Ce curseur élastique reprend la sensation du réglage de volume des smartphones récents. Il convient à un lecteur de musique ou de podcasts, aux réglages d’une appli vidéo ou au réglage de luminosité d’un tableau de bord domotique. La barre s’épaissit au survol, les haut-parleurs pulsent à zéro et au maximum, et au-delà du bord elle s’étire.
Commencez avec une élasticité de 20 à 30 pixels ; à zéro, vous obtenez un curseur classique et net. L’étirement n’a lieu qu’en faisant glisser. Au clavier, les flèches avancent de cinq en cinq, et de dix en dix avec Maj enfoncée.