React + MotionMicro-interactions

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

28px

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.