Разбиение на слова и строки

Работа с текстовой анимацией в Motion One требует перехода от целостного текстового узла к набору управляемых DOM-элементов. Браузер воспринимает текст как поток символов, тогда как анимационные библиотеки оперируют элементами. Поэтому любое сложное поведение — пословная или построчная анимация — начинается с разбиения строки на структурные единицы.


Зачем требуется разбиение текста

Анимация текста на уровне контейнера ограничена:

  • нельзя независимо управлять словами и строками
  • отсутствует контроль над переносами
  • невозможны эффекты «волны», «раскрытия строки», «поочерёдного появления»

Разбиение текста превращает строку в набор элементов:

  • слова → <span class="word">
  • строки → <span class="line">

Это позволяет применять animate() из Motion One к каждому элементу отдельно, включая stagger, индивидуальные transform, opacity, filter.


Базовая структура DOM

Перед разбиением требуется подготовить контейнер:

<p class="text">
  Motion One позволяет создавать гибкие анимации текста.
</p>

После обработки структура становится вложенной:

<p class="text">
  <span class="line">
    <span class="word">Motion</span>
    <span class="word">One</span>
    <span class="word">позволяет</span>
    <span class="word">создавать</span>
  </span>
</p>

Каждый уровень используется для отдельного типа анимации.


Разбиение на слова

Самый простой этап — разбиение строки по пробелам.

function splitWords(element) {
  const text = element.textContent;
  const words = text.trim().split(/\s+/);

  element.textContent = "";

  const fragment = document.createDocumentFragment();

  words.forEach(word => {
    const span = document.createElement("span");
    span.className = "word";
    span.textContent = word;
    fragment.appendChild(span);
  });

  element.appendChild(fragment);
}

Особенности:

  • используется DocumentFragment для минимизации reflow
  • пробелы не сохраняются автоматически
  • управление пробелами выполняется через CSS
.word {
  display: inline-block;
  margin-right: 0.3em;
}

Анимация слов через Motion One

После разбиения можно применять animate():

import { animate, stagger } from "motion";

animate(
  ".word",
  { opacity: [0, 1], y: [20, 0] },
  { delay: stagger(0.05) }
);

Ключевые моменты:

  • stagger управляет последовательностью появления
  • transform предпочтительнее изменения layout-свойств
  • opacity комбинируется с translate

Разбиение на строки

Разбиение на строки значительно сложнее, поскольку браузер не предоставляет прямого API для получения визуальных строк текста.

Подход через измерение геометрии

Основная идея — определить позицию каждого слова и сгруппировать элементы по offsetTop.

Шаг 1: оборачивание слов

Сначала выполняется разбиение на слова, как ранее.

Шаг 2: определение строк

function wrapLines(element) {
  const words = Array.from(element.querySelectorAll(".word"));

  let currentTop = null;
  let line = [];
  const lines = [];

  words.forEach(word => {
    const top = word.offsetTop;

    if (currentTop === null || top !== currentTop) {
      if (line.length) lines.push(line);
      line = [];
      currentTop = top;
    }

    line.push(word);
  });

  if (line.length) lines.push(line);

  lines.forEach(lineWords => {
    const wrapper = document.createElement("span");
    wrapper.className = "line";

    const parent = lineWords[0].parentNode;

    lineWords[0].before(wrapper);

    lineWords.forEach(w => wrapper.appendChild(w));
  });
}

Особенности вычисления строк

Метод через offsetTop чувствителен к:

  • ширине контейнера
  • шрифтам и их загрузке
  • line-height
  • масштабированию (zoom, transform)

Поэтому разбиение должно выполняться:

  • после полной загрузки шрифтов
  • после рендера DOM
window.addEventListener("load", () => {
  splitWords(element);
  wrapLines(element);
});

Дополнительно:

document.fonts.ready.then(() => {
  splitWords(element);
  wrapLines(element);
});

Анимация строк в Motion One

После формирования .line элементы можно анимировать как единые блоки:

