Patterns

テキストを1文字ずつアニメーションさせる時の注意点と実装例

次の実装例のように、テキストを1文字ずつspan要素で区切ってアニメーションする際の注意点と実装例について纏めたメモ書きです。

2026年11月に、sibling-index()関数で要素の順番を取得する方法を追記しました。

実装例

テキストアニメーションの実装例(英文)
文字ごとにspanで区切ったテキスト
<p class="text">
	<span class="character" aria-hidden="true" translate="no" style="--index: 0;">J</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 1;">a</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 2;">n</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 3;">u</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 4;">a</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 5;">r</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 6;">y</span>
	<span class="alternative">January</span>
</p>
アニメーションのCSSの例
.text {
	font-family: 'Lobster', cursive;
	font-size: clamp(2.5rem, 2.136rem + 1.82vi, 3.5rem);
	letter-spacing: 0.05em;
	text-shadow: 0 2px 5px rgb(0 0 0 / 50%);
}

@media (prefers-reduced-motion: no-preference) {
	.character {
		display: inline-block;
		animation: bounce 0.8s var(--ease-out-quint) calc(var(--index) * 0.15s) both infinite;
	}
}

.alternative {
	position: fixed !important;
	inset: 0 !important;
	display: block !important;
	inline-size: 4px !important;
	block-size: 4px !important;
	contain: strict !important;
	pointer-events: none !important;
	opacity: 0 !important;
}

@keyframes bounce {
	0% {
		translate: 0;
		scale: 1;
	}
	30% {
		translate: 0 -20%;
	}
	50% {
		scale: 1;
	}
	90% {
		translate: 0;
		scale: 1.15 0.8;
	}
	100% {
		translate: 0;
		scale: 1;
	}
}
テキストアニメーションの実装例(和文)
文字ごとにspanで区切ったテキスト
<h1 class="title">
	<span class="character" aria-hidden="true" translate="no" style="--index: 0;">自</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 1;">然</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 2;">豊</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 3;">か</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 4;">で</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 5;">美</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 6;">し</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 7;">い</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 8;">国</span>
	<span aria-hidden="true">&nbsp;</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 9;">日</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 10;">本</span>
	<span class="alternative">自然豊かで美しい国 日本</span>
</h1>
アニメーションのCSSの例
.title {
	font-family: 'Zen Old Mincho', serif;
	font-size: clamp(1.25rem, 0.614rem + 3.18vi, 3rem);
}

@media (prefers-reduced-motion: no-preference) {
	.character {
		display: inline-block;
		animation: fade 4s var(--ease-in-out-sine) calc(var(--index) * 0.15s) both infinite;
	}
}

.character:not(:lang(ja)) {
	display: none;
}

.alternative:lang(ja) {
	position: fixed !important;
	inset: 0 !important;
	display: block !important;
	inline-size: 4px !important;
	block-size: 4px !important;
	contain: strict !important;
	pointer-events: none !important;
	opacity: 0 !important;
}

@keyframes fade {
	0% {
		filter: blur(4px);
		opacity: 0;
	}

	50% {
		filter: blur(0);
		opacity: 1;
	}

	100% {
		filter: blur(4px);
		opacity: 0;
	}
}

spanで区切ったテキストは読み上げから除外する

支援技術はspan要素で区切ったテキストを1文字ずつ分割して読み上げてしまいます。また、「自」は「じ」、「美」は「び」と読み上げられて意味の通らない読み上げがされる可能性もあります。

テキストアニメーションの実装例(和文)をVoiceOverで読み上げた例
VoiceOverはテキストを1文字ずつ読み上げ、11文字以降は「その他の項目」として省略する

この問題の解決策として、span要素で区切ったテキストはaria-hidden="true"を指定して読み上げから除外するようにします。

spanで区切ったテキストにaria-hidden="true"を指定する
<h1>
	<span aria-hidden="true">自</span><span aria-hidden="true">然</span><span aria-hidden="true">豊</span>
	<span aria-hidden="true">か</span><span aria-hidden="true">で</span><span aria-hidden="true">美</span>
	<span aria-hidden="true">し</span><span aria-hidden="true">い</span><span aria-hidden="true">国</span>
	<span aria-hidden="true">&nbsp;</span><span aria-hidden="true">日</span><span aria-hidden="true">本</span>
</h1>

このままだと支援技術を使用しているユーザーはアニメーションされているテキストの内容を認識することができないため、そのテキストに書かれているものと同じ文言のテキストを別途visually hiddenして挿入します。

visually hiddenしたテキストを挿入する
<h1>
	<span aria-hidden="true">自</span><span aria-hidden="true">然</span><span aria-hidden="true">豊</span>
	<span aria-hidden="true">か</span><span aria-hidden="true">で</span><span aria-hidden="true">美</span>
	<span aria-hidden="true">し</span><span aria-hidden="true">い</span><span aria-hidden="true">国</span>
	<span aria-hidden="true">&nbsp;</span><span aria-hidden="true">日</span><span aria-hidden="true">本</span>
	<span class="alternative">自然豊かで美しい国 日本</span>
</h1>
.alternative {
	position: fixed !important;
	inset: 0 !important;
	display: block !important;
	inline-size: 4px !important;
	block-size: 4px !important;
	contain: strict !important;
	pointer-events: none !important;
	opacity: 0 !important;
}

後ほど説明しますが、visually hiddenのユーティリティクラスを用意している場合でもそれを使用せずに.alternativeセレクタに別途スタイルを指定することを推奨します。

visually hiddenのスタイルはAMP(新しいタブで開く)のものを参考にしています。

改善後のテキストアニメーションの実装例(和文)をVoiceOverで読み上げた例
上記の施策により支援技術の読み上げが期待通りのものになりました

spanで区切ったテキストは機械翻訳対象から除外する

Google翻訳などの機械翻訳はdisplay:inline-blockを指定したspan要素を文字ごとに区切って翻訳してしまいます。

富士山と五重塔の写真に、意味の通らない英単語の連なりが見出しとして重なっている
テキストアニメーションの実装例(和文)を英訳した表示例

支離滅裂な翻訳がされるならまだしも、実装例だと「か」は “Mosquito” 、「し」は “death” と英訳されるなど元の文章からは想像ができない不穏な文章に化けてしまっています。

そのため、表示崩れや不穏な文章に化けてしまう事態を防ぐためにもspan要素にtranslate="no"を指定して翻訳対象から除外するようにします。

spanで区切ったテキストにtranslate="no"を指定する
<h1>
	<span aria-hidden="true" translate="no">自</span>
	<span aria-hidden="true" translate="no">然</span>
	<span aria-hidden="true" translate="no">豊</span>
	<span aria-hidden="true" translate="no">か</span>
	<span aria-hidden="true" translate="no">で</span>
	<span aria-hidden="true" translate="no">美</span>
	<span aria-hidden="true" translate="no">し</span>
	<span aria-hidden="true" translate="no">い</span>
	<span aria-hidden="true" translate="no">国</span>
	<span aria-hidden="true">&nbsp;</span>
	<span aria-hidden="true" translate="no">日</span>
	<span aria-hidden="true" translate="no">本</span>
	<span class="alternative">自然豊かで美しい国 日本</span>
</h1>

しかし、意味の通らない翻訳を防いだところで元の日本語のテキストは翻訳されません。故に海外からのユーザーは困る可能性があります。

そこで、Google翻訳のようにlang属性を変更してくれる翻訳ツール限定とはなりますが、spanで区切ったテキストは非表示にしつつ、前項で用意したalternative要素を :lang(ja)でない場合に可視化することで機械翻訳された元のテキストを出力するようにします。

/* spanで区切ったものは`:lang(ja)`でない場合は非表示にする */
span:not(.alternative):not(:lang(ja)) {
	display: none;
}

/* alternativeテキストは`:lang(ja)`の時にvisually hiddenする */
.alternative:lang(ja) {
	position: fixed !important;
	inset: 0 !important;
	display: block !important;
	inline-size: 4px !important;
	block-size: 4px !important;
	contain: strict !important;
	pointer-events: none !important;
	opacity: 0 !important;
}

各span要素の:lang擬似クラスを参照することで、html要素のlang属性を取得して出し分けるのと同様の効果を得られることを教えていただきましたので、:lang(ja)擬似クラスを参照する方法に置き換えています。ご提案いただきありがとうございました。

こうすることで機械翻訳後はテキストアニメーションは行われないものの、元のテキストの翻訳が期待通りに行われて表示されます。

改善後のテキストアニメーションの実装例(和文)をGoogle翻訳した例
上記の施策により翻訳が期待通りのものになりました

なお、span区切りによる読み上げの問題と機械翻訳の問題はdisplay:inline-blockを使用した改行でも起こり得ます。文節ごとにspan要素で改行する実装を行う場合は念の為読み上げと機械翻訳のチェックを行うことを推奨します。

animation-delay用のカスタムプロパティはspan毎にstyle属性で持たせておく

テキストアニメーションを実装する場合は各span要素をanimation-delayすることとなりますが、CSS側で:nth-child(n)にそれを指定するのは手間がかかります。

Sassを使用する場合でもテキストが多くなった場合に備えて無駄に多くループ処理する必要があったりと、あまりスマートな実装とは言い難いです。

🙅‍♂ Not Recommended
span {
	@for $i from 1 through 30 {
		&:nth-child(#{$i + 1}) {
			$delay: $i * 0.15 + s;
			animation-delay: $delay;
		}
	}
}

そこで、animation-delay用のカスタムプロパティはspan毎にstyle属性で持たせておくようにすればCSS側の記述は簡潔になります。

spanで区切ったテキストにカスタムプロパティを指定する
<h1>
	<span style="--index: 0;" aria-hidden="true" translate="no">自</span>
	<span style="--index: 1;" aria-hidden="true" translate="no">然</span>
	<span style="--index: 2;" aria-hidden="true" translate="no">豊</span>
	<span style="--index: 3;" aria-hidden="true" translate="no">か</span>
	<span style="--index: 4;" aria-hidden="true" translate="no">で</span>
	<span style="--index: 5;" aria-hidden="true" translate="no">美</span>
	<span style="--index: 6;" aria-hidden="true" translate="no">し</span>
	<span style="--index: 7;" aria-hidden="true" translate="no">い</span>
	<span style="--index: 8;" aria-hidden="true" translate="no">国</span>
	<span aria-hidden="true">&nbsp;</span>
	<span style="--index: 9;" aria-hidden="true" translate="no">日</span>
	<span style="--index: 10;" aria-hidden="true" translate="no">本</span>
	<span class="alternative">自然豊かで美しい国 日本</span>
</h1>
span {
	display: inline-block;
	animation: fade 4s var(--ease-in-out-sine) calc(var(--index) * 0.15s) both infinite;
}

各span要素にstyle="--index: n"を指定する手間に関しては後述する方法で自動化するため、そこまで問題にはなりません。

なお、animation-delayをstyle属性に直接指定することも考えられますが、CSSファイルで待機時間を調整できるこちらの方法がtransition-delayにも対応できたりと柔軟性があってベターかと思います。

【追記】sibling-index()関数を使う場合

現在はsibling-index()関数を使えば、style属性を使わずにCSSだけで要素の順番を取得できます。sibling-index()関数はChrome 138、Firefox 154、Safari 26.2からサポートされ、2026年8月にBaseline Newly available(新しく利用可能)になりました。

span {
	display: inline-block;
	animation: fade 4s var(--ease-in-out-sine) calc((sibling-index() - 1) * 0.15s) both infinite;
}

ただし、sibling-index()関数は兄弟要素を1から数え、空白用のspan要素も含めて数えるため、上記の例では「日」以降の待機時間が1文字分ずれます。また、サポートしていないブラウザではanimationの宣言ごと無効になるため、古いブラウザに対応する場合はstyle属性で持たせる方法が確実です。

アニメーションは視差効果(アニメーション)を減らす設定がされている場合には除外する

前回の記事「スクロール連動アニメーションの実装例」で説明したので詳細は省きますが、例に漏れず視差効果(アニメーション)を減らす設定がされている場合にはアニメーションを行わないようにします。

@media (prefers-reduced-motion: no-preference) {
	span {
		display: inline-block;
		animation: fade 4s var(--ease-in-out-sine) calc(var(--index) * 0.15s) both infinite;
	}
}

手作業でspanで区切るよりJSで自動化したほうがいい

手作業でテキストをspanで区切り、各属性を指定するのは非常に手間がかかります。実装時もそうですが、実装後に改修を行う場合も少なからずコストが掛かるでしょう。

このような作業に時間を掛けるのはもったいないのでJSで自動化することを推奨します。

JavaScriptの実装はGitHub Gistに纏めています。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。

initializeSplitText

initializeSplitText. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

🤞Use
import initializeSplitText, { type SplitTextOptions } from '@/scripts/initializeSplitText.ts'

document.addEventListener('DOMContentLoaded', () => {
  const target = document.querySelector('h1') as HTMLElement

  if (!target) return

  const option: SplitTextOptions = {
    characterClass: 'home-headline__character' // BEMしたいならoptionでclassを変えられるぞ!
  }

  initializeSplitText(target, option)
})

Astroや各種フレームワークのSSGを利用しているのなら、クライアントサイドではなくビルド時に同じような処理を実行するのが良いでしょう。

上記のJSを実行すると次のようなHTMLが出力されます。

<h1>
	<span class="character" aria-hidden="true" translate="no" style="--index: 0;">自</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 1;">然</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 2;">豊</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 3;">か</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 4;">で</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 5;">美</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 6;">し</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 7;">い</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 8;">国</span>
	<span aria-hidden="true">&nbsp;</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 9;">日</span>
	<span class="character" aria-hidden="true" translate="no" style="--index: 10;">本</span>
	<span class="alternative">自然豊かで美しい国 日本</span>
</h1>

以下、JSの実装についての簡単な解説です。

オプションの設定とinitializeSplitText関数
export type SplitTextOptions = {
  characterClass?: string
  alternativeClass?: string
  indexVariable?: string
}

const defaultOptions: SplitTextOptions = {
  characterClass: 'character',
  alternativeClass: 'alternative',
  indexVariable: 'index',
}

const initializeSplitText = (element: HTMLElement, options: SplitTextOptions = {}): void => {
  if (!element) {
    console.error('initializeSplitText: Element is not found.')
    return
  }

  const mergedOptions: SplitTextOptions = { ...defaultOptions, ...options }

  prepareVisuallyHidden(element, mergedOptions)
  splitText(element, mergedOptions)
}

テキストを区切るspan要素と代替テキスト要素のclass名はオプションで変更できるようにしています。各種プロジェクトの命名規則に対応。必要があるかは分かりませんがインデックスのカスタムプロパティの命名も変更できます。

prepareVisuallyHidden関数
const prepareVisuallyHidden = (element: HTMLElement, options: SplitTextOptions): void => {
  const text = element.textContent
  if (!text) return

  const span = document.createElement('span')
  span.textContent = text
  if (options.alternativeClass !== undefined) {
    span.classList.add(options.alternativeClass)
  }
  element.appendChild(span)
}

要素のテキストコンテンツを複製し、visually hidden用の要素を生成します。class名はデフォルトでalternativeです。スタイルの指定は本記事を参考に各々で行ってください。

splitText関数
const splitText = (element: HTMLElement, options: SplitTextOptions): void => {
  const nodes = [...element.childNodes]
  const fragment = document.createDocumentFragment()
  let characterIndex = 0

  nodes.forEach((node) => {
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.textContent || ''
      const characters = [...text]

      characters.forEach((character) => {
        if (/\s/.test(character)) {
          const span = document.createElement('span')
          span.innerHTML = '&nbsp;'
          span.setAttribute('aria-hidden', 'true')
          fragment.appendChild(span)
        } else {
          const span = document.createElement('span')
          span.textContent = character
          if (options.characterClass !== undefined) {
            span.classList.add(options.characterClass)
          }
          if (options.indexVariable !== undefined) {
            span.style.setProperty(`--${options.indexVariable}`, String(characterIndex))
          }
          span.setAttribute('aria-hidden', 'true')
          span.setAttribute('translate', 'no')
          fragment.appendChild(span)
          characterIndex++
        }
      })
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      fragment.appendChild(node.cloneNode(true))
    }
  })

  element.textContent = ''
  element.appendChild(fragment)
}

テキストに空白スペースが含まれる場合はaria-hidden="true"を指定したspan要素で囲んだ実体参照を出力します。その他のテキストノード(Node.TEXT_NODE)の場合はaria-hidden="true"、translate="no"、空白スペースを除くインデックスを割り当てたカスタムプロパティ、class属性(デフォルトでcharacter)を含んだspan要素で区切って出力します。

また、brなどのHTML要素(Node.ELEMENT_NODE)はそのまま出力するようにします。

CSSに関しては次の指定を参考に各々でアニメーションを実装してください。

@media (prefers-reduced-motion: no-preference) {
	.character {
		display: inline-block;
		animation: your-animation-name 4s var(--ease-in-out-sine) calc(var(--index) * 0.15s) both infinite;
	}
}

.character:not(:lang(ja)) {
	display: none;
}

.alternative:lang(ja) {
	position: fixed !important;
	inset: 0 !important;
	display: block !important;
	inline-size: 4px !important;
	block-size: 4px !important;
	contain: strict !important;
	pointer-events: none !important;
	opacity: 0 !important;
}

各種イージングはカスタムプロパティで定義しておくと便利です。

イージングリスト
:root {
	--ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
	--ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1);
	--ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);

	--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
	--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
	--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);

	--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
	--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
	--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);

	--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
	--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
	--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);

	--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
	--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
	--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);

	--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
	--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
	--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);

	--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
	--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
	--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);

	--ease-in-back: cubic-bezier(0.6, -0.28, 0.735, 0.045);
	--ease-out-back: cubic-bezier(0.175, 0.885, 0.32, 1.275);
	--ease-in-out-back: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}

参考リンク