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

広告
次の実装例のように、テキストを1文字ずつ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>.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;  }}<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"> </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>.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文字ずつ分割して読み上げてしまいます。また、「自」は「じ」、「美」は「び」と読み上げられて意味の通らない読み上げがされる可能性もあります。

この問題の解決策として、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"> </span><span aria-hidden="true">日</span><span aria-hidden="true">本</span></h1>このままだと支援技術を使用しているユーザーはアニメーションされているテキストの内容を認識することができないため、そのテキストに書かれているものと同じ文言のテキストを別途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"> </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のものを参考にしています。

spanで区切ったテキストは機械翻訳対象から除外する
Google翻訳などの機械翻訳はdisplay:inline-blockを指定したspan要素を文字ごとに区切って翻訳してしまいます。

支離滅裂な翻訳がされるならまだしも、実装例だと「か」は “Mosquito” 、「し」は “death” と英訳されるなど元の文章からは想像ができない不穏な文章に化けてしまっています。
そのため、表示崩れや不穏な文章に化けてしまう事態を防ぐためにも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"> </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で区切ったものは`:not(: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)擬似クラスを参照する方法に置き換えています。ご提案いただきありがとうございました。
こうすることで機械翻訳後はテキストアニメーションは行われないものの、元のテキストの翻訳が期待通りに行われて表示されます。

なお、span区切りによる読み上げの問題と機械翻訳の問題はdisplay:inline-blockを使用した改行でも起こり得ます。文節ごとにspan要素で改行する実装を行う場合は念の為読み上げと機械翻訳のチェックを行うことを推奨します。
animation-delay用のカスタムプロパティはspan毎にstyle属性で持たせておく
テキストアニメーションを実装する場合は各span要素をanimation-delayすることとなりますが、CSS側で:nth-child(n)にそれを指定するのは手間がかかります。
Sassを使用する場合でもテキストが多くなった場合に備えて無駄に多くループ処理する必要があったりと、あまりスマートな実装とは言い難いです。
span {  @for $i from 1 through 30 {    &:nth-child(#{$i + 1}) {      $delay: $i * 0.15 + s;      animation-delay: $delay;    }  }}そこで、animation-delay用のカスタムプロパティはspan毎にstyle属性で持たせておくようにすればCSS側の記述は簡潔になります。
<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"> </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にも対応できたりと柔軟性があってベターかと思います。
アニメーションは視差効果(アニメーション)を減らす設定がされている場合には除外する
前回の記事「スクロール連動アニメーションの実装例」で説明したので詳細は省きますが、例に漏れず視差効果(アニメーション)を減らす設定がされている場合にはアニメーションを行わないようにします。
@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ファイルに変換してください。
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"> </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の実装についての簡単な解説です。
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名はオプションで変更できるようにしています。各種プロジェクトの命名規則に対応。必要があるかは分かりませんがインデックスのカスタムプロパティの命名も変更できます。
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です。スタイルの指定は本記事を参考に各々で行ってください。
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 = ' '          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);}