animate(
  ".line",
  { opacity: [0, 1], y: [30, 0] },
  { delay: stagger(0.1) }
);

Построчная анимация часто используется для:

  • заголовков
  • hero-блоков
  • презентационных текстов

Комбинированная анимация: строки + слова

Часто применяется каскадный эффект:

  1. сначала появляются строки
  2. затем внутри строк — слова
animate(
  ".line",
  { opacity: [0, 1] },
  { duration: 0.6, delay: stagger(0.12) }
);

animate(
  ".word",
  { y: [15, 0], opacity: [0, 1] },
  { delay: stagger(0.03, { startDelay: 0.2 }) }
);

Так создаётся многослойная динамика.


Реакция на изменение размера контейнера

Разбиение на строки становится невалидным при изменении ширины. Любое изменение layout требует пересчёта.

Используется ResizeObserver:

const observer = new ResizeObserver(() => {
  reset(element);
  splitWords(element);
  wrapLines(element);
});

observer.observe(element);

Функция сброса:

function reset(element) {
  element.innerHTML = element.textContent;
}

Проблема перерисовок и производительности

Основная нагрузка возникает из-за:

  • чтения layout (offsetTop)
  • многократных reflow при DOM-операциях

Оптимизация:

1. Батчинг измерений

Сначала собираются позиции:

const positions = words.map(w => w.offsetTop);

Затем выполняется DOM-модификация.

2. Использование requestAnimationFrame

requestAnimationFrame(() => {
  wrapLines(element);
});

3. Минимизация DOM операций

  • избегать повторных append/remove
  • использовать фрагменты
  • группировать операции

Улучшенная стратегия разбиения строк

Более устойчивый подход — использование временного клона:

function computeLines(element) {
  const clone = element.cloneNode(true);
  clone.style.position = "absolute";
  clone.style.visibility = "hidden";
  document.body.appendChild(clone);

  const words = Array.from(clone.querySelectorAll(".word"));

  const lines = [];
  let currentTop = null;
  let buffer = [];

  words.forEach(word => {
    const top = word.offsetTop;

    if (currentTop === null || top !== currentTop) {
      if (buffer.length) lines.push(buffer);
      buffer = [];
      currentTop = top;
    }

    buffer.push(word.textContent);
  });

  document.body.removeChild(clone);

  return lines;
}

Этот метод позволяет отделить измерение от реального DOM.


Интеграция с timeline Motion One

Motion One поддерживает последовательные анимации:

import { timeline } from "motion";

timeline([
  [".line", { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.1) }],
  [".word", { scale: [0.9, 1] }, { delay: stagger(0.02) }]
]);

Это позволяет синхронизировать появление строк и слов без ручного управления временем.


Поведение при динамическом контенте

Если текст меняется динамически:

  • разбиение выполняется заново
  • старые spans уничтожаются
  • анимация пересоздаётся
function updateText(element, text) {
  element.textContent = text;
  splitWords(element);
  wrapLines(element);
}

Практические ограничения

Разбиение текста на уровне DOM имеет ограничения:

  • ухудшение доступности (screen readers могут читать разрозненно)
  • увеличение количества узлов
  • сложность SSR-гидрации
  • необходимость пересчётов при любом layout shift

Поэтому такие техники применяются точечно:

  • заголовки
  • лендинг-экраны
  • декоративные блоки

Поведение с CSS трансформациями

Motion One использует transform, что критично при текстовой анимации:

  • не вызывает reflow
  • ускоряется GPU
  • не ломает layout

Рекомендуемые свойства:

  • translate
  • scale
  • opacity

Избегаемые:

  • top/left
  • width/height
  • margin в анимации

Синхронизация с шрифтами и рендерингом

Некорректное разбиение часто связано с поздней загрузкой шрифтов.

Корректный порядок:

  1. загрузка DOM
  2. загрузка шрифтов
  3. вычисление строк
  4. анимация
await document.fonts.ready;
splitWords(element);
wrapLines(element);
animate(".line", { opacity: [0, 1] });