Luces de Madrid

Noche Temprana

React + MotionMicro-interactions

Vinyle lecture/pause

Touchez le disque : lecture devient pause, le bras se pose sur les sillons et le vinyle accélère puis freine avec inertie.

Réglages

Code

import { useEffect, useState } from "react";
import { motion, useAnimationFrame, useMotionValue, useReducedMotion, useSpring } from "framer-motion";

const PLAY_L = "M8 5.5 L13.5 8.75 L13.5 15.25 L8 18.5 Z";
const PLAY_R = "M13.5 8.75 L19 12 L19 12 L13.5 15.25 Z";
const PAUSE_L = "M7 5.5 L10.5 5.5 L10.5 18.5 L7 18.5 Z";
const PAUSE_R = "M13.5 5.5 L17 5.5 L17 18.5 L13.5 18.5 Z";
const vinylMorph = { type: "spring" as const, stiffness: 420, damping: 30 };

interface VinylPlayerProps {
  title?: string;
  artist?: string;
  color?: string;
}

export default function VinylPlayer({
  title = "Luces de Madrid",
  artist = "Noche Temprana",
  color = "#ff5a36",
}: VinylPlayerProps) {
  const [playing, setPlaying] = useState(false);
  const reduce = useReducedMotion();
  const speed = useSpring(0, { stiffness: 26, damping: 12 });
  const spin = useMotionValue(0);

  useEffect(() => {
    speed.set(playing ? 1 : 0);
  }, [playing, speed]);

  useAnimationFrame((_, delta) => {
    const s = speed.get();
    if (reduce || s < 0.001) return;
    spin.set((spin.get() + delta * 0.2 * s) % 360);
  });

  return (
    <div className="flex flex-col items-center gap-4">
      <div className="relative h-[200px] w-[232px]">
        <motion.button
          type="button"
          aria-label={playing ? "Pause " + title : "Lire " + title}
          aria-pressed={playing}
          onClick={() => setPlaying((p) => !p)}
          whileTap={{ scale: 0.97 }}
          transition={{ type: "spring", stiffness: 500, damping: 28 }}
          className="absolute left-0 top-2 h-[184px] w-[184px] rounded-full outline-none focus-visible:ring-2 focus-visible:ring-black/25 focus-visible:ring-offset-4 dark:focus-visible:ring-white/40 dark:focus-visible:ring-offset-black"
          style={{ boxShadow: "0 1px 2px rgba(0,0,0,0.25), 0 14px 34px -10px rgba(0,0,0,0.5)" }}
        >
          <motion.span
            className="absolute inset-0 rounded-full"
            style={{
              rotate: spin,
              background: "repeating-radial-gradient(circle at 50% 50%, #121212 0 1px, #1d1d1d 1px 2.5px)",
            }}
          >
            <span className="absolute inset-[33%] rounded-full" style={{ backgroundColor: color }}>
              <span className="absolute left-1/2 top-[12%] h-[3px] w-5 -translate-x-1/2 rounded-full bg-white/45" />
              <span className="absolute bottom-[14%] left-1/2 h-[2px] w-3 -translate-x-1/2 rounded-full bg-black/20" />
            </span>
          </motion.span>
          <span
            className="pointer-events-none absolute inset-0 rounded-full"
            style={{
              background:
                "conic-gradient(from 30deg, transparent 0deg, rgba(255,255,255,0.13) 30deg, transparent 70deg, transparent 180deg, rgba(255,255,255,0.08) 215deg, transparent 250deg)",
            }}
            aria-hidden="true"
          />
          <span className="pointer-events-none absolute inset-0 rounded-full ring-1 ring-inset ring-white/10" aria-hidden="true" />
          <span className="absolute inset-0 grid place-items-center text-white" aria-hidden="true">
            <svg viewBox="0 0 24 24" className="h-6 w-6 drop-shadow-[0_1px_1px_rgba(0,0,0,0.25)]" fill="currentColor">
              <motion.path initial={false} animate={{ d: playing ? PAUSE_L : PLAY_L }} transition={vinylMorph} />
              <motion.path initial={false} animate={{ d: playing ? PAUSE_R : PLAY_R }} transition={vinylMorph} />
            </svg>
          </span>
        </motion.button>
        <motion.div
          className="pointer-events-none absolute left-[206px] top-[18px] h-0 w-0"
          initial={false}
          animate={{ rotate: playing ? 22 : 0 }}
          transition={
            playing ? { type: "spring", stiffness: 90, damping: 13, delay: 0.12 } : { type: "spring", stiffness: 220, damping: 24 }
          }
          aria-hidden="true"
        >
          <svg className="absolute overflow-visible" style={{ left: -20, top: -24 }} width="40" height="172" viewBox="-20 -24 40 172">
            <rect x="-5" y="-22" width="10" height="13" rx="3" className="fill-[#3a3a3c] dark:fill-[#8e8e93]" />
            <path
              d="M0 0 V106 L-9 124"
              fill="none"
              strokeWidth="3.5"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="stroke-[#c7c7cc] dark:stroke-[#636366]"
            />
            <rect x="-15" y="120" width="11" height="17" rx="2.5" transform="rotate(27 -9.5 128)" className="fill-[#3a3a3c] dark:fill-[#aeaeb2]" />
            <circle r="11" className="fill-[#e5e5ea] stroke-[#c7c7cc] dark:fill-[#3a3a3c] dark:stroke-[#48484a]" strokeWidth="1" />
            <circle r="4" className="fill-[#aeaeb2] dark:fill-[#8e8e93]" />
          </svg>
        </motion.div>
      </div>
      <div className="text-center">
        <p className="text-[15px] font-semibold tracking-tight text-[#1d1d1f] dark:text-[#f5f5f7]">{title}</p>
        <p className="text-[13px] text-[#86868b]">{artist}</p>
      </div>
    </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

Cette animation de bouton lecture pause transforme un simple interrupteur en objet : un disque qui tourne avec son propre bras. Elle va bien au site d’un groupe ou d’un DJ, à un disquaire ou à un podcast à l’esprit rétro. Le disque met un instant à prendre sa vitesse et ralentit doucement, comme une vraie platine.

Ce n’est que la partie visuelle : il ne joue aucun son tout seul. Reliez-le à votre lecteur audio pour que le disque démarre et s’arrête avec le morceau. Adaptez la couleur de l’étiquette à votre pochette et modifiez titre et artiste.