breathing 3.2s · floating 2.8s · spring(80, 18)
Ambient Loops
触っていない時にも生命感を出す 3 つの常時ループ演出。呼吸・浮遊・マウス視差。振幅を小さく保つのが疲れさせないコツ。
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>
);
} Implement ambient loops with motion/react:
- breathing: scale [1, 1.08, 1] + opacity [0.6, 1, 0.6], 3.2s, easeInOut, infinite
- floating: translateY [0, -10, 0], 2.8s, easeInOut, infinite
- parallax: track mouse offset from container center, smooth via useSpring (stiffness 80, damping 18), move 3 layers at 0.08x / 0.16x / 0.3x for depth
- keep amplitude small (max 10px / 8% scale) to avoid eye fatigue
- animate only transform / opacity (GPU compositing)