word delay 90ms · char delay 25ms

Text Reveal

フレーズを単語単位・文字単位で分割し、順に fade + slide インさせる。tab で分割粒度を切り替えて stagger の効き方を比較する。

word delay 90ms · char delay 25ms

Motiondesignisstorytelling.

/**
 * Text Reveal — word by word / char by char の分割表示を tab で切り替える。
 * 移植元: motion-playground app/_sections/TextReveal.tsx(dark 変換 M-B-2)
 */
import { motion } from 'motion/react';
import { useState } from 'react';
import { easings } from '../../../lib/motion-tokens/playground';
import { useLabReplay } from './use-lab-replay';

const phrase = 'Motion design is storytelling.';

function ByWord({ playKey }: { playKey: number }) {
  const words = phrase.split(' ');
  return (
    <p className="text-2xl font-semibold leading-[1.5] text-[var(--text-primary)]">
      {words.map((word, i) => (
        <motion.span
          key={`${playKey}-${i}`}
          initial={{ opacity: 0, y: 12 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{
            duration: 0.4,
            ease: easings.md3Emphasized,
            delay: i * 0.09,
          }}
          className="mr-2 inline-block"
        >
          {word}
        </motion.span>
      ))}
    </p>
  );
}

function ByChar({ playKey }: { playKey: number }) {
  const chars = phrase.split('');
  return (
    <p className="text-2xl font-semibold leading-[1.5] text-[var(--text-primary)]">
      {chars.map((ch, i) => (
        <motion.span
          key={`${playKey}-${i}`}
          initial={{ opacity: 0, y: 6 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{
            duration: 0.25,
            ease: easings.md3Standard,
            delay: i * 0.025,
          }}
          className="inline-block"
        >
          {ch === ' ' ? ' ' : ch}
        </motion.span>
      ))}
    </p>
  );
}

export default function TextReveal() {
  const { rootRef, playKey } = useLabReplay();
  const [mode, setMode] = useState<'word' | 'char'>('word');

  return (
    <div ref={rootRef} data-lab-island className="w-full p-4 lg:p-6">
      <div className="mb-4 flex flex-wrap items-center gap-1.5" role="tablist">
        <button
          type="button"
          role="tab"
          aria-selected={mode === 'word'}
          onClick={() => setMode('word')}
          className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
            mode === 'word'
              ? 'bg-[var(--text-primary)] text-[var(--text-inverted)]'
              : 'bg-[var(--bg-surface)] text-[var(--text-secondary)] ring-1 ring-[var(--border-subtle)] hover:bg-[var(--bg-elevated)]'
          }`}
        >
          word by word
        </button>
        <button
          type="button"
          role="tab"
          aria-selected={mode === 'char'}
          onClick={() => setMode('char')}
          className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
            mode === 'char'
              ? 'bg-[var(--text-primary)] text-[var(--text-inverted)]'
              : 'bg-[var(--bg-surface)] text-[var(--text-secondary)] ring-1 ring-[var(--border-subtle)] hover:bg-[var(--bg-elevated)]'
          }`}
        >
          char by char
        </button>
      </div>
      <div className="rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-8 py-12">
        {mode === 'word' ? <ByWord playKey={playKey} /> : <ByChar playKey={playKey} />}
      </div>
    </div>
  );
}