Анимация на Тильде и prefers-reduced-motion: что делает скрипт
Системную настройку «меньше движения» посетитель включает не из любопытства — от движения на экране у него кружится голова. Правильная реакция скрипта на неё одна и она скучная — не запускаться. Ни «анимировать быстрее», ни «убрать только самое резкое». Рядом с этой настройкой живут ещё три вещи, которые ломают страницу тише — невидимое поверх кликабельного, эффект только для мыши и своя плашка ошибки вместо родной.
Доступность на Тильде обычно обсуждают как альт-тексты и контрастность. Это верно, но к сторонним скриптам относится мало: скрипт добавляет на страницу движение, невидимые элементы и свои сообщения — и ломается доступность именно там. Ниже четыре места, где это происходит, с кодом, который делает правильно.
Короткий ответ
Скрипт, добавляющий движение, обязан проверить настройку «меньше движения» и при
ней не запускаться — до того, как он что-то спрятал или создал. Эффект, которому
нужна мышь, не включается там, где мыши нет. Что стало невидимым, обязано
пропускать клики и уходить из чтения скринридером. Сама Тильда настройку не
проверяет: во всех её ресурсах со снятой страницы строки
prefers-reduced-motion нет ни одной.
Кто такой посетитель с «меньше движения» и чего он просил
Это не настройка «не люблю анимацию». У части людей движение на экране вызывает
вестибулярную реакцию — головокружение, тошноту, дезориентацию. Настройка
делается один раз в системе, а не на каждом сайте, и браузер сообщает её
странице медиавыражением prefers-reduced-motion. Пути в системе, если нужно
проверить у себя, перечислены в справочнике MDN — оттуда же эта таблица:
| Система | Где включается |
|---|---|
| Windows 11 | Параметры → Специальные возможности → Визуальные эффекты → Эффекты анимации |
| macOS до 15 | Системные настройки → Универсальный доступ → Дисплей → Уменьшение движения |
| macOS 25 и новее | Системные настройки → Универсальный доступ → Движение → Уменьшение движения |
| iOS | Настройки → Универсальный доступ → Движение |
| Android 9+ | Настройки → Специальные возможности → Удалить анимацию |
Просьба конкретная: убрать несущественное движение. Плавное появление текста, бегущие числа, параллакс, кольцо, которое ездит за курсором, — всё это несущественное движение по определению: страница без него работает полностью.
Скрипт должен не запускаться, а не «анимировать быстрее»
В ядре библиотеки проверка занимает одну функцию:
function reduced() {
return !!(
window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
Важнее, где стоит её вызов. В моде проявления текста — сразу после того, как элементы найдены, и до того, как хоть один из них спрятан:
if (!items.length) return;
// Уважение к системной настройке: движение отключено — ничего не прячем.
if (MK.reduced()) return;
В моде бегущих чисел то же место и та же причина, только формулировка своя — «числа как есть»: посетитель видит итоговое значение, а не ноль, который никогда не начнёт расти.
Из этого следует правило, которое важнее самой проверки: начальное состояние
эффекта обязан ставить скрипт, а не CSS. Соблазн написать в стиле
.mk-reveal p { opacity: 0 } велик — так текст не мигнёт до запуска. Но правило
из <style> применится всегда, а скрипт может не дойти до дела: настройка
включена, ошибка выше в коде, блок T123 не сохранился. И тогда на странице
остаётся невидимый текст, которого нет способа показать. При правильном порядке
худший исход — текст просто виден без анимации.
Медиазапрос в CSS при этом лишним не будет, но как вторая страховка, а не как
единственная защита. Скрипт делает не только анимацию: он создаёт элементы,
подписывается на события, считает координаты каждый кадр. Спрятать результат
через @media — не то же самое, что не начинать.
Курсор-кольцо на тач-устройстве — мусор на экране
Мод с кольцом-курсором проверяет два условия, и оба до создания элемента:
var hoverable = !window.matchMedia || window.matchMedia('(hover: hover)').matches;
if (!hoverable || MK.reduced()) return;
Первое — про мышь. Кольцо, которое догоняет курсор, на телефоне не догоняет
ничего: курсора там нет, элемент повиснет в углу или посреди экрана и останется
там. Проверка hover: hover отвечает не на вопрос «телефон ли это», а на
нужный — «есть ли устройство, которое умеет наводить».
Второе — та же настройка движения. Непрерывное движение за курсором — самый чистый пример того, от чего эта настройка защищает, и здесь мод не ослабляет эффект, а не включается совсем.
В стилях те же два условия повторены медиазапросами:
@media (prefers-reduced-motion: reduce) {
#mk-cursor { display: none; }
}
/* Без мыши кольцо не нужно. Скрипт и так не запускается, это вторая страховка. */
@media (hover: none) {
#mk-cursor { display: none; }
}
Ещё две вещи в этом же моде стоит назвать. Системный курсор по умолчанию остаётся
на месте: спрятать его можно параметром, но это ухудшает доступность, поэтому не
по умолчанию. И кольцо получает aria-hidden="true" в момент создания — оно
декоративное, и в чтении страницы ему делать нечего.
Невидимое не должно перехватывать клики
Это самая тихая из ошибок. Элемент с opacity: 0 невидим, но остаётся в
раскладке и продолжает принимать клики. Посетитель нажимает на то, что видит, а
попадает в то, чего не видит. Если невидимое — ссылка, он уходит не туда, и
пожаловаться на это невозможно внятно: «кнопка не работает» звучит как каприз.
Мышь закрывается одной строкой — pointer-events: none на спрятанном элементе; в
моде зависимой анимации она стоит с объяснением прямо в CSS, вместе с разбором
самого эффекта — зависимая анимация по
наведению.
Дальше начинается то, чего этой строкой не закрыть. pointer-events — про
указатель, и на дерево доступности он не влияет вообще: прозрачная картинка
остаётся в нём наравне с видимой. Значит, программа чтения экрана пройдёт по всем
шести подписям подряд, из которых человек видит одну, и структура страницы для
незрячего разойдётся с тем, что на экране, — причём в ту же сторону, что и клик по
невидимому: содержание есть, а причины для него нет. Поэтому у спрятанного
элемента должен сниматься и ставиться aria-hidden — вторым действием там же, где
меняется прозрачность, а не отдельным проходом.
У кольца из предыдущего раздела pointer-events: none стоит всегда: оно висит
поверх всей страницы, и без этого правила по странице стало бы нельзя щёлкнуть
вообще.
Отдельная проверка — клавиатура. Если эффект висит только на mouseover, для
человека без мыши он не существует. В моде зависимой анимации к наведению
добавлен focusin: пункт списка, до которого дошли табуляцией, включает картинку
так же, как наведённый курсором.
Ошибка поля: почему своя плашка хуже родного контейнера
В сохранённой форме Тильды под каждым полем уже лежит контейнер .t-input-error
внутри .t-input-group. Он не пустая заготовка: у него есть aria-live="polite",
и он связан с полем через aria-describedby="error_<id>" — в проверенной форме
таких контейнеров девять.
Своя плашка рядом с полем даёт два сообщения об одной ошибке — одно связанное с полем и молчащее, второе видимое и не связанное ни с чем. Скринридер прочитает поле и его описание, то есть пустоту, а текст, который человек видит на экране, не прочитает. Из девяти полей проверенной формы так ломается любое: связку пишет Тильда, и переписать её своей разметкой нельзя — только занять уже написанную.
Отсюда правило, применимое к любому моду, который сообщает посетителю об ошибке:
писать текст в найденный .t-input-error внутри .t-input-group того же поля и
ставить группе класс js-error-control-box — тот же, что ставит сама Тильда.
Готовый код этого приёма с разбором каждой строки — в статье про
проверку длины телефона, где он и
работает.
Побочная выгода тут не в доступности, а в виде: контейнер уже оформлен дизайнером, и своё сообщение выглядит как родное, без единой строки CSS.
Что уносит aria-describedby и как это заметить
У этой связки есть враг, и это не сторонний скрипт. Если в редакторе включена
автоматическая маска телефона, tilda-phone-mask-1.1.min.js при запуске
пересобирает поле: вместо одного <input> появляется обёртка с выбором страны и
новое поле, а номер с кодом уходит в скрытое поле результата. Вместе со старым
узлом пропадает и aria-describedby — связь поля с контейнером ошибки, которую
Тильда написала в разметке сама.
Заметить это можно за десять секунд на опубликованной странице: выбрать поле
телефона в инспекторе и посмотреть, есть ли на нём aria-describedby. Если поле
лежит внутри .t-input-phonemask__wrap, атрибута не будет. Починить это, не
отбирая маску у Тильды, можно только одним способом — вернуть атрибут после того,
как маска отработала, и следить, чтобы он не потерялся снова.
Чек-лист: семь проверок к любой модификации
- Системная настройка «меньше движения» проверяется в самом скрипте, и при ней он не запускается, а не работает быстрее.
- Если анимации нет, контент виден. Начальное состояние ставит скрипт, а не
правило в
<style>. - Эффекту, которому нужна мышь, проверяется
hover: hover— до создания элементов. - Всё, что стало невидимым, получает
pointer-events: none. - Всё декоративное и всё скрытое получает
aria-hidden, и атрибут снимается при показе. - Эффект по наведению доступен с клавиатуры —
focusinрядом сmouseover. - Сообщение об ошибке идёт в родной
.t-input-error, а не в свою плашку.
Все моды библиотеки собраны по этим семи пунктам, и это записано в шапке каждого до кода — каталог. Если нужен эффект под свою задачу и с теми же правилами, это отдельная работа — эффект на заказ.