breathing 3.2s · floating 2.8s · spring(80, 18)

Ambient Loops

触っていない時にも生命感を出す 3 つの常時ループ演出。呼吸・浮遊・マウス視差。振幅を小さく保つのが疲れさせないコツ。

breathing 3.2s · floating 2.8s · spring(80, 18)

Breathing

scale + opacity の3.2s loop

Floating

y oscillation の2.8s loop

✦

Parallax

マウス位置で奥行き

マウスを動かす →

/**
 * Ambient Loops — breathing / floating / parallax の3つの常時ループ演出。
 * 移植元: motion-playground app/_sections/AmbientLoops.tsx(dark 変換 M-B-2)
 * 常時ループ・マウス駆動のデモのため useLabReplay は不要。
 */
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react';
import { useEffect, useRef } from 'react';

function Breathing() {
  return (
    <motion.div
      animate={{ scale: [1, 1.08, 1], opacity: [0.6, 1, 0.6] }}
      transition={{
        duration: 3.2,
        ease: 'easeInOut',
        repeat: Infinity,
      }}
      className="h-16 w-16 rounded-full bg-gradient-to-br from-emerald-400/80 to-sky-500/80 shadow-lg"
    />
  );
}

function Floating() {
  return (
    <motion.div
      animate={{ y: [0, -10, 0] }}
      transition={{
        duration: 2.8,
        ease: 'easeInOut',
        repeat: Infinity,
      }}
      className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-300/80 to-rose-400/80 text-2xl shadow-md"
    >
      ✦
    </motion.div>
  );
}

function Parallax() {
  const containerRef = useRef<HTMLDivElement>(null);
  const mouseX = useMotionValue(0);
  const mouseY = useMotionValue(0);

  const springConfig = { stiffness: 80, damping: 18 };
  const x = useSpring(mouseX, springConfig);
  const y = useSpring(mouseY, springConfig);

  const farX = useTransform(x, (v) => v * 0.08);
  const farY = useTransform(y, (v) => v * 0.08);
  const midX = useTransform(x, (v) => v * 0.16);
  const midY = useTransform(y, (v) => v * 0.16);
  const nearX = useTransform(x, (v) => v * 0.3);
  const nearY = useTransform(y, (v) => v * 0.3);

  useEffect(() => {
    function handle(e: MouseEvent) {
      if (!containerRef.current) return;
      const rect = containerRef.current.getBoundingClientRect();
      mouseX.set(e.clientX - rect.left - rect.width / 2);
      mouseY.set(e.clientY - rect.top - rect.height / 2);
    }
    const node = containerRef.current;
    if (node) {
      node.addEventListener('mousemove', handle);
      return () => node.removeEventListener('mousemove', handle);
    }
  }, [mouseX, mouseY]);

  return (
    <div
      ref={containerRef}
      className="relative h-32 w-full overflow-hidden rounded-lg bg-[var(--bg-elevated)]"
    >
      <motion.div
        style={{ x: farX, y: farY }}
        className="absolute top-3 left-6 h-6 w-6 rounded-full bg-indigo-300/70"
      />
      <motion.div
        style={{ x: midX, y: midY }}
        className="absolute right-10 top-10 h-10 w-10 rounded-md bg-fuchsia-400/70"
      />
      <motion.div
        style={{ x: nearX, y: nearY }}
        className="absolute bottom-4 left-1/3 h-12 w-12 rounded-2xl bg-indigo-400/80"
      />
      <p className="absolute right-3 bottom-2 text-[11px] text-[var(--text-secondary)]">
        マウスを動かす →
      </p>
    </div>
  );
}

function Card({
  title,
  note,
  children,
}: {
  title: string;
  note: string;
  children: React.ReactNode;
}) {
  return (
    <div className="rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-5">
      <p className="text-sm font-medium text-[var(--text-primary)]">{title}</p>
      <p className="mt-0.5 mb-4 text-xs text-[var(--text-muted)]">{note}</p>
      <div className="flex h-32 items-center justify-center">{children}</div>
    </div>
  );
}

export default function AmbientLoops() {
  return (
    <div
      data-lab-island
      className="grid w-full gap-4 p-4 sm:grid-cols-3 lg:p-6"
    >
      <Card title="Breathing" note="scale + opacity の3.2s loop">
        <Breathing />
      </Card>
      <Card title="Floating" note="y oscillation の2.8s loop">
        <Floating />
      </Card>
      <Card title="Parallax" note="マウス位置で奥行き">
        <Parallax />
      </Card>
    </div>
  );
}