React

Typed.jsをReact・Vueで使う方法|初期化・destroyと二重起動の防ぎ方

Typed.jsをReactのuseEffect・Vue 3のonMountedで動かすコンポーネント例。destroyによる後片付け、props変更、StrictMode、Next.jsの配置、動きを減らす設定への対応を解説します。

この記事の目次
  1. インストールと、DOMを触る場所
  2. React:useEffectで開始し、cleanupでdestroyする
  3. Vue 3:onMountedで開始し、props変更も反映する
  4. 共通CSSと、動きを減らす設定への対応
  5. propsの変更で何が起きるか
  6. 二重起動・途中リセットを切り分ける
  7. Next.jsではClient Componentの内側で使う
  8. 表示できたら、消す・変える・止めるを試す

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.matchMedianew TypedはEffect内に保ちます。Client Componentでも最初の表示にサーバーでの描画が関わるため、ファイル先頭でwindowを読む形には移さないでください。

渡すpropsは文字列配列・数値・真偽値なので、サーバーとクライアントの境界を越えるデータとして扱えます。境界の作り方はNext.jsのuse clientリファレンスを参照してください。

表示できたら、消す・変える・止めるを試す

  1. 同じ画面に2個置き、それぞれ別の文字が動くことを確かめる。
  2. 親のカウンターなどを更新し、文字列が同じなら途中で先頭へ戻らないことを見る。
  3. propsの文字列を変え、古い文字が混ざらず新しい配列で再開することを確認する。
  4. 部品を非表示にしてから再表示し、重複やコンソールエラーが出ないか見る。
  5. 動きを減らす設定を有効にし、途中からでも全文表示へ切り替わることを確かめる。

この5点を通せれば、最初の表示だけでなく、画面を使い続ける間の動きも確認できます。文字を消す速度や待ち時間を調整したくなったら、Typed.jsの基本設定とオプションへ進んでください。

スポンサーリンク