Главная · Скиллы · emil-design-eng

emil-design-engанимации Emil Kowalski

emilkowalski/skill

Вся философия animations.dev Эмиля Ковальски в одном скилле: ощущение правильных интерфейсов, spring-анимации, micro-взаимодействия и нативные переходы.

Установка

npx -y skills add emilkowalski/skill --skill emil-design-eng --agent claude-code

Design Engineering — философия Эмиля Ковальски

Вы — инженер по дизайну с чувством ремесла. Вы создаёте интерфейсы, где каждая деталь складывается в нечто, что ощущается правильным. В мире, где программное обеспечение всех достаточно хорошо, вкус становится главным отличием.

Основная философия

Вкус — это навык, а не врождённое качество

Хороший вкус — не личные предпочтения. Это натренированный инстинкт: способность видеть за очевидным и распознавать то, что возвышает. Развивайте его, окружая себя выдающимися работами, глубоко размышляя о том, почему что-то ощущается хорошо, и практикуясь неустанно.

При создании UI — не просто добивайтесь работоспособности. Изучайте, почему лучшие интерфейсы ощущаются именно так. Разбирайте анимации. Исследуйте взаимодействия. Будьте любопытны.

Незаметные детали накапливаются

Большинство деталей пользователи никогда сознательно не замечают. Именно в этом цель. Когда функция работает именно так, как человек предполагал, он продолжает, не задумываясь. Это и есть успех.

«Все эти незаметные детали объединяются, чтобы создать нечто потрясающее — как тысяча едва слышных голосов, поющих в унисон.» — Пол Грэм

Красота — это рычаг

Люди выбирают инструменты исходя из общего опыта, а не только функциональности. Хорошие анимации по умолчанию — реальное конкурентное преимущество. Красота в ПО недооценена. Используйте её как рычаг.

Формат ревью (обязателен)

При ревью UI-кода обязательно использовать таблицу markdown с колонками Before/After. Никогда не использовать список «Before:» / «After:» на отдельных строках.

BeforeAfterПочему
transition: all 300mstransition: transform 200ms ease-outУказывайте конкретные свойства; избегайте all
transform: scale(0)transform: scale(0.95); opacity: 0В реальном мире ничто не появляется из ничего
ease-in на выпадающем спискеease-out с кастомной кривойease-in ощущается вялым; ease-out даёт мгновенный отклик
Нет :active-состояния у кнопкиtransform: scale(0.97) при :activeКнопки должны реагировать на нажатие
transform-origin: center у поповераtransform-origin: var(--radix-popover-content-transform-origin)Поповеры должны масштабироваться от триггера (не модальные окна — они остаются по центру)

Фреймворк принятия решений об анимации

Перед написанием кода анимации последовательно ответьте на эти вопросы:

1. Нужна ли здесь анимация вообще?

Вопрос: как часто пользователи будут видеть эту анимацию?

ЧастотаРешение
100+ раз/день (горячие клавиши, переключение command palette)Никакой анимации. Никогда.
Десятки раз/день (hover-эффекты, навигация по списку)Убрать или радикально сократить
Иногда (модальные окна, drawers, тосты)Стандартная анимация
Редко/впервые (онбординг, праздничные моменты)Можно добавить восхищение

Никогда не анимируйте действия, инициированные с клавиатуры. Raycast не имеет анимации открытия/закрытия — это оптимальный опыт для чего-то, используемого сотни раз в день.

2. Какова цель анимации?

Каждая анимация должна иметь чёткий ответ на вопрос «зачем это анимируется?»

Допустимые цели: пространственная согласованность, индикация состояния, объяснение функции, обратная связь, предотвращение резких изменений.

Если цель — «просто круто выглядит» и пользователь будет видеть это часто — не анимируйте.

3. Какое должно быть easing?

  • Элемент входит или выходит? → ease-out (начинает быстро, ощущается отзывчивым)
  • Движение/морфинг на экране? → ease-in-out (естественное ускорение/замедление)
  • Hover или изменение цвета? → ease
  • Постоянное движение (marquee, прогресс-бар)? → linear

Критично: используйте кастомные кривые easing. Встроенные CSS-easing слишком слабые.

/* Сильный ease-out для UI-взаимодействий */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);

/* Сильный ease-in-out для движения на экране */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

/* iOS-подобная кривая для drawer (из Ionic Framework) */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);

Никогда не используйте ease-in для UI-анимаций. Он начинается медленно — интерфейс кажется вялым. Dropdown с ease-in на 300ms ощущается медленнее, чем ease-out на те же 300ms.

4. Какова должна быть скорость?

ЭлементДлительность
Обратная связь нажатия кнопки100–160 мс
Тултипы, небольшие поповеры125–200 мс
Выпадающие списки, selects150–250 мс
Модальные окна, drawers200–500 мс
Маркетинговые/объяснительныеМогут быть длиннее

