Reactで、タイプライター風に1文字ずつ表示する方法
文字を1文字ずつ表示するには、「何文字目まで表示したか」を state に持ち、setTimeout で一定の時間ごとに 1 ずつ増やします。表示する文字列は、Intl.Segmenter で見た目の1文字ごとに分けておくと、絵文字が途中で割れません。さらに、全文を見えない状態で同じ場所に置いておくと、文字が増えても下の内容が押し下げられません。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。時間は Playwright の clock で止め、10ms ずつ進めては表示を読み取っています。Firefox と Safari、スマートフォンの実機、実際のスクリーンリーダー、サーバーでレンダリングする構成(Next.js など)では試していません。

何文字目まで表示したかを持つフック
表示の処理は、文字列を受け取って「いま表示する部分」を返すフックにまとめます。
import { useEffect, useMemo, useState } from 'react';
// 見た目の1文字(書記素)ごとに分けるための Segmenter
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
type Options = {
speed?: number; // 1文字あたりのミリ秒
pause?: number; // 「、」「。」などの後で止まるミリ秒
};
export function useTypewriter(text: string, { speed = 80, pause = 400 }: Options = {}) {
const chars = useMemo(() => Array.from(segmenter.segment(text), (s) => s.segment), [text]);
// 動きを減らす設定なら、最初から全部を表示する
const [count, setCount] = useState(() =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches ? chars.length : 0,
);
useEffect(() => {
if (count >= chars.length) return;
const prev = chars[count - 1];
const delay = prev && /[、。!?]/.test(prev) ? pause : speed;
const id = setTimeout(() => setCount((c) => c + 1), delay);
return () => clearTimeout(id);
}, [count, chars, speed, pause]);
return {
shown: chars.slice(0, count).join(''),
done: count >= chars.length,
skip: () => setCount(chars.length), // 残りをすぐに表示する
};
}
state に持つのは、表示した文字の数 count だけです。表示する文字列は、chars の先頭から count 個をつなげて、レンダーのたびに作ります。なお、count の初めの値を決めるときに window.matchMedia を読んでいます。window のないサーバーでレンダリングする構成では、このままでは使えないはずです(試していません)。
effect は count が変わるたびに実行され、次の1文字を出すための setTimeout を1つだけ予約します。直前に出した文字が「、」「。」「!」「?」なら、待ち時間を pause に延ばします。句読点のところで少し止まると、文の区切りが読み取りやすくなります。
setInterval で一定の間隔を回し続ける作りにしないのは、文字ごとに待ち時間を変えたいからです。1文字ごとに setTimeout を予約し直せば、次に待つ時間をその都度決められます。
「こんにちは。猫の手です。」を speed 80ms、pause 400ms で表示し、時間ごとに読み取りました。
0ms「」
80ms「こ」
160ms「こん」
400ms「こんにちは」
480ms「こんにちは。」
880ms「こんにちは。猫」
960ms「こんにちは。猫の」
1280ms「こんにちは。猫の手です。」
1680ms「こんにちは。猫の手です。」
2000ms「こんにちは。猫の手です。」
80ms ごとに1文字ずつ増え、「。」を出した 480ms の後は、400ms 止まってから 880ms に「猫」が出ました。最後の「。」を出した 1280ms からは、時間を進めても表示は変わりません。
絵文字を途中で割らない分け方
文字列を1文字ずつ切り出すときに text.slice(0, n) を使うと、絵文字の途中で切れることがあります。JavaScript の文字列の長さは、UTF-16 のコード単位で数えます。絵文字には2単位で表すものや、いくつかの文字をつないで1つに見せるものがあり、その途中で切れてしまうためです。
「猫🐈⬛です」で、3つの分け方を比べました。🐈⬛(黒猫)は、🐈・ゼロ幅接合子(ZWJ)・⬛ の3つをつないで1つの絵文字として表示するものです。
text.length: 7
slice(UTF-16 の単位):
1: 猫
2: 猫\uD83D
3: 猫🐈
4: 猫🐈[ZWJ]
5: 猫🐈[ZWJ]⬛
6: 猫🐈[ZWJ]⬛で
7: 猫🐈[ZWJ]⬛です
Array.from: 6 個
1: 猫
2: 猫🐈
3: 猫🐈[ZWJ]
4: 猫🐈[ZWJ]⬛
5: 猫🐈[ZWJ]⬛で
6: 猫🐈[ZWJ]⬛です
Intl.Segmenter: 4 個
1: 猫
2: 猫🐈⬛
3: 猫🐈⬛で
4: 猫🐈⬛です
それぞれの途中の文字列を Chromium で表示すると、次のようになりました。

