Основания
Движение
Три длительности и две функции плавности. Достаточно быстро, чтобы движение
читалось причиной, а не анимацией: всё, что вызвал пользователь, обязано
разрешиться внутри --dur-2.
Разметка
<div class="inst-cluster">
<button class="inst-btn" type="button">Наведите и нажмите</button>
<button class="inst-btn inst-btn--primary" type="button" aria-busy="true">Запустить</button>
<span class="inst-badge" data-tone="running"><span class="inst-dot"></span>идёт</span>
</div>
Использование
Движение в ките отвечает на один вопрос: что изменилось. Длительность берётся из шкалы, функция плавности — из двух.
.my-thing {
transition: background var(--dur-1) var(--ease-out);
}
| Что | Обязательно | Почему |
|---|---|---|
Длительность из --dur-* |
да | Четыре ступени. Число на месте расходится с остальным китом на первой же правке |
--ease-out для появления |
да | Вход быстрый в начале и мягкий в конце; --ease-in-out — только для того, что ходит туда-обратно |
| Уменьшенное движение учтено | да | Бесконечные индикаторы замедляются, а не гаснут: остановленный индикатор сообщает «ничего не происходит», когда происходит |
| Анимация у данных | нет | График — это состояние, а не процесс. Движется то, что меняется по действию |
Когда использовать
| Используйте | Возьмите другое |
|---|---|
| Переход поверхности и рамки по действию пользователя | Движение layout — ховер не двигает раскладку. Состояние показывает бейдж |
| Бесконечный индикатор для занятости машины | Определённая полоса, если процент неизвестен — застрявшая на 90% полоса врёт. У неопределённой меры нет aria-valuenow |
--dur-3 для заполнения меры |
Та же длительность для ховера — 220ms на наведении читаются как задержка интерфейса, см. кнопку |
| Переход, который может не доиграть | Анимация как носитель смысла в одиночку — состояние обязано быть и в разметке: aria-busy, data-state. См. шаг |
Появление оверлея через @starting-style |
Ручное открытие на JS — верхний слой и Escape даёт платформа, см. поповер |
Шкала
| Токен | Значение | Где |
|---|---|---|
--dur-1 |
80ms | Наведение, нажатие, фокус, поворот шеврона, подчёркивание ссылки. Это большинство переходов кита |
--dur-2 |
140ms | Свитч, появление и исчезновение поповера. Потолок для всего, что вызвал пользователь |
--dur-3 |
220ms | Заполнение меры — единственное, что меняет геометрию по данным, а не по касанию |
Всё, что не укладывается в --dur-2, требует состояния, а не перехода: если
процесс длиннее, показывать его должен индикатор занятости, а не растянутая
анимация.
Функции плавности
| Токен | Значение | Где |
|---|---|---|
--ease-out |
cubic-bezier(0.22, 0.61, 0.36, 1) |
Все переходы. Резкий старт и мягкая остановка читаются как отклик на действие |
--ease-in-out |
cubic-bezier(0.65, 0, 0.35, 1) |
Бесконечные циклы: пульсация точки, дыхание скелетона, неопределённая мера |
Линейная остаётся там, где вращение обязано быть равномерным. Кольцо
спиннера и кольцо занятой кнопки крутятся linear: замедление в цикле
читается как рывок.
Поведение
| Что | Длительность | Как |
|---|---|---|
| Наведение и нажатие кнопки | --dur-1 |
background, border-color, transform |
| Нажатие | --dur-1 |
scale(0.985) — нажатие надо почувствовать, а не разглядывать |
| Фокус | — | Кольцо появляется мгновенно: задержка на фокусе читается как лаг |
| Шеврон селекта и раскрытого шага | --dur-1 |
rotate |
| Свитч | --dur-2 |
Положение бегунка и заливка дорожки |
| Поповер | --dur-2 |
opacity + translate, плюс display и overlay через allow-discrete |
| Заполнение меры | --dur-3 |
inline-size |
Точка running |
1.6s, бесконечно | Пульсация прозрачности |
| Спиннер и кольцо занятой кнопки | 0.7s, бесконечно | Вращение |
| Скелетон | 1.6s, бесконечно | Дыхание прозрачностью, а не блик: градиентов кит не знает |
| Неопределённая мера | 1.3s, бесконечно | Проход отрезка по дорожке |
| Каретка стриминга | 1s, бесконечно | Мигание шагами |
Уменьшенное движение
prefers-reduced-motion: reduce обрабатывается в отдельном каскадном слое,
объявленном последним. Слой нужен, чтобы правило перебивало компоненты (у
.inst-btn специфичность выше, чем у *) и при этом не перебивало
приложение: без слоя это требовало бы !important, а !important внутри слоя
выигрывает у неважных объявлений приложения — то есть кит нарушал бы
собственное обещание.
Переходы схлопываются
*, *::before, *::after {
transition-duration: 0.01ms;
animation-delay: 0ms;
scroll-behavior: auto;
}
0.01ms, не none. Событие transitionend продолжает приходить, и машины
состояний приложения, которые его слушают, продолжают работать. Выключенный
переход тихо остановил бы их.
Индикаторы не гаснут
Здесь кит расходится с общепринятым «выключить всё».
Бесконечную анимацию нельзя схлопнуть. Сжатая до 0.01ms анимация с одной
итерацией — это не сжатие длительности, это остановка: спиннер замирает в
случайной фазе, точка перестаёт пульсировать, каретка застывает. Кит, вся
работа которого — показывать, что машина занята, переставал бы показывать, что
машина занята. У занятой кнопки это буквально превращает её в пустой
прямоугольник: подпись погашена, а кольцо стоит.
Поэтому индикаторы уходят на частоту, которая читается как «идёт», но не дёргает периферийное зрение. Укачивают амплитуда и частота, а не движение само по себе.
| Индикатор | Обычно | При reduce |
|---|---|---|
.inst-spinner |
0.7s | 2.4s |
.inst-btn[aria-busy="true"] |
0.7s | 2.4s |
.inst-skeleton |
1.6s | 3s |
.inst-meter в неопределённом состоянии |
1.3s | 3s |
.inst-dot в тоне running |
1.6s | 3s |
.inst-dot внутри .inst-task и .inst-step в состоянии running |
1.6s | 3s |
.inst-caret |
1s | Мигание снято, каретка остаётся видимой |
У каретки мигание говорит только о факте, не о скорости. Поэтому гасится мигание, а сама каретка остаётся.
Список замедляемых точек обязан совпадать с тем, что заводит пульсацию в компонентах. Разошёлся — и где-то остался индикатор, замирающий в случайной фазе.
Доступность
| Уменьшенное движение | Переходы схлопываются до 0.01ms; индикаторы активности замедляются, а не останавливаются |
| Занятость | Озвучивается aria-busy, а не анимацией. Движение — второй носитель, не единственный |
| Прогресс | Неопределённая мера не имеет aria-valuenow, и это само по себе сообщение |
| Фокус | Не анимируется: кольцо появляется сразу |
| Мигание | Ничто в ките не мигает чаще 3 Гц |
| Слой | kit.motion объявлен последним, поэтому перебивает компоненты и не перебивает приложение — без единого !important |
API
| Имя | Значение | Что делает |
|---|---|---|
| токен | ||
--dur-1 | 80ms | |
--dur-2 | 140ms | |
--dur-3 | 220ms | |
--ease-out | cubic-bezier(0.22, 0.61, 0.36, 1) | |
--ease-in-out | cubic-bezier(0.65, 0, 0.35, 1) | |
Что кит не анимирует
| Не анимируется | Почему |
|---|---|
| Раскладка на наведении | Ховер не двигает layout, не меняет кегль и не добавляет тень |
| Появление контента при прокрутке | Инструментальный интерфейс читают, а не смотрят |
scroll-behavior: smooth глобально |
Воюет с навигацией с клавиатуры и делает длинные логи вязкими. Включается точечно, на конкретное взаимодействие |
| Цвет текста при смене темы | Переключение темы — не переход, а другое состояние документа |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument