ReactではuseEffect、Vue 3ではonMountedの中でTyped.jsを初期化し、コンポーネントが消えるときにdestroy()を呼びます。出力先はCSSセレクタで探すより、各コンポーネントのDOM参照を渡すと、複数置いた場合も別々に動かせます。
文字が重なったり、別の画面へ移っても処理が残ったりする場合は、初期化の回数と後片付けを対にして確認しましょう。ここではReactとVue 3で、文字列・速度・繰り返しをpropsとして渡せる部品を作ります。
インストールと、DOMを触る場所
既存のReactまたはVue 3プロジェクトにTyped.jsを追加します。以下はTyped.js 3.0.0を対象にした例です。導入する版の利用条件は公式のLicensing欄で確認してください。
npm install typed.js@3.0.0
Typed.jsが書き換える空のspanを一つ用意し、その内側へReactやVueのテキスト補間を置かないのがコツです。同じ文字をフレームワークとTyped.jsの両方が書き換える構成を避けます。
React:useEffectで開始し、cleanupでdestroyする
TypedText.jsxとして保存してください。CSSは後の共通スタイルを同じフォルダへ置きます。先頭の'use client'はNext.jsのApp Router用で、通常のReactアプリでは不要です。
'use client';
import { useEffect, useRef } from 'react';
import Typed from 'typed.js';
import './typed-text.css';
export default function TypedText({ strings, typeSpeed = 40, loop = false }) {
const element = useRef(null);
const stringsKey = JSON.stringify(strings);
useEffect(() => {
const node = element.current;
const words = JSON.parse(stringsKey);
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
let typed;
function start() {
typed?.destroy();
typed = undefined;
node.textContent = '';
if (motion.matches || words.length === 0) {
node.textContent = words.join(' / ');
return;
}
typed = new Typed(node, {
strings: words,
typeSpeed,
loop,
contentType: 'null', // HTMLとして解釈せず、文字として表示する
showCursor: false,
});
}
start();
motion.addEventListener('change', start);
return () => {
motion.removeEventListener('change', start);
typed?.destroy();
node.textContent = '';
};
}, [stringsKey, typeSpeed, loop]);
return (
<span className="typed-text">
<span className="typed-text__readable">{strings.join(' / ')}</span>
<span ref={element} aria-hidden="true" />
</span>
);
}
useRefが保持するのは表示先のspanです。Effectの中で作ったインスタンスは、そのEffectが返す関数で破棄します。依存値が変わる場合も、前の処理を片付けてから新しい文字列で開始します。ReactのuseEffectリファレンスでも、外部の仕組みとの接続と切断を対にしています。
呼び出す側では、表示したい文字列を渡します。
import TypedText from './TypedText.jsx';
export default function App() {
return <p>できること:<TypedText
strings={['ページを作る', '動きを加える']}
typeSpeed={40}
/></p>;
}
JSON.stringify(strings)を依存値にしているのは、このような短い文字列配列を渡すとき、親の再描画だけでアニメーションが先頭へ戻るのを防ぐためです。配列の中身が同じなら同じキーになり、文章を変えたときだけ作り直します。propsは文字列だけの配列にしてください。
Vue 3:onMountedで開始し、props変更も反映する
VueではTypedText.vueとして保存します。DOMができてから初期化し、propsを変更したら現在のインスタンスを破棄して再生成します。
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import Typed from 'typed.js';
import './typed-text.css';
const props = defineProps({
strings: { type: Array, required: true },
typeSpeed: { type: Number, default: 40 },
loop: { type: Boolean, default: false },
});
const element = ref(null);
let typed;
let motion;
function start() {
if (!motion || !element.value) return;
typed?.destroy();
typed = undefined;
element.value.textContent = '';
if (motion.matches || props.strings.length === 0) {
element.value.textContent = props.strings.join(' / ');
return;
}
typed = new Typed(element.value, {
strings: [...props.strings],
typeSpeed: props.typeSpeed,
loop: props.loop,
contentType: 'null',
showCursor: false,
});
}
onMounted(() => {
motion = window.matchMedia('(prefers-reduced-motion: reduce)');
start();
motion.addEventListener('change', start);
});
watch(
[() => JSON.stringify(props.strings), () => props.typeSpeed, () => props.loop],
start,
{ flush: 'post' },
);
onBeforeUnmount(() => {
motion?.removeEventListener('change', start);
typed?.destroy();
});
</script>
<template>
<span class="typed-text">
<span class="typed-text__readable">{{ strings.join(' / ') }}</span>
<span ref="element" aria-hidden="true"></span>
</span>
</template>
watchはDOM更新後に実行する設定です。onBeforeUnmountではイベント監視とTyped.jsを両方片付けます。マウント前にはwindowへ触れないため、サーバー側の描画処理で初期化が走ることも避けられます。ライフサイクルの実行時点はVue公式のLifecycle Hooksで確認できます。
<script setup>
import TypedText from './TypedText.vue';
</script>
<template>
<p>できること:<TypedText
:strings="['ページを作る', '動きを加える']"
:type-speed="40"
/></p>
</template>
共通CSSと、動きを減らす設定への対応
次をtyped-text.cssとして保存します。アニメーション部分は読み上げ対象から外し、支援技術向けには文字列全体を別のspanで渡します。一文字ずつの更新を読み上げ通知にしないため、aria-liveは付けていません。
.typed-text {
display: inline-block;
min-height: 1.5em;
}
.typed-text__readable {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
OSなどで「視差効果を減らす」「アニメーションを減らす」が有効なときは、Typed.jsを作らず、すべての文字列を「 / 」でつないで即表示します。ページを開いた後の設定変更も監視するので、途中から動きを止められます。prefers-reduced-motionは、その利用者設定をWeb側で受け取るためのメディア特性です。
例ではカーソルを非表示にし、繰り返しも既定で無効にしています。無限に動かすUIを採用する場合は、利用者が止める操作も用意しましょう。重要な見出しや説明は通常のテキストで残し、タイピングは補助的な演出として使うと、JavaScriptが動かない場合も内容を伝えられます。
propsの変更で何が起きるか
| props | 渡す値 | この部品の動き |
|---|---|---|
strings |
文字列の配列 | 内容が変わると先頭から再開。空配列なら表示を空にする |
typeSpeed |
0以上の数値。既定40 | 変更時に作り直す。数字を小さくすると速くなる |
loop |
真偽値。既定false | trueなら文字列の組を繰り返す。変更時に再開する |
どちらもcontentType: 'null'で文字列として出力します。例えば<b>Hello</b>を渡すと、太字ではなくタグの文字を含めて表示します。HTML装飾を受け取る部品ではないため、文字列にマークアップを混ぜる必要はありません。
二重起動・途中リセットを切り分ける
| 症状 | 先に見る箇所 | 直し方 |
|---|---|---|
| 開発中に開始処理が2回動く | ReactのStrictModeとEffectのcleanup | 追加の開始→後片付け→開始でも1つだけ動くようにdestroyを返す |
| 入力欄を操作するたびに文字が先頭へ戻る | Effectに毎回新しい配列・オブジェクトを渡していないか | この例の文字列キー、または安定したprops参照を使う |
| 2つ目の部品が動かない/別の文字が変わる | 同じidやグローバルなセレクタ | 部品ごとのDOM参照をTypedへ渡す |
| 画面から消した後も処理が残る | destroyとchangeリスナー解除 | タイマーを持つインスタンスとイベント監視を一緒に片付ける |
StrictModeを外して開始ログが1回になっても、画面切替時の片付け漏れが直ったとは限りません。開発時の再実行を確認に使い、消す→戻すを繰り返しても同じ表示になるか見ます。
Next.jsではClient Componentの内側で使う
App Routerでは、このReact版を'use client'付きファイルへ置き、ページから読み込みます。window.matchMediaとnew TypedはEffect内に保ちます。Client Componentでも最初の表示にサーバーでの描画が関わるため、ファイル先頭でwindowを読む形には移さないでください。
渡すpropsは文字列配列・数値・真偽値なので、サーバーとクライアントの境界を越えるデータとして扱えます。境界の作り方はNext.jsのuse clientリファレンスを参照してください。
表示できたら、消す・変える・止めるを試す
- 同じ画面に2個置き、それぞれ別の文字が動くことを確かめる。
- 親のカウンターなどを更新し、文字列が同じなら途中で先頭へ戻らないことを見る。
- propsの文字列を変え、古い文字が混ざらず新しい配列で再開することを確認する。
- 部品を非表示にしてから再表示し、重複やコンソールエラーが出ないか見る。
- 動きを減らす設定を有効にし、途中からでも全文表示へ切り替わることを確かめる。
この5点を通せれば、最初の表示だけでなく、画面を使い続ける間の動きも確認できます。文字を消す速度や待ち時間を調整したくなったら、Typed.jsの基本設定とオプションへ進んでください。