spinner 900ms · skeleton 1.4s · dots stagger 120ms

Loading Patterns

待ち時間の見せ方 5 パターンを並べて比較する。spinner は安全策、skeleton は構造が見える分体感速度が速い。dots はチャット系で定番。

spinner 900ms · skeleton 1.4s · dots stagger 120ms

Spinner

linear rotate のみ OK

Progress (indeterminate)

長さ不明の処理

Skeleton

構造が見える分速く感じる

Shimmer

グラデーション sliding

Pulsing dots

チャット・思考中

/**
 * Loading Patterns — 待たせるときの5パターン(Spinner / Progress / Skeleton / Shimmer / Pulsing dots)。
 * 移植元: motion-playground app/_sections/LoadingPatterns.tsx(dark 変換 M-B-2)
 */
import { motion } from 'motion/react';
import type { ReactNode } from 'react';

function Spinner() {
  return (
    <motion.div
      animate={{ rotate: 360 }}
      transition={{ duration: 0.9, ease: 'linear', repeat: Infinity }}
      className="h-8 w-8 rounded-full border-[3px] border-[var(--border-strong)] border-t-[var(--text-primary)]"
    />
  );
}

function ProgressBar() {
  return (
    <div className="h-2 w-full overflow-hidden rounded-full bg-[var(--bg-elevated)]">
      <motion.div
        animate={{ x: ['-100%', '100%'] }}
        transition={{
          duration: 1.4,
          ease: 'easeInOut',
          repeat: Infinity,
        }}
        className="h-full w-1/2 rounded-full bg-[var(--text-primary)]"
      />
    </div>
  );
}

function Skeleton() {
  return (
    <div className="space-y-2">
      {[80, 60, 90].map((w, i) => (
        <motion.div
          key={i}
          animate={{ opacity: [0.4, 0.9, 0.4] }}
          transition={{
            duration: 1.4,
            ease: 'easeInOut',
            repeat: Infinity,
            delay: i * 0.15,
          }}
          className="h-3 rounded-full bg-[var(--text-muted)]"
          style={{ width: `${w}%` }}
        />
      ))}
    </div>
  );
}

function Shimmer() {
  return (
    <div className="relative h-16 w-full overflow-hidden rounded-md bg-[var(--bg-elevated)]">
      <motion.div
        animate={{ x: ['-100%', '200%'] }}
        transition={{
          duration: 1.6,
          ease: 'easeInOut',
          repeat: Infinity,
          repeatDelay: 0.2,
        }}
        className="absolute inset-y-0 w-1/2 bg-gradient-to-r from-transparent via-[rgba(255,255,255,0.16)] to-transparent"
      />
    </div>
  );
}

function PulsingDots() {
  return (
    <div className="flex items-center gap-2">
      {[0, 1, 2].map((i) => (
        <motion.div
          key={i}
          animate={{ y: [0, -8, 0] }}
          transition={{
            duration: 0.7,
            ease: 'easeInOut',
            repeat: Infinity,
            delay: i * 0.12,
          }}
          className="h-2.5 w-2.5 rounded-full bg-[var(--text-primary)]"
        />
      ))}
    </div>
  );
}

function Card({
  title,
  note,
  children,
}: {
  title: string;
  note: string;
  children: 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-20 items-center justify-center">{children}</div>
    </div>
  );
}

export default function LoadingPatterns() {
  return (
    <div
      data-lab-island
      className="grid w-full gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 lg:p-6"
    >
      <Card title="Spinner" note="linear rotate のみ OK">
        <Spinner />
      </Card>
      <Card title="Progress (indeterminate)" note="長さ不明の処理">
        <ProgressBar />
      </Card>
      <Card title="Skeleton" note="構造が見える分速く感じる">
        <div className="w-full">
          <Skeleton />
        </div>
      </Card>
      <Card title="Shimmer" note="グラデーション sliding">
        <Shimmer />
      </Card>
      <Card title="Pulsing dots" note="チャット・思考中">
        <PulsingDots />
      </Card>
    </div>
  );
}