word delay 90ms · char delay 25ms
Text Reveal
フレーズを単語単位・文字単位で分割し、順に fade + slide インさせる。tab で分割粒度を切り替えて stagger の効き方を比較する。
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>
);
} .text-reveal-word {
display: inline-block;
margin-right: 0.5em;
opacity: 0;
transform: translateY(12px);
animation: text-reveal-in 400ms cubic-bezier(0.05, 0.7, 0.1, 1) forwards;
/* index を custom property で渡す: style="--i: 0" */
animation-delay: calc(var(--i) * 90ms);
}
@keyframes text-reveal-in {
to {
opacity: 1;
transform: translateY(0);
}
} Implement a text reveal with motion/react:
- split text into words or characters (toggle via tabs)
- word mode: opacity 0 -> 1, translateY 12px -> 0, 400ms, cubic-bezier(0.05, 0.7, 0.1, 1), stagger 90ms per word
- char mode: opacity 0 -> 1, translateY 6px -> 0, 250ms, cubic-bezier(0.2, 0, 0, 1), stagger 25ms per char
- wrap each unit in an inline-block span so transforms apply independently
- preserve literal spaces between words in char mode
- remount the list with a changing key to re-trigger on replay