slice では、2回目に置き換え文字(�)が出ました。🐈 を表す2単位のうち、前半だけが残ったためです。Array.from はコードポイント単位で分けるので置き換え文字は出ませんが、黒猫になる前に別の猫が2回出ます。Intl.Segmenter で granularity: 'grapheme'(書記素)を指定すると、見た目の1文字ごとに分けるので、2回目でいきなり黒猫が出て、途中の猫は出ませんでした。フックでは、この分け方で作った配列 chars を使っています。
chars は useMemo で text が変わったときだけ作り直します。分ける処理を、1文字出すたびのレンダーで繰り返さないためです。
下の内容を押し下げないコンポーネント
文字が増えていくと、行の終わりで折り返すたびに表示の高さが増え、その下にある内容が押し下げられます。これを防ぐため、全文を見えない状態で同じ場所に置き、全部を表示したときの大きさを最初から確保しておきます。
import { useTypewriter } from './useTypewriter';
type Props = {
text: string;
speed?: number;
pause?: number;
};
export function Typewriter({ text, speed, pause }: Props) {
const { shown, done } = useTypewriter(text, { speed, pause });
return (
<p className="typewriter">
{/* 全文を見えない状態で置き、最後の大きさの場所を先に取っておく */}
<span className="typewriter-ghost" aria-hidden="true">
{text}
<span className="caret" />
</span>
<span className="typewriter-typed" aria-hidden="true">
{shown}
<span className={done ? 'caret caret-done' : 'caret'} />
</span>
{/* 読み上げには全文を1回だけ渡す */}
<span className="visually-hidden">{text}</span>
</p>
);
}
CSS では、display: grid の中で2つの span を同じマス(grid-area: 1 / 1)に重ねます。全文のほうは visibility: hidden にします。MDN によると、hidden のボックスは描画されませんが、レイアウトには通常どおり影響します。見えないまま全文の大きさの場所を取るので、重ねたほうの文字が増えても、マスの大きさは変わりません。全文の後ろにもカーソルと同じ幅の span を置いているのは、カーソルの分で行が折り返す場合にも備えるためです。
.typewriter {
display: grid; /* 2つの span を同じ場所に重ねる */
}
.typewriter-ghost,
.typewriter-typed {
grid-area: 1 / 1;
}
.typewriter-ghost {
visibility: hidden;
}
幅 320px の枠に 63 文字の文章を表示し、すぐ下にある段落の位置を、文字が増えるたびに測りました。全文を置かない版(<p>{shown}<span className="caret" /></p>)と比べています。
全文を置かない: 下の段落の top 56, 80, 104, 128px / 表示の高さ 24, 48, 72, 96px
全文を置く: 下の段落の top 128px / 表示の高さ 96px
全文を置かない版では、行が増えるたびに下の段落が 24px ずつ、合わせて 3 回押し下げられました。全文を置く版では、最初から4行分の高さ(96px)を取っているので、下の段落は 128px のまま動きませんでした。
カーソルを打ち終わってから点滅させる
文字の後ろのカーソルは、幅 0.1em の span に文字の色を塗って作ります。打っている間は点灯したままにして、打ち終わったら caret-done を付けて点滅させます。
.caret {
display: inline-block;
width: 0.1em;
height: 1em;
margin-left: 0.05em;
vertical-align: -0.1em;
background: currentColor;
}
.caret-done {
animation: blink 1s steps(1) infinite; /* 打ち終わってから点滅させる */
}
@keyframes blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.caret { animation: none; }
}
steps() は、変化を指定した数の段階に分ける関数です。steps(1) なら段階は1つなので、少しずつ薄くなるのではなく、表示と非表示がそのまま切り替わります(点滅の見た目を測ってはいません)。打ち終わった後のカーソルを調べると、caret caret-done が付き、animation-name は blink になっていました。
読み上げと、動きを減らす設定への対応
1文字ずつ増える文字列がスクリーンリーダーに読まれると、聞く人には途中の文字列が届いてしまいます。Typewriter では、全文のほうと表示中のほうの両方に aria-hidden="true" を付けてアクセシビリティツリーから外し、読み上げ用の span(visually-hidden)に全文を1回だけ入れています。全文のほうは、MDN によると visibility: hidden だけでもアクセシビリティツリーから外れますが、意図が読み取りやすいように aria-hidden も付けています。読み上げ用の span を見えなくする CSS は次のとおりです。
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
「こんにちは。」まで表示した 480ms の時点で、Playwright でアクセシビリティツリーを書き出すと、全文だけが1回入っていました。
480ms の表示「こんにちは。」
アクセシビリティツリー:
- paragraph: こんにちは。猫の手です。
OS で動きを減らす設定にしている人には、1文字ずつの表示を見せずに全文を出します。フックでは、prefers-reduced-motion が reduce のとき、count の初めの値を全体の文字数にしています。Playwright で reducedMotion: 'reduce' を指定して開くと、時間を進める前から全文が表示され、カーソルの animation-name は none でした。
reduce 0ms「こんにちは。猫の手です。」 / カーソルの animation-name: none
フックが返す skip を呼ぶと、残りをすぐに表示できます。読むのが速い人のために「すべて表示」のボタンを付けるときに使います。
const { shown, done, skip } = useTypewriter(text);
// ...
<button onClick={skip} disabled={done}>すべて表示</button>
240ms の時点(「こんに」)でボタンを押すと、すぐに全文が表示され、ボタンは押せない状態になりました。その後 2000ms 進めても、表示は変わりませんでした。
文章が変わったら key で最初からやり直す
チャットの返事のように、表示する文章が切り替わることがあります。このフックは count を text とは別に持っているので、そのままでは文章が変わっても count が残ります。
15 文字の文章を 10 文字まで表示したところで、10 文字の別の文章に変え、そのあとの DOM の文字列の移り変わりを MutationObserver で記録しました。比べたのは、何もしない場合、effect の中で setCount(0) を呼んで戻す場合、コンポーネントに key={text} を渡す場合の3つです。
何もしない: 「ABCDEFGHIJ」
effect で 0 に戻す: 「ABCDEFGHIJ」 → 「」 → 「A」 → 「AB」 → 「ABC」 → 「ABCD」
key={text} を渡す: 「」 → 「A」 → 「AB」 → 「ABC」 → 「ABCD」 → 「ABCDE」
何もしないと、count が 10 のままなので、新しい文章は1文字ずつではなく一度に全部表示されました。effect で 0 に戻す場合は、最初からやり直しますが、その前に新しい文章の全部が一度 DOM に入っています。effect は、レンダーの結果が DOM に反映された後に実行されるので、古い count のままのレンダーが先に済んでしまうためです(この一瞬の文字列が画面に描画されるかどうかは確かめていません)。公式ドキュメントも、この書き方では古い値でいったんレンダーしてから、もう一度レンダーすることになると説明しています。
key を渡すと、最初の DOM の変化から空の文字列になり、1文字目からやり直しました。どちらも記録したのは変えてから 400ms 分で、最後の文字列が「ABCD」と「ABCDE」で違いますが、ここで比べているのは、やり直す前に何が DOM に入るかです。
key を渡したときの動きは、React の公式ドキュメントにある、props が変わったときに state をすべてリセットする方法のとおりです。key が変わると React はコンポーネントを別のものとして作り直し、state を初めの値に戻します。冒頭の GIF でも、ボタンを押すたびに key を変えて、新しい文章を最初から表示しています。
<Typewriter key={message.id} text={message.text} />
クリーンアップで clearTimeout を呼ぶ理由
開発時の StrictMode では、コンポーネントを表示したときに、effect のセットアップとクリーンアップが一度余分に実行されます。フックのクリーンアップで clearTimeout を呼ばないと、1回目のセットアップで予約したタイマーが残り、最初の待ち時間で2文字が一度に表示されます。
記事のフックと、クリーンアップの return だけを外した版を、StrictMode で囲む場合と囲まない場合で比べました。
記事のフック、StrictMode: 80ms「こ」 / 160ms「こん」 / 240ms「こんに」 / 320ms「こんにち」
return を外した版: 80ms「こ」 / 160ms「こん」 / 240ms「こんに」 / 320ms「こんにち」
return を外した版、StrictMode: 80ms「こん」 / 160ms「こんに」 / 240ms「こんにち」 / 320ms「こんにちは」
クリーンアップを外した版を StrictMode で囲んだときだけ、80ms の時点で「こん」と2文字が出て、その後も 320ms まで、1文字先に進んでいました。StrictMode の余分な実行は開発時だけです。それでも、クリーンアップがなければ、effect がやり直されるときに前のタイマーを止める処理がどこにもありません。タイマーを予約する effect では、取り消しをクリーンアップに書いておきます。





