hover(≤100ms) · settle 150–300ms

Hover Micro-interactions

Button lift / Card tilt / Icon rotate / Image zoom / Underline reveal の 5 パターン。hover は 100ms 以下で反応し、150–300ms で settle する。

hover(≤100ms) · settle 150–300ms

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>
  );
}