Работа с текстовой анимацией в Motion One требует перехода от целостного текстового узла к набору управляемых DOM-элементов. Браузер воспринимает текст как поток символов, тогда как анимационные библиотеки оперируют элементами. Поэтому любое сложное поведение — пословная или построчная анимация — начинается с разбиения строки на структурные единицы.
Анимация текста на уровне контейнера ограничена:
Разбиение текста превращает строку в набор элементов:
<span class="word"><span class="line">Это позволяет применять animate() из Motion One к
каждому элементу отдельно, включая stagger, индивидуальные
transform, opacity, filter.
Перед разбиением требуется подготовить контейнер:
<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.word {
display: inline-block;
margin-right: 0.3em;
}
После разбиения можно применять 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.
Сначала выполняется разбиение на слова, как ранее.
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 чувствителен к:
Поэтому разбиение должно выполняться:
window.addEventListener("load", () => {
splitWords(element);
wrapLines(element);
});
Дополнительно:
document.fonts.ready.then(() => {
splitWords(element);
wrapLines(element);
});
После формирования .line элементы можно анимировать как
единые блоки:
animate(
".line",
{ opacity: [0, 1], y: [30, 0] },
{ delay: stagger(0.1) }
);
Построчная анимация часто используется для:
Часто применяется каскадный эффект:
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;
}
Основная нагрузка возникает из-за:
offsetTop)Оптимизация:
Сначала собираются позиции:
const positions = words.map(w => w.offsetTop);
Затем выполняется DOM-модификация.
requestAnimationFramerequestAnimationFrame(() => {
wrapLines(element);
});
Более устойчивый подход — использование временного клона:
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.
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) }]
]);
Это позволяет синхронизировать появление строк и слов без ручного управления временем.
Если текст меняется динамически:
function updateText(element, text) {
element.textContent = text;
splitWords(element);
wrapLines(element);
}
Разбиение текста на уровне DOM имеет ограничения:
Поэтому такие техники применяются точечно:
Motion One использует transform, что критично при
текстовой анимации:
Рекомендуемые свойства:
translatescaleopacityИзбегаемые:
top/leftwidth/heightmargin в анимацииНекорректное разбиение часто связано с поздней загрузкой шрифтов.
Корректный порядок:
await document.fonts.ready;
splitWords(element);
wrapLines(element);
animate(".line", { opacity: [0, 1] });