React + MotionMikrointeraktionen

Elastischer Lautstärkeregler

Ein Lautstärkeregler mit Gummi: Ziehst du übers Ende hinaus, dehnt sich der Balken mit Widerstand und schnellt beim Loslassen zurück.

Einstellungen

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 = "Lautstärke",
  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 für KI

Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.

Mehr aus Mikrointeraktionen

Wofür du ihn nutzen kannst

Dieser elastische Slider übernimmt das Gefühl der Lautstärkeregelung aktueller Smartphones. Er passt in einen Musik- oder Podcast-Player, in die Einstellungen einer Video-App oder als Helligkeitsregler in einem Smart-Home-Dashboard. Der Balken wird beim Hovern dicker, die Lautsprecher-Icons pulsieren bei null und bei voll, und über den Rand hinaus dehnt er sich wie ein Gummiband.

Fang mit einer Elastizität von 20 bis 30 Pixeln an, bei null bekommst du einen ganz normalen Slider. Das Dehnen passiert nur beim Ziehen. Per Tastatur bewegen ihn die Pfeiltasten in Fünferschritten, mit gedrückter Umschalttaste in Zehnerschritten.