タイプライター効果とは、誰かが声に出して考えている様子をデジタルで再現したものと言えます。テキストが1文字ずつ現れる様子は、まるで本物の手が本物のキーを叩いているかのようです。ポートフォリオサイトのヒーローセクション、ブラウザベースのターミナルエミュレータ、そして、あらかじめ用意されたテキストの塊を読み込んでいるのではなく、回答を「タイピング」していることを証明しようとするAIアシスタントのチャットウィンドウなどでよく見かけます。うまく機能すれば期待感を高めますが、作りが悪いと、1987年に詰まったプリンターのように感じられます。

なぜこのパターンが存続しているのか

コンピュータは情報を瞬時に伝えますが、人間はそうではありません。この2つの速度の差を利用することは非常に有効です。タイプライター効果は、人間のペースをシミュレートすることでその差を埋めます。ランディングページでは、見出しを1単語ずつ表示させることで、訪問者が内容を読み飛ばすのではなく、バリュープロポジションを実際に読ませるように視線を誘導できます。ターミナルエミュレータでは、コマンドがリアルタイムで実行されているという錯覚を生み出します。チャットボットのインターフェースでは、そのリズムによって、回答がデータベースから取得されたものではなく、その場で生成されていることを示唆します。

しかし、この効果が機能するのは、その仕組みがユーザーを尊重している場合に限られます。一定の間隔で「チッ、チッ、チッ」と刻まれる単調なリズムは、ロボットのように感じられます。さらに悪いことに、アクセシビリティ技術を無視した実装は、楽しい視覚的演出を、フラストレーションの溜まる障壁に変えてしまう可能性があります。目標はユーザーの動作を遅らせることではなく、インターフェースに「生命感」を与えるために、適度な摩擦を加えることなのです。

再帰的な setTimeout で構築する

setTimeout から始めましょう。setInterval には手を触れないでください。その違いは、見た目以上に重要です。

setInterval は頑固です。スクリプトやブラウザのメインスレッドで他に何が起きていようと、n ミリ秒ごとに実行されます。もし、通常のタイピングには50ミリ秒の休止が必要で、句読点の後には150ミリ秒の休止が必要だとしても、setInterval では適応できません。結局、余計な条件分岐で包み込んだり、レースコンディションと戦ったり、頻繁にインターバルをクリアしてリセットしたりすることになり、コードは状態管理の悪夢と化します。固定の間隔では、速度を変化させる必要が生じた瞬間に破綻します。

再帰的な setTimeout は、各ステップが次のステップのルールを決定できるようにすることで、この問題を解決します。これは小さな「状態マシン」だと考えてください。現在の文字列、現在の文字インデックス、タイピング中か削除中かを示すブール値フラグ、そして複数の文字列をループさせるための textIndex カウンタといった、いくつかの変数を保持します。関数は1文字を追加し、文のどこにいるかを確認してから、文脈に合わせた遅延時間で自分自身の次の呼び出しをスケジュールします。

例えば、ほとんどの文字は50ミリ秒で入力し、カンマの後は150ミリ秒に遅らせ、文末では削除モードに切り替える前に800ミリ秒停止させるといったことが可能です。これらを setInterval で綺麗に行うことはできません。再帰的な setTimeout を使えば、ロジックは明快です。

if typing:
  append next character
  if at end of string:
    switch to pause mode
    schedule next call after 1000ms
if deleting:
  remove last character
  if string empty:
    increment textIndex
    load next string
    switch to typing mode

この構造により、クリーンアップも容易になります。タイムアウトIDを保存しておけば、コンポーネントがアンマウントされたときやユーザーがページを離れたときに、一度 clearTimeout を呼び出すだけで済みます。バックグラウンドで動き続ける孤立したインターバルに悩まされることはありません。

カーソルは単独で点滅させるべきである

点滅するカーソルは視覚的なディテールであり、データに関する問題ではありません。JavaScriptの状態エンジンには含めないでください。::after 擬似要素に付随させた個別の CSS アニメーションか、テキストコンテナの末尾に配置した専用の <span> を使用しましょう。

@keyframes blink を使い、step-end タイミングで opacityborder-color を切り替えるシンプルな実装であれば、コンポジター上で動作する、ハードウェアに優しい鮮明なパルスが得られます。JavaScriptがカーソルの可視性を細かく管理する必要はありません。もし setTimeout の再帰の中から表示プロパティを切り替えると、文字ごとに不要なスタイルの再計算を強制することになります。美学は CSS に任せ、シーケンスは JavaScript に任せましょう。

textIndex カウンタを使えば、複数の文字列をループさせるのも簡単です。文字列を配列に格納します。アニメーションの削除フェーズが終了してコンテナが空になったら、textIndex を配列の長さで割った余りにインクリメントし、文字ポインタをゼロにリセットして、再びタイピングを開始します。ポートフォリオサイトが、ページをリロードすることなく ["Developer", "Designer", "Writer"] といった役割を切り替えていくのは、この仕組みによるものです。

錯覚を壊してしまう間違い

素人の実装によく見られる間違いが3つあります。

setInterval の使用。 変速の問題についてはすでに説明しましたが、より微妙な問題があります。もしDOMの更新が(例えばブラウザがレイアウトシフトの描画を行っているなどの理由で)遅延した場合、setInterval は実行を続けてしまいます。その結果、書き込みの重複、文字の重複、あるいはブラウザのレンダリング速度よりも速い書き込みが発生する可能性があります。再帰的な setTimeout であれば、現在のステップが完了するまで次のステップの実行を待ちます。

HTMLのエスケープ忘れ。 ソース文字列に不等号(<>)が含まれており、innerHTML を介して1文字ずつコンテンツを注入している場合、タグが途中で分割されてしまいます。ブラウザは <、次に <s、そして <st と認識します。これにより、適切なタグの解析ができなくなり、壊れたDOMノードや予期しないスタイルの連鎖(カスケード)を引き起こす可能性があります。文字をそのまま表示したい場合は、事前にエスケープするか、より良い方法として innerHTML の代わりに textContent を使用してください。タイプライターの出力の中にスタイル付きの <span> をどうしても含めたい場合は、文字のループを開始する前に、文字列を事前処理してタグの開始と終了の位置を正確に把握しておきましょう。

アクセシビリティの無視。 スクリーンリーダーは、1文字ずつ読み上げられることを好みません。スクリプトがDOMに新しい文字を追加するたびに、一部の支援技術はノード全体を再度アナウンスするため、断片的な単語がスタッカートのように連続して読み上げられることになります。これは、音声ナビゲーションに頼っているユーザーにとって悪夢です。解決策は簡単です。最終的な全文を保持するコンテナに aria-label を追加してください。また、aria-hidden="true" を使用してアニメーション中の要素を支援技術から完全に隠し、スクリーンリーダー用に視覚的に隠された静的なコピーを提供することもできます。いずれにせよ、ユーザーにパフォーマンスを見せつけるのではなく、最初から完全な文章を提供するようにしてください。

バージョンをブラッシュアップする方法

コアとなるループが正しく動作したら、追加の機能を重ねていくことができます。ただし、基本が固まるまでは、安易に追加したい衝動を抑えてください。

打鍵音のエフェクト。 1文字ごとにかすかなクリック音を鳴らすと心地よく感じられますが、ウェブページにおける音声は地雷原です。Web Audio API を使用するか、短いバッファを持つ軽量な Audio 要素を使用してください。再生レートを 0.95 から 1.05 の間でわずかに変化させると、同じクリック音が機械的に聞こえなくなります。常にブラウザの自動再生ポリシーを尊重し、ミュート切り替えを用意してください。午前9時に、ミュートされていないポートフォリオページがタイピング音を自動再生している状況ほど、ユーザーを素早く遠ざけるものはありません。

マルチライン(複数行)タイピング。 本物のターミナルウィンドウはテキストを折り返します。テキストが改行をまたぐ場合、レイアウトが予測可能でない限り、単純な border-right によるカーソルは不自然にジャンプしてしまいます。文字列を改行文字で分割し、各行を個別の <span> でレンダリングするか、コンテンツの末尾を追跡する配置済みの擬似要素を使用してください。テキストの折り返しには注意が必要です。インラインのボーダーとして実装されたカーソルは、コンテナの幅が変わるとテキストから離れてしまうことがあります。ターミナル風のスタイルにする場合は、white-space: pre-wrap と等幅フォントの使用を検討してください。固定幅の文字を使用することで、カーソルの計算がはるかに予測しやすくなります。

リアルタイムのMarkdownレンダリング。 ここからが難所です。**bold** と入力した場合、アスタリスクをそのまま表示するか、即座に太字スタイルに変換するか、選択肢があります。後者を選択した場合、途中で textContent から innerHTML に切り替えると、テキストノードの境界が変わってしまいます。HTMLタグによってその下のDOMツリーが動いてしまうため、カーソル位置の管理が非常に厄介になります。より安全なアプローチの一つは、生のMarkdown文字列を通常通りタイピングし、画面上に完全な文字列が表示された後にレンダリング・パスを実行することです。どうしてもライブフォーマットが必要な場合は、隠されたタイピング用バッファと、解析済みの視覚的オーバーレイという2つのレイヤーを維持してください。

逆再生エフェクト。 テキストを削除することは、必ずしも1文字ずつバックスペースで消すことだけを意味しません。「すべて選択して削除」のようなリセットをシミュレートして、次の文字列がタイピングされる前にフィールドを瞬時にクリアすることもできます。これは少し無機質な印象を与えます。あるいは、1文字あたり30ミリ秒の低速なバックスペースによって緊張感を演出することもできます。これらを組み合わせることも可能です。タイポを素早くバックスペースで消し、一旦停止してから、通常の速度で削除を再開するのです。こうしたバリエーションをつけることで、エフェクトに人間味(自然さ)が生まれます。

真の教訓

タイプライターエフェクトは、表面上は些細なUIの装飾に見えますが、実際に構築してみるとその複雑さが明らかになるものの一つです。まずは...