React + MotionMicrointeracciones

Slider de volumen elástico

Un slider de volumen con goma: si arrastras más allá del final, la barra se estira con resistencia y rebota al soltar.

Ajustes

28px

Código

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 = "Volumen",
  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 para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Microinteracciones

Dónde usarlo

Este slider elástico imita el control de volumen de los móviles actuales. Encaja en un reproductor de música o podcasts, en los ajustes de una app de vídeo o en el brillo de un panel domótico. La barra engorda al pasar por encima, los altavoces laten en cero y al máximo, y al pasarte del borde se estira.

Empieza con una elasticidad de 20 a 30 píxeles; a cero tienes un slider normal y limpio. El estirón solo ocurre al arrastrar. Con teclado, las flechas lo mueven de cinco en cinco, y de diez en diez si mantienes Mayúsculas.