それはしばらく線路に沿って
たくさんのあかりが窓の外をのぞきながら、
もうすっかりまわりと同じいろになってしまうのでした。

Sample Code

index.html

<p class="js-splitText">
  それはしばらく線路に沿って<br>
  たくさんのあかりが窓の外をのぞきながら、<br>
  もうすっかりまわりと同じいろになってしまうのでした。
</p>

style.css

.js-splitText span {
  opacity: 0;
  transition: all 0.1s ease-in-out;
}
.js-splitText.--active span {
  opacity: 1;
}

index.js

document.addEventListener("DOMContentLoaded", () => {
  splitText();

  /* ▽ 発火用 ▽ */
  const targets = document.querySelectorAll(".js-splitText");
  targets.forEach((target) => {
    setTimeout(() => {
      target.classList.add("--active");
    }, 1000);
  });
  /* △ 発火用 △ */
});

const splitText = () => {
  // trueなら1行ずつspan.line-textで囲んでから出力する
  const LINE_SPLIT_FLAG = true;

  const targetElements = document.querySelectorAll(".js-splitText");

  // ターゲット1つずつに対して処理
  targetElements.forEach((element) => {
    const text = element.innerHTML;

    // テキストを改行で分割し、<br>タグも保持する
    const splitTextByBr = text.split(/(<br\b[^>]*>)/i);

    let charCount = 0; // 現在の文字数をカウントする変数
    const processedLines = [];

    splitTextByBr.forEach((part) => {
      if (part.startsWith("<br")) {
        // <br>タグはそのまま追加
        processedLines.push(part);
      } else {
        // テキスト部分を1文字ずつに分割して処理
        const line = part.trim().split("").map((char) => {
          charCount++; // 文字数をカウント

          // 遅延時間を計算 ※最後に浮動小数点の誤差を補正
          const delay = (0.1 * (charCount - 1)).toFixed(1);

          // <span>で囲み、transition-delayを付与
          return `<span style="transition-delay: ${delay}s;">${char}</span>`;
        }).join(""); // 各行の文字を結合

        // lineSplitFlagがtrueの場合に各行を<span class="line-text">で囲む
        const wrappedLine = LINE_SPLIT_FLAG
          ? `<span class="line-text">${line}</span>`
          : line;

        processedLines.push(wrappedLine);
      }
    });

    // 行ごとに元の<br>タグで再結合
    const processedText = processedLines.join("");

    element.innerHTML = processedText;
  });
};