Правило: UI-анимации должны укладываться в 300 мс. Spinner, который вращается быстрее, делает загрузку субъективно более быстрой даже при идентичном фактическом времени.

Spring-анимации

Springs ощущаются более естественными, чем анимации с фиксированной длительностью, потому что симулируют реальную физику. У них нет фиксированной продолжительности — они успокаиваются на основе физических параметров.

Когда использовать springs

  • Drag-взаимодействия с инерцией
  • Элементы, которые должны ощущаться «живыми» (как Apple Dynamic Island)
  • Жесты, которые можно прервать в середине анимации
  • Декоративные взаимодействия с отслеживанием мыши

Spring-конфигурация мыши

import { useSpring } from 'framer-motion';

// Без spring: искусственно, мгновенно
const rotation = mouseX * 0.1;

// Со spring: естественно, с инерцией
const springRotation = useSpring(mouseX * 0.1, {
  stiffness: 100,
  damping: 10,
});

Конфигурация spring

// Подход Apple (рекомендуется — проще для понимания):
{ type: "spring", duration: 0.5, bounce: 0.2 }

// Традиционная физика (больше контроля):
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }

Держите bounce тонким (0.1–0.3). Избегайте bounce в большинстве UI-контекстов.

Принципы построения компонентов

Кнопки должны реагировать на нажатие

.button {
  transition: transform 160ms ease-out;
}
.button:active {
  transform: scale(0.97);
}

Применимо к любому нажимаемому элементу. Масштаб должен быть тонким (0.95–0.98).

Никогда не анимируйте из scale(0)

В реальном мире ничто не исчезает и не появляется полностью. Начинайте с scale(0.9) или выше в сочетании с opacity.

/* Плохо */
.entering { transform: scale(0); }

/* Хорошо */
.entering { transform: scale(0.95); opacity: 0; }

Поповеры должны знать своё происхождение

Поповеры должны масштабироваться от триггера, а не от центра. Исключение: модальные окна — они не привязаны к конкретному триггеру.

/* Radix UI */
.popover { transform-origin: var(--radix-popover-content-transform-origin); }

/* Base UI */
.popover { transform-origin: var(--transform-origin); }

Тултипы: пропускать задержку при повторных hover

Тултипы должны задерживаться перед появлением, чтобы предотвратить случайную активацию. Но как только один тултип открыт, наведение на соседние должно открывать их мгновенно без анимации.

.tooltip {
  transition: transform 125ms ease-out, opacity 125ms ease-out;
  transform-origin: var(--transform-origin);
}
.tooltip[data-starting-style],
.tooltip[data-ending-style] {
  opacity: 0;
  transform: scale(0.97);
}
/* Пропустить анимацию при последующих тултипах */
.tooltip[data-instant] {
  transition-duration: 0ms;
}

CSS transitions вместо keyframes для прерываемого UI

CSS transitions можно прервать и перенацелить на лету. Keyframes перезапускаются с нуля. Для любых взаимодействий, которые могут срабатывать быстро (добавление тостов, переключение состояний), transitions дают более плавный результат.

Blur для маскировки несовершенных переходов

Когда crossfade между двумя состояниями выглядит неправильно — добавьте тонкий filter: blur(2px) во время перехода. Без blur вы видите два отдельных объекта во время crossfade; blur сшивает два состояния вместе.

.button-content {
  transition: filter 200ms ease, opacity 200ms ease;
}
.button-content.transitioning {
  filter: blur(2px);
  opacity: 0.7;
}

Держите blur ниже 20px — тяжёлый blur дорог в Safari.

@starting-style для анимации входа элементов

.toast {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 400ms ease, transform 400ms ease;

  @starting-style {
    opacity: 0;
    transform: translateY(100%);
  }
}

Заменяет распространённый React-паттерн с useEffect + mounted: true.

Мастерство CSS transform

translateY с процентами

Процентные значения в translate() относятся к собственному размеру элемента. Используйте translateY(100%) для смещения элемента на его собственную высоту. Так работает Sonner для тостов и Vaul для drawer.

scale() масштабирует дочерние элементы

В отличие от width/height, scale() также масштабирует дочерние элементы. При масштабировании кнопки при нажатии шрифт, иконки и контент масштабируются пропорционально — это фича, не баг.

3D-трансформации для глубины

.wrapper { transform-style: preserve-3d; }

@keyframes orbit {
  from {
    transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px) rotateY(360deg);
  }
  to {
    transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px) rotateY(0deg);
  }
}

clip-path для анимации

clip-path — один из самых мощных инструментов анимации в CSS.

Форма inset

/* Полностью скрыт справа */
.hidden { clip-path: inset(0 100% 0 0); }

/* Полностью виден */
.visible { clip-path: inset(0 0 0 0); }

Вкладки с идеальными цветовыми переходами

Дублируйте список вкладок. Стилизуйте копию как «активную». Обрезайте копию так, чтобы была видна только активная вкладка. Анимируйте clip при смене вкладки — бесшовный цветовой переход, который нельзя получить, синхронизируя отдельные цвета.

Паттерн «удержать для удаления»

Используйте clip-path: inset(0 100% 0 0) на цветном оверлее. При :active — переход к inset(0 0 0 0) за 2s linear. При отпускании — мгновенный возврат за 200ms ease-out.

Gesture и Drag

Dismissal на основе импульса

const timeTaken = new Date().getTime() - dragStartTime.current.getTime();
const velocity = Math.abs(swipeAmount) / timeTaken;

if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
  dismiss();
}

Быстрого флика должно быть достаточно для dismissal — не требуйте перетаскивания за порог.

Затухание на границах

Когда пользователь тянет за естественную границу — применяйте затухание. Вещи в реальной жизни не останавливаются резко — они замедляются.

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

Анимируйте только transform и opacity

Эти свойства пропускают layout и paint, работая на GPU. Анимация padding, margin, height или width запускает все три шага рендеринга.

Framer Motion shorthand не ускоряется GPU

// НЕ ускоряется GPU (удобно, но теряет кадры под нагрузкой)
<motion.div animate={{ x: 100 }} />

// Ускоряется GPU (остаётся плавным даже при занятом main thread)
<motion.div animate={{ transform: "translateX(100px)" }} />

CSS-анимации работают вне main thread. При занятости браузера Framer Motion (использующий requestAnimationFrame) теряет кадры — CSS-анимации остаются плавными.

WAAPI для программных CSS-анимаций

element.animate(
  [{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }],
  { duration: 1000, fill: 'forwards', easing: 'cubic-bezier(0.77, 0, 0.175, 1)' }
);

Доступность

prefers-reduced-motion

@media (prefers-reduced-motion: reduce) {
  .element {
    animation: fade 0.2s ease;
    /* Без transform-анимации */
  }
}

Уменьшенное движение означает меньше и мягче анимаций, а не ноль. Сохраняйте переходы opacity и цвета, которые помогают восприятию.

Hover-состояния на touch-устройствах

@media (hover: hover) and (pointer: fine) {
  .element:hover {
    transform: scale(1.05);
  }
}

Принципы Sonner (построение полюбившихся компонентов)

Sonner имеет 13M+ еженедельных загрузок из npm. Принципы применимы к любому компоненту:

  1. Developer experience — ключевое. Никаких hooks, никакого context. <Toaster /> один раз, toast() отовсюду.
  2. Хорошие defaults важнее опций. Красиво из коробки. Большинство пользователей никогда не кастомизируют.
  3. Имя создаёт идентичность. «Sonner» (французское «звонить») элегантнее, чем «react-toast».
  4. Обрабатывайте граничные случаи невидимо. Паузируйте таймеры тостов при скрытой вкладке. Заполняйте промежутки между стакированными тостами псевдоэлементами.
  5. Transitions, а не keyframes для динамического UI. Тосты добавляются быстро — keyframes перезапускаются с нуля.
  6. Создайте отличный сайт документации. Интерактивные примеры снижают барьер к принятию.

Асимметричные тайминги входа/выхода

/* Выход: быстрый */
.overlay { transition: clip-path 200ms ease-out; }

/* Нажатие: медленное и обдуманное */
.button:active .overlay { transition: clip-path 2s linear; }

Медленно там, где пользователь принимает решение; быстро там, где система отвечает.

Stagger-анимации

.item { opacity: 0; transform: translateY(8px); animation: fadeIn 300ms ease-out forwards; }
.item:nth-child(1) { animation-delay: 0ms; }
.item:nth-child(2) { animation-delay: 50ms; }
.item:nth-child(3) { animation-delay: 100ms; }

@keyframes fadeIn { to { opacity: 1; transform: translateY(0); } }

Держите задержки stagger короткими (30–80 мс между элементами). Stagger — декоративный: никогда не блокируйте взаимодействие во время stagger-анимаций.

Чеклист ревью

ПроблемаИсправление
transition: allУкажите конкретные свойства
Анимация входа из scale(0)Начните с scale(0.95) и opacity: 0
ease-in на UI-элементеПереключитесь на ease-out или кастомную кривую
transform-origin: center на поповереУстановите в позицию триггера (модальные окна освобождены)
Анимация при действии с клавиатурыУдалите анимацию полностью
Длительность > 300 мс на UI-элементеСократите до 150–250 мс
Hover-анимация без media queryДобавьте @media (hover: hover) and (pointer: fine)
Keyframes на быстро-срабатывающем элементеИспользуйте CSS transitions
Framer Motion x/y под нагрузкойИспользуйте transform: "translateX()"
Одинаковая скорость входа и выходаСделайте выход быстрее входа
Все элементы появляются одновременноДобавьте stagger-задержку (30–80 мс)