hover(≤100ms) · settle 150–300ms
Hover Micro-interactions
Button lift / Card tilt / Icon rotate / Image zoom / Underline reveal の 5 パターン。hover は 100ms 以下で反応し、150–300ms で settle する。
Button lift
Card tilt
Card tilt
scale + rotateX/Y
Icon rotate
Image zoom
Image zoom on hover
Link underline
/**
* Hover Micro-interactions — Button lift / Card tilt / Icon rotate /
* Image zoom / Underline reveal の 5 パターン。
* 移植元: motion-playground app/_sections/HoverInteractions.tsx(dark 変換 M-B-2)
*/
import { motion } from 'motion/react';
import { easings } from '../../../lib/motion-tokens/playground';
function HoverButton() {
return (
<motion.button
type="button"
whileHover={{ y: -2, boxShadow: '0 8px 20px rgba(0, 0, 0, 0.55)' }}
whileTap={{ y: 0, scale: 0.98 }}
transition={{ duration: 0.15, ease: easings.md3Standard }}
className="rounded-md bg-[var(--text-primary)] px-5 py-2.5 text-sm font-medium text-[var(--text-inverted)]"
style={{ boxShadow: '0 2px 6px rgba(0, 0, 0, 0.4)' }}
>
Button lift
</motion.button>
);
}
function HoverCard() {
return (
<motion.div
whileHover={{ scale: 1.02, rotateX: 3, rotateY: -3 }}
transition={{ duration: 0.25, ease: easings.gentle }}
style={{ transformStyle: 'preserve-3d', transformPerspective: 600 }}
className="cursor-pointer rounded-lg border border-[var(--border-strong)] bg-[var(--bg-surface)] p-4"
>
<p className="text-sm font-medium text-[var(--text-primary)]">Card tilt</p>
<p className="mt-1 text-xs text-[var(--text-muted)]">scale + rotateX/Y</p>
</motion.div>
);
}
function HoverIcon() {
return (
<motion.button
type="button"
whileHover={{ rotate: 90 }}
transition={{ duration: 0.25, ease: easings.md3Standard }}
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--bg-elevated)] text-[var(--text-secondary)]"
aria-label="settings"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</motion.button>
);
}
function HoverImage() {
return (
<div className="relative h-32 w-full overflow-hidden rounded-lg bg-[var(--bg-elevated)]">
<motion.div
whileHover={{ scale: 1.08 }}
transition={{ duration: 0.4, ease: easings.gentle }}
className="absolute inset-0 bg-gradient-to-br from-violet-500/60 via-fuchsia-400/40 to-amber-300/50"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-3 text-xs font-medium text-[var(--text-primary)]">
Image zoom on hover
</div>
</div>
);
}
function HoverLink() {
return (
<motion.a
href="#preview"
onClick={(e) => e.preventDefault()}
initial="rest"
whileHover="hover"
className="relative inline-block text-base text-[var(--text-primary)]"
>
<span>Underline reveal</span>
<motion.span
variants={{ rest: { scaleX: 0 }, hover: { scaleX: 1 } }}
transition={{ duration: 0.22, ease: easings.md3Emphasized }}
style={{ transformOrigin: 'left' }}
className="absolute inset-x-0 -bottom-0.5 h-0.5 bg-[var(--text-primary)]"
/>
</motion.a>
);
}
const CELL =
'rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-5';
const CELL_LABEL =
'mb-4 text-xs font-medium uppercase tracking-[0.1em] text-[var(--text-muted)]';
export default function HoverInteractions() {
return (
<div
data-lab-island
className="grid w-full gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 lg:p-6"
>
<div className={CELL}>
<p className={CELL_LABEL}>Button lift</p>
<div className="flex h-20 items-center justify-center">
<HoverButton />
</div>
</div>
<div className={CELL}>
<p className={CELL_LABEL}>Card tilt</p>
<div className="flex h-20 items-center justify-center">
<HoverCard />
</div>
</div>
<div className={CELL}>
<p className={CELL_LABEL}>Icon rotate</p>
<div className="flex h-20 items-center justify-center">
<HoverIcon />
</div>
</div>
<div className={`${CELL} sm:col-span-2`}>
<p className={CELL_LABEL}>Image zoom</p>
<HoverImage />
</div>
<div className={CELL}>
<p className={CELL_LABEL}>Link underline</p>
<div className="flex h-20 items-center justify-center">
<HoverLink />
</div>
</div>
</div>
);
} .button-lift {
transition:
transform 150ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 150ms cubic-bezier(0.2, 0, 0, 1);
}
.button-lift:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.55); }
.button-lift:active { transform: translateY(0) scale(0.98); }
.card-tilt { transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1); }
.card-tilt:hover { transform: perspective(600px) scale(1.02) rotateX(3deg) rotateY(-3deg); }
.icon-rotate { transition: transform 250ms cubic-bezier(0.2, 0, 0, 1); }
.icon-rotate:hover { transform: rotate(90deg); }
.image-zoom > img { transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1); }
.image-zoom:hover > img { transform: scale(1.08); }
.underline-reveal::after {
content: '';
position: absolute;
inset-inline: 0;
bottom: -2px;
height: 2px;
background: currentColor;
transform: scaleX(0);
transform-origin: left;
transition: transform 220ms cubic-bezier(0.05, 0.7, 0.1, 1);
}
.underline-reveal:hover::after { transform: scaleX(1); } Implement hover micro-interactions with motion/react:
- respond within 100ms, settle in 150-300ms
- button: whileHover { y: -2 } + soft shadow, whileTap { scale: 0.98 }, 150ms MD3 standard
- card: whileHover { scale: 1.02, rotateX: 3, rotateY: -3 } with transformPerspective 600
- icon: whileHover { rotate: 90 }, 250ms
- image: inner layer whileHover { scale: 1.08 }, 400ms gentle ease
- link: underline scaleX 0 -> 1 from left, 220ms MD3 emphasized
- leave states must return instantly (no exit delay)