@labpics/motion
v0.3.0
Published
Headless zero-dependency motion engine: analytic spring solver, keyframes, timeline, FLIP, gestures, WAAPI compositor path, 9 framework bindings.
Maintainers
Readme
@labpics/motion
Headless-движок анимаций дизайн-системы Labpics: чистая математика движения
(пружины, кадры, тайминги) без единой runtime-зависимости. Ядро не знает про
DOM — рендер делает ваш колбэк, время приходит через инжектируемый requestFrame.
Три вещи, которые нужно понять сразу:
- Всё — субпути. Корневой экспорт + 40 субпутей (41 входов
exportsвpackage.json); точныйsideEffects-allowlist сохраняет только авто-регистрацию web components, остальные неиспользуемые субпути вырезаются. - Две фазы движения. Интерактив и фаза слежения (палец ведёт значение) — на
главном потоке (
MotionValue,drive,…/gestures). Автономные переходы и release-фаза — compositor-путь (…/compositor,…/waapi): пружина компилируется в адаптивный WAAPI-план и живёт на compositor-потоке; главный поток не будится до завершения. Подробно — в разделе «Compositor-путь». - Гарантии запечатаны тестами.
NaN/Infinityникогда не попадают в CSS (fuzz-гейты в CI),prefers-reduced-motionменяет ХАРАКТЕР движения, а не выключает его грубо, публичная поверхность запинена api-surface тестами.
Установка
Установите опубликованную версию из npm:
pnpm add @labpics/motionДля разработки из исходников — тарбол (pnpm build && pnpm pack, затем
pnpm add /путь/к/labpics-motion-<версия>.tgz); git-установка не поддерживается:
dist/ собирается и не хранится в репозитории.
Требования: Node ≥ 22. Runtime-зависимостей нет; фреймворк для биндинга — optional
peer, ставится у потребителя (peer объявлены для 8 фреймворков; ./wc не требует
ничего). Целостность артефакта у потребителя проверяют pnpm pack:smoke
(тарбол → чистый проект → ESM/CJS-импорт всех входов без обязательного peer,
файлы каждой export-ветки) и pnpm pack:compat (TypeScript/Vite, SSR,
tree shaking, точный минимальный Preact peer).
Быстрый старт
Пружина к значению (ядро)
import { MotionValue } from '@labpics/motion';
const x = new MotionValue({ initial: 0, spring: { mass: 1, stiffness: 200, damping: 20 } });
x.onChange((v) => { el.style.transform = `translateX(${v}px)`; });
x.setTarget(240); // плавно едем; повторный setTarget подхватит скорость без рывкаУправляемая анимация (scrub)
import { createDriver } from '@labpics/motion/driver';
const anim = createDriver({ from: 0, to: 1, spring: { mass: 1, stiffness: 200, damping: 24 },
onStep: (v) => { el.style.opacity = String(v); } });
anim.pause();
anim.seek(0.5);
await anim; // thenableАвтономный переход на compositor-потоке
import { animate } from '@labpics/motion/nano';
const moves = animate('.card', { translate: '240px', rotate: 8, opacity: 1 }, {
spring: { mass: 1, stiffness: 170, damping: 26 },
stagger: 40,
});
moves[0]?.pause(); // каждый элемент — нативный Animation
await moves.finished;./nano — platform-trusted to-only WAAPI-вход под жёстким гейтом 1 КБ gzip; контролы — сами
Animation. Числа — миллисекунды; translate/scale/rotate — целые нативные CSS
longhand-каналы, цвета/фильтры/единицы интерполирует браузер. CSS x/y не
трактуются как оси translate (nano не читает layout, чтобы угадывать вторую
ось) — transform-шортхенды x/y принадлежат полному ./animate. Нужны нативные
Element.animate, Animation.commitStyles и CSS linear(); скрытого
rAF-fallback, C1-подхвата и защиты от hostile/polyfill-host здесь нет. Физические
параметры должны задавать конечную затухающую пружину: длительность и плотность
linear() выводятся из её полюсов и допуска реконструкции, без wall-clock cap;
кривая выше общего compiler-ceiling отклоняется до синхронной материализации.
Defensive-граница, C1-подхват, fallback и живой solver для сверхдлинных кривых —
контракт полного ./animate.
import { compileSpringLinear } from '@labpics/motion/compositor';
// Чистый компилятор (SSR-safe): пружина → строка адаптивного CSS linear().
// DOM компилятор не трогает; применение строки — клиентский код.
const easing = compileSpringLinear({ mass: 1, stiffness: 170, damping: 26 });
document.querySelector<HTMLElement>('.card')!
.style.transition = `transform 0.9s ${easing}`;Контроллер CompositorSpring (WAAPI-план в Element.animate, ретаргет, хендофф,
байт-паритетный main-thread fallback без WAAPI) — в разделе «Compositor-путь».
Больше примеров (drag, FLIP, presence, scroll-scrub, value-mapping) — в docs/recipes.md.
Как устроен движок
Слои снизу вверх; клятва «ядро не знает про DOM» держится на каждом уровне:
- L1 — чистая физика (zero-dep, headless):
spring— closed-form солвер;easing/value/utils— математика значений. - Драйверы значений (главный поток):
MotionValue— smooth pickup;drive/createDriver— scrub, thenable. Питаются солвером и математикой. frame— единый rAF-шедулер (read → update → render), на нём едут оба драйвера.- Потребители: 9 биндингов (optional peer) поверх шедулера — либо пружина
целиком уезжает на compositor-поток: адаптивный план WAAPI →
Element.animateпрямо из солвера.
Карта субпутей
Импорт — @labpics/motion (ядро) или @labpics/motion/<субпуть>.
Ядро и управление
| Импорт | Что даёт |
|---|---|
| @labpics/motion | spring (аналитический closed-form солвер), tween, drive (декларативный запуск), MotionValue (реактивное значение со smooth-pickup), MotionParamError |
| …/driver | Scrubbable-контроллер: play/pause/reverse/seek/timeScale/progress + thenable |
| …/frame | Единый frame-шедулер: createFrameLoop / синглтон frame — один rAF на кадр, фазы read→update→render против layout-thrash, SSR-safe; asRequestFrame(loop) сажает MotionValue/drive на общий кадр. Биндинги используют его по умолчанию (как shared-ticker у Framer Motion/GSAP); инжекция своего requestFrame переопределяет |
| …/nano | Platform-trusted WAAPI to-only ≤ 1 КБ gzip: spring/tween, целые translate/scale/rotate longhand-каналы, любые нативно-анимируемые CSS-свойства, delay/stagger, reduced-motion и сами Animation как контролы. Без layout-read, независимых x/y, rAF-fallback, C1-подхвата и hostile-host обещаний |
| …/animate | Фасад-one-liner: animate(target, props, options) — цели по каналам (x/y/scale/rotate, opacity, CSS-свойства), режим { spring } или { duration, ease }, delay/stagger, контролы { finished, play, pause, seek, cancel, stop }. Это базовый single-transition DX-срез; ядро от него не растёт |
Математика значений
| Импорт | Что даёт |
|---|---|
| …/easing | Каталог кривых: named-кривые, cubicBezier, steps, кастомные функции |
| …/value | CSS-значения: парсинг/интерполяция единиц (px/%/deg/rem/vh), цветов (hex/rgb/hsl), transform-компонент, var(), относительных значений |
| …/utils | Value-mapping примитивы (headless-ядро Framer Motion / GSAP): mapRange, interpolate (N-стоповый маппер: клампинг, per-segment easing, кастомный mixer), clamp, wrap, snap, mix, pipe. Каррируемые config-first, финитность гарантирована |
| …/spring | Эргономика пружин: fromBounce (duration+bounce ∈ [−1,1], канон SwiftUI ⊇ Motion [0,1]), fromVisualDuration, springPresets (канон react-spring), springAsEasing |
Композиция движения
| Импорт | Что даёт |
|---|---|
| …/keyframes | Ключевые кадры: массивы, offsets, per-keyframe easing, repeat/reverse/yoyo |
| …/timeline | Оркестрация: createTimeline — сегменты, seek/progress/totalDuration, thenable |
| …/stagger | Каскадные задержки: списки и 2D-сетки, from/направления/easing |
| …/decay | Инерция: аналитическое затухание (drag-momentum, инерционный скролл) |
| …/presets | Словарь generic-движений «от смысла» (иконки): 10 фабрик (pulse, blink, wiggle, spin, breathe, pop, bounceY, drift, fadeSlide, drawOn), мультитрековые кейфреймы, runPreset с виртуальным временем, presetToWaapi; текстовые/числовые сахара — splitText/typewriterAt/scrambleAt, formatNumber (Intl) + tickerCells, раннеры runTypewriter/runScramble/runNumber |
| …/svg | SVG: parsePath/pathLength, draw-математика штриха (drawPath), движение вдоль пути (createMotionPath) |
| …/svg-morph | Морфинг путей: interpolatePath(dFrom, dTo) — точный режим при совпадающей структуре, ресэмплинг с выравниванием при разной |
Взаимодействие и layout
| Импорт | Что даёт |
|---|---|
| …/gestures | createPress (tap + клавиатурный путь Enter/Space), createHover, createPan, createDrag (границы + rubber-band + инерция + reduced-motion) |
| …/behaviors | Headless state machines типовых мобильных взаимодействий поверх ./gestures/./decay/пружины ядра: createBottomSheet (snap-точки + выбор по положению+скорости), createDragDismiss (порог по смещению/скорости + направление), createCarousel (единый clock позиции+индекса, RTL/вертикаль), createPullToRefresh (резистентный overscroll + pending). Единый контракт BehaviorState { value, velocity, phase }; cancel/destroy, reduced-motion меняет характер. Подробно — раздел «Behaviors-путь» |
| …/scroll | Headless-прогресс страницы/target-с-офсетами (семантика Motion), чистая in-view машина, скорость, scrub-клей к timeline |
| …/in-view | Нативный IntersectionObserver-адаптер: selector/Element/список, custom root/margin/amount, one-shot либо парный enter/leave cleanup; возвращает idempotent stop |
| …/presence | Enter/exit lifecycle: «доиграй exit-анимацию → потом убирай из DOM», прерывания, swapPresence (wait/sync) |
| …/flip | Layout-анимация FLIP: инверсия first→last, пружинный «доезд», коррекция scale-искажений (correctRadius, counterScale) |
| …/projection | Вложенный FLIP-движок (жанр Framer projection): дерево узлов — transform родителя НЕ искажает детей и border-radius; projectAt (чистая математика), createProjection (headless-драйвер: одна пружина, velocity continuity при перехвате, seek/release под жест), createDomProjection (capture → мутация DOM → play). Подробно — раздел «Projection-путь» |
| …/smart | Smart-animate поверх ./projection (жанр Figma smart-animate / shared-element): диф двух снимков дерева по строке-ключу data-motion-key → matched/entered/exited/skipped; captureSmart/smartTransition (capture → мутация → animate), resolveSmartTier. matched едут FLIP'ом (continuity переживает пересоздание узла), entered — fade-in, exited — ghost-протокол; reduced = смена характера. Подробно — раздел «Smart-путь» |
| …/auto | Zero-config FLIP: autoAnimate(parent) — add/remove/move детей анимируются сами; reduced-motion меняет характер (move→снап), не выключает |
| …/a11y | createMotionConfig — политика reduced-motion (system/always/never), меняет характер анимации, не выключает |
Compositor-путь и токены (подробно — в следующем разделе)
| Импорт | Что даёт |
|---|---|
| …/waapi | Низкоуровневый мост: compileWaapi/animateWaapi (кейфреймы движка → нативный Element.animate), easingToLinear (любой easing → CSS linear()), supportsWaapi |
| …/compositor | Базовый compositor-компилятор: compileSpringLinear, compileSpringPlan, CompositorSpring, ретаргет, хендофф и fallback-матрица |
| …/compositor/stagger | Самодостаточный групповой compositor-фасад: compileStaggerPlan, CompositorStaggerGroup и связанные compileSpringPlan/CompositorSpring из одного entry |
| …/tokens | Motion-токены: duration, easing, spring, staggerGap, distanceScale. См. «Motion-токены» |
Биндинги (9; peer-фреймворк ставит потребитель)
| Импорт | Что даёт |
|---|---|
| …/react | useSpring, useMotionValue, useMotionStyle (effect-binding: пишет в style через ref без render на кадр — аналог vMotion), useReducedMotion (реактивное системное prefers-reduced-motion, hydration-safe) |
| …/preact | useSpring, useMotionValue (зеркало react-биндинга поверх preact/hooks) |
| …/solid | createSpring, createMotionValue (сигналы, авто-уборка через onCleanup) |
| …/vue | useSpring, useMotionValue, директива vMotion |
| …/svelte | springStore |
| …/angular | Angular (v16+): injectSpring, injectMotionValue (Signals + DestroyRef) |
| …/qwik | useSpring — управление сигналом target (резюм-safe), MotionValue = noSerialize, пересоздаётся на клиенте |
| …/lit | MotionController (ReactiveController), LabMotionSpringElement |
| …/wc | Vanilla web-component <lab-spring> без зависимостей — путь для Astro/Stencil/HTML-first стеков |
Примеры
Runnable-рецепты (drag с инерцией и захватом compositor-импульса, FLIP, presence с C¹-прерыванием, скролл-прогресс → таймлайн, value-mapping, bottom sheet) — в docs/recipes.md; живые сниппеты каждого пути остаются ниже по разделам.
Compositor-путь
Фазовая модель: когда какой путь
Путать фазы — класс дефекта:
- Compositor (
…/compositor,…/waapi) — автономные переходы, settle и release-фаза жеста: скомпилировать адаптивную кривую →Element.animate(форма исполнения по движку — см. «Fallback-матрицу»). Пружина переживает блокировки главного потока и не планирует на нём покадровую работу. - Main-поток (
drive/MotionValue/…/gestures) — интерактив и follow-фаза (палец ведёт значение, будущая траектория неизвестна). - Прерывание compositor-анимации — редкое ONE-SHOT событие
(
CompositorSpring.retarget): serialized snapshot по native currentTime + cancel + новая кривая. Непрерывный ретаргет каждый кадр (gesture-follow через cancel+re-emit) — задокументированный АНТИПАТТЕРН: для слежения берите главный поток. will-change— ограниченная дисциплина у потребителя: включать точечно перед переходом и снимать после завершения, не «на всякий случай».
CompositorSpring: ретаргет и хендофф
Публичный API один на всех тирах. В effect-space numeric/affine-канала при
default fill:'both' прерывание точно продолжает position и правый slope
кусочно-линейного сегмента. На самом stop-kink производная неоднозначна — выбран
правый сегмент. Это не обещание rendered-pixel C¹ для clamping, non-affine
format, меняющегося underlying/composite или custom fill вне active interval.
import { CompositorSpring } from '@labpics/motion/compositor';
const panel = new CompositorSpring({
spring: { mass: 1, stiffness: 170, damping: 26 },
property: 'transform', from: 0, to: 240,
target: el, format: (v) => `translateX(${v}px)`,
apply: (val) => { el.style.transform = String(val); }, // только на fallback-пути
});
panel.start();
// ДИСКРЕТНОЕ прерывание: O(log K) snapshot execution-stops без style/layout-read.
panel.retarget(120);
// ХЕНДОФФ compositor→live: траектория перестала быть автономной (палец перехватил
// значение — follow-фаза). Снимок → живая rAF-пружина продолжает без разрыва.
const live = panel.handoffToLive(); // продолжить к текущей цели, ИЛИ
const live2 = panel.handoffToLive(300); // сразу к новой цели с сохранённой скоростьюЧисло raw diagnostic-узлов выводится из бюджета реконструкции (допуск
DEFAULT_TOLERANCE, адаптивная сетка + упрощение): жёстче пружина — короче
кривая. Один exact-key bounded LRU хранит execution artifact { linear(),
serialized samples }: Chromium исполняет строку, WebKit строит из тех же
numeric samples явные кадры, snapshot сэмплирует их бинарным поиском.
Composited stagger (каскад группы)
Задержки каждого элемента — нативный WAAPI-delay поверх ОДНОЙ запечённой
кривой: общей строки linear() в Chromium/Firefox или общего набора узлов в
WebKit. Группа строит сетку/RDP ровно один раз независимо от N; ограниченный cache
переиспользует результат. Покадровая стоимость каскада — ноль: его исполняет
браузер, планирование одноразово.
import {
CompositorSpring,
CompositorStaggerGroup,
compileSpringPlan,
compileStaggerPlan,
} from '@labpics/motion/compositor/stagger';
// Чистый планировщик (SSR-safe): общая кривая + per-element задержки (headless).
const plan = compileStaggerPlan({
spring: { mass: 1, stiffness: 170, damping: 26 },
property: 'opacity', from: 0, to: 1,
count: 5, gap: 40, staggerFrom: 'first', // → delays [0, 40, 80, 120, 160] мс
});
// Контроллер группы: N целей делят кривую, каждый стартует со своей задержкой.
const list = new CompositorStaggerGroup({
spring: { mass: 1, stiffness: 170, damping: 26 },
property: 'transform', from: 24, to: 0,
targets: rows, // N Element'ов; count = rows.length
gap: 40, staggerFrom: 'center',
format: (v) => `translateY(${v}px)`,
apply: (i, v) => { rows[i].style.transform = String(v); }, // только fallback-путь
});
list.start(); // каскад: N Element.animate с delay[i]Одиночный и групповой контроллеры вместе импортируйте из …/compositor/stagger
(смешивание двух compositor-entry дублирует предсобранное ядро); без групп
используйте меньший …/compositor.
Граница per-group vs per-element (честно): каскад (start) — per-GROUP (это и
есть composited-выигрыш); retarget(i, to) / retargetAll(to) — per-ELEMENT без
пере-каскада (дискретное прерывание, не новый парад); handoffToLive(i, to?)
отдаёт ОДИН элемент в живую rAF-пружину, группового хендоффа нет. reducedMotion
схлопывает задержки в 0 — анимируются одновременно (character-switch, не hard-off).
Fallback-матрица
CompositorSpring прозрачно деградирует: публичный API один, точная effect-space
гарантия ограничена условиями выше — меняется движок под капотом. Тир определяется
возможностями один раз в конструкторе; отдельно форма исполняемого плана один раз
на реалм учитывает WebKit через узкий мемоизированный шов navigator.vendor +
AppleWebKit — независимость многостопового linear() от главного потока не
наблюдаема через API возможностей, а синтаксическая поддержка даёт
ложноположительный ответ. Фактический тир — диагностическое поле CompositorSpring.tier.
Выбор тира — в порядке precedence: доступность (reduced) перекрывает любой
доступный движок, дальше решают WAAPI, локальное правило WebKit и CSS linear();
без DOM и инжектированного requestFrame остаётся ssr:
| Тир | Условие | Движок / поведение | Что теряем |
|---|---|---|---|
| compositor | WAAPI + (явные кадры WebKit или CSS linear()) | WebKit: адаптивные явные кадры; Chromium/Firefox: два кадра + CSS linear(). Оба пути не зависят от главного потока | — (полный путь) |
| waapi-no-linear | Не-WebKit: WAAPI есть, CSS linear() нет | Живой rAF (MotionValue) на главном потоке — доступной независимой формы пружинной кривой нет | Анимация чувствительна к блокировкам главного потока |
| raf | Нет Element.animate | Живой rAF (MotionValue) на главном потоке | То же, что выше |
| reduced | prefers-reduced-motion: reduce | Мгновенный снап к цели: значение эмитится один раз, без анимации | Всякое движение (осознанно — политика доступности) |
| ssr | Нет DOM и нет инжектированного requestFrame | Тот же rAF-движок под Node-обвязкой; импорт и конструктор не трогают window/document | На сервере кадры не рисуются |
Честные границы: (1) все не-compositor тиры кроме reduced идут в ОДИН живой
rAF-движок — ярлыки различают ПРИЧИНУ (телеметрия); (2) детекция одноразовая —
WAAPI/linear() за жизнь контроллера и WebKit-policy за жизнь реалма не
переопрашиваются; (3) на waapi-no-linear/raf анимация делит главный поток.
Политика reduced-motion — мгновенный снап к финальному значению, ЕДИНАЯ для всего
пакета (drive/keyframes/presets тоже резолвятся в финал сразу): один
характер, ноль дрифта. Детекция reduce — один раз на входе; смена системного
предпочтения в полёте не подхватывается.
Диагностика: resolveCompositorTier({ target?, matchMedia?, requestFrame? }) —
тир без конструирования контроллера; supportsLinearEasing() — кэшированная
проба linear(); supportsCompositor(target?) — булев предикат.
Browser support
Тир выражен возможностями среды (WAAPI, CSS linear(), reduced-motion), а не
версией браузера; единственное локальное исключение — форма исполняемого плана
для WebKit через мемоизированный шов, описанный выше в «Fallback-матрице».
| Среда | Статус | Основание |
|---|---|---|
| Chromium (Chrome/Edge) | Полный compositor-путь | WAAPI + CSS linear() |
| Firefox | Полный compositor-путь | WAAPI + CSS linear() |
| WebKit (Safari и браузеры iOS) | Полный compositor-путь | WAAPI + адаптивные явные ключевые кадры; многостоповый linear() не используется |
| Не-WebKit без CSS linear() | waapi-no-linear → живой rAF | доступной off-main формы пружинной кривой нет |
| Без Element.animate | raf → живой rAF | нет WAAPI |
| SSR / Node ≥ 22 | ssr → импорт SSR-safe, кадры не рисуются | нет DOM; см. pack:compat |
Два уровня reduced-motion (единая политика — см. «Fallback-матрицу») и их согласование:
- отдельная анимация (
CompositorSpring/drive/keyframes/…) читает предпочтение ОДИН раз при старте — уже запущенная НЕ переигрывается при смене системного предпочтения («read once»); createMotionConfig(./a11y) держит ЖИВУЮ подписку — влияет на анимации, запускаемые ПОСЛЕ смены (потребитель перечитывает конфиг), а не на идущие. Противоречия нет: живая подписка — про будущие запуски, «read once» — про текущий.
Явно НЕ поддержано (документировано, не маскируется fallback'ом):
- ограничения
./projection(осевая модель,fixed/sticky, scroll-контейнеры, shadow root'ы, чужойtransform) — полный список в «Не-цели v1» раздела «Projection-путь»; - покадровый
retargetпри слежении за жестом — антипаттерн (см. «Фазовая модель»): follow-фаза живёт на главном потоке (./gestures+MotionValue).
Conformance-слой. Serialized effect сверяется с аналитическим солвером в
пределах tolerance, а Chromium/Firefox/WebKit — между собой в browser/*.spec.ts.
Локально: pnpm test:browser (только Chromium, opt-in — в дефолтный pnpm test
браузеры не входят). Полная матрица Chromium/Firefox/WebKit — на CI
(.github/workflows/browser.yml), обязательна для PR, затрагивающих platform
adapters (compositor/gestures/waapi/projection/animate/presence/flip/a11y).
Независимость WebKit-пути от главного потока дополнительно проверяет видеостенд
bench/compare/webkit-freeze.mjs (запуск — cd bench/compare && node
webkit-freeze.mjs после корневого pnpm build): во время блокировки на 900 мс
синий контроль WAAPI задаёт окно измерения, контрфакт с многостоповым linear()
замирает, а исполняемый план с явными кадрами обязан продолжать менять экранную
позицию. Consumer-контракт тарбола: pnpm pack:compat (см. «Установку»).
Латентность (справочно)
pnpm bench:latency измеряет p50/p95/p99 одноразовых compositor-операций;
pnpm bench:ceiling — массовый старт и кадр для N=1/100/1000, долю бюджета
120/240 Гц и жёсткий машинонезависимый закон: один native requestFrame на кадр
независимо от числа целей, без idle wakeups (сценарии и правила — в
docs/benchmark.md). Wall-clock числа машинозависимы и
берутся только из свежего вывода команд.
Границы замера. Стенд меряет ТОЛЬКО main-thread cost (Node, против dist).
Compositor-резидентность и input→photon не наблюдаемы из JS — достоверно
только реальным Chrome + tracing (cc.animation в DevTools Performance), вне
CI-скоупа.
Projection-путь
Честный вложенный FLIP (@labpics/motion/projection). Обычный FLIP вешает
translate+scale на один элемент — вложенные потомки и border-radius на время
полёта искажаются. Projection-движок ведёт ДЕРЕВО узлов: локальный transform
ребёнка каждый кадр вычисляется замкнутой формой через визуальный бокс ближайшего
проецирующего предка — каждый узел рендерится ровно в свой интерполированный
бокс, радиусы корректируются под кумулятивный масштаб (correctRadius из
./flip — живой вызов, не дубль).
import { createDomProjection } from '@labpics/motion/projection';
const proj = createDomProjection();
proj.capture([card, avatar, badge]); // avatar/badge — потомки card: дерево выводится само
moveCardToSidebar(); // мутируйте DOM как угодно
proj.play(); // card едет FLIP'ом; avatar/badge и радиусы НЕ искажаютсяКлючевые свойства (все запинены тестами):
- Слои:
geometry(чистая математика, SSR-safe, кандидат mutation-гейта) →driver(headless: инжектируемыеrequestFrame/matchMedia) →dom(тонкий адаптер: page-space замеры, composed-обход открытых shadow root'ов). Клятва «ядро не знает про DOM» сохранена: DOM трогает только адаптер. - Одна нормированная пружина на переход — дерево едет «одним жестом»,
tearing родитель/ребёнок исключён по построению; каждый кадр — замкнутая
форма
solveSpring(params, t, v0)с ЖИВЫМ v0. - Velocity continuity при перехвате: повторный
play()/capture()в полёте берёт текущие боксы аналитически (V(p̂), ноль чтений DOM под transform) и пересеивает скоростьv0' = v̂/(1−p̂)(точный C¹ всех каналов при неизменных целях — теорема, differential-тест; при изменённых — C¹ доминантного канала, потолокV0_CAP). Жест ведёт черезseek(p), отпускание —release(v). - Граница переизмерения: batch clear → measure → start — элемент никогда не меряется под нашим активным transform (класс бага «смешение layout и transform» закрыт по построению; журнал-тест).
clamp: falseпо умолчанию — честный overshoot пружины уходит в кадры (осознанное отличие от легаси-дефолта./flip); размеры флорятся ≥ 0, публичныйprogressвсегда в [0, 1].- Reduced-motion = смена характера: снап в конечный layout одним кадром,
ноль rAF; невалидная пружина бросает
MotionParamErrorдаже под reduce. - Деградации без NaN: вырожденные боксы (display:none, 0×0), NaN/∞ в
ректах, k→0 при overshoot — каждый кадр конечен (fuzz-гейт ≥10 000 деревьев
в CI),
-0схлопнут.
Не-цели v1 (честно): rotate/skew и не-'0 0' transform-origin (модель строго
осевая), position: fixed/sticky, компенсация вложенных scroll-контейнеров
(только window-scroll page-space), пер-узловые пружины, WAAPI/compositor-эмиссия
дерева (пер-кадровая коррекция 1/k(t) нелинейна — v1 main-thread), интеграция
с реестром каналов ./animate. Закрытые shadow root'ы непрозрачны для
composed-подъёма (открытые — прозрачны); чужой inline/CSS-transform на
треканном узле искажает замер (matrix-декомпозиция — не-цель v1). Диф двух
DOM-состояний по identity-ключу, shared-element и ghost-протокол — субпуть
./smart (раздел ниже).
Smart-путь
Figma-подобный smart-animate поверх ./projection (@labpics/motion/smart).
Projection требует собрать набор элементов вручную и знать «что во что
превратилось»; smart закрывает ровно это: ДВА снимка дерева по строковому
identity-ключу (data-motion-key), диф → matched / entered / exited /
skipped, оркестрация поверх ОДНОГО projection-движка.
import { smartTransition } from '@labpics/motion/smart';
// пометьте узлы: <div data-motion-key="card-3">…</div>
const handle = smartTransition(container, () => {
reorderAndSwapLayout(); // мутируйте DOM как угодно (sync или async)
});
await handle.finished;Либо разнесённо: const cap = captureSmart(container); mutate(); cap.animate().
Ключевые свойства (все запинены тестами):
- Диф по строке-ключу: перемещённый ключ →
matched(едет FLIP'ом), новый →entered(fade-in без transform), ушедший из DOM →exited(ghost-протокол), уехавший в чужой контейнер или вырожденный →skipped. Дубликат ключа → раннийMotionParamError. - Continuity переживает ПЕРЕСОЗДАНИЕ узла: id проекции = строка-ключ, а не
ссылка на элемент. Повторный
captureSmart/animateв полёте берёт аналитическийV(p̂)(ноль чтений DOM под нашим transform) и пересеивает скорость — C¹ у драйвера./projection. Ре-рендер (тот же ключ, новый объект) не рвёт жест. - Единый clock: matched-FLIP, enter- и exit-фейды едут одной нормированной пружиной — дерево движется как один жест.
- Ghost-протокол exit: узел реинсертится в root
absoluteна прежних page-координатах (padding-box), фейд 1→0,removeChildДО резолваfinished(терминальное действие раньше уведомлений). Реинкарнация ключа при живом ghost — ghost снимается, узел продолжает от его состояния без прыжка. - Reduced-motion = смена характера: matched снапаются (ноль transform-записей),
а enter/exit-фейды остаются ЖИВЫМИ;
tier=reduced.respectReducedMotion: falseигнорирует reduce.resolveSmartTierрезолвитreduced/projection/ssr. - SSR-инертность: на не-элементе
size0,tierssr,finishedрезолвлен сразу — без чтения DOM на уровне модуля. - Деградации без NaN: злые снапшоты (NaN/∞-ректы, битые радиусы, пересоздания,
скролл, перехваты) — ни одного броска, ни одного нефинитного числа и ни одного
-0в записях (fuzz-гейт ≥10 000 дифов в CI).
Не-цели v1 (честно): нативный View Transitions API (отдельная фаза; здесь
projection-путь + reduced + ssr), авто-детект мутаций (MutationObserver),
live-подписка на смену reduce в полёте; closed shadow roots и вложенные
scroll-контейнеры наследуются от ./projection.
Behaviors-путь
Headless state machines типовых мобильных взаимодействий
(@labpics/motion/behaviors) поверх переиспользуемых примитивов: трекер скорости
из ./gestures, проекция момента из ./decay, пружинный солвер ядра, темп-токены
./tokens. Ничего из этого НЕ дублируется — субпуть только оркестрирует. Поведение
не знает про фреймворк/компонентную библиотеку: DOM-обвязка ниже — тонкий адаптер.
Общий контракт: BehaviorState { value, velocity, phase },
phase ∈ 'idle' | 'follow' | 'release' | 'settle'. Вход — pointerDown/Move/
Up/Cancel с точкой { x, y, t } (t — секунды, напр. e.timeStamp / 1000);
выход — state-геттер + subscribe; программные переходы и идемпотентные
cancel() / destroy().
Четыре поведения (кросс-контрактные законы — выбор цели по положению+скорости, C¹-доводка, единый clock — в «Ключевых свойствах» ниже):
createBottomSheet— snap-точки, rubber-band за крайними snap, программныйsnapTo(index), перехват новым pointer-down.createDragDismiss— порог по смещению ИЛИ скорости, настраиваемое направление, возврат с унаследованной скоростью при недостигнутом пороге, детерминизм приpointerCancel(всегда домой, без закрытия).createCarousel— index выводится из position каждый кадр, inertia с доводкой к странице, RTL и вертикаль,goTo/next/prev.createPullToRefresh— резистентный overscroll, порог активации,pendingБЕЗ второго владельца позиции (удержание — тот же единственный runner), возврат пружиной после async-действия.
Runnable DOM-адаптер bottom sheet (transform из headless-состояния, полная
связка pointer-событий, программный snapTo) — в
docs/recipes.md.
Ключевые свойства (все запинены тестами):
- Один clock (одна state machine): pointer / programmatic control не плодят
параллельных loops — единый generation-токен гасит stale-кадры, активен максимум
один runner. Перехват pointer-down во время доводки →
followбез утечки цикла. - C¹ на стыке follow→release: скорость момента отпускания наследуется доводкой
(
v0n = velocity / rangeв пружинном солвере) — как smooth-pickup уMotionValue. - Выбор цели по положению+скорости: snap/страница выбираются по проекции момента
через
./decay(.rest) — быстрый флик перепрыгивает snap; property-тесты (seeded-LCG) пинят «ближайший к decay-landing» на диапазоне value+velocity. - Reduced-motion = смена характера: пространственная доводка снапает в цель МГНОВЕННО (ни одного кадра), состояние и результат сохранены — не «выключение».
- Финитность и SSR-safe:
value/velocityвсегда конечны (никогда NaN/∞,-0схлопнут — fuzz-гейт злого ввода), импорт не трогает window/document; единственный платформенный шов — инжектируемыйrequestFrame(детерминизм тестов). cancel()/destroy()идемпотентны:destroyделает вход инертным.
Motion-токены
Типобезопасный словарь примитивов движения (as const, tree-shakeable по
семействам). Это ФУНДАМЕНТ, а не вся ДС: семантики ролей («кнопка-ховер») здесь
нет — роль→токен маппит потребитель (labui). Физический словарь (длительности,
изинги, ДС-пружины smooth/expressive) зеркалирует SSOT motion-токенов labui
(--lab-motion-*, labui/docs/motion-tokens.md) — при пересечении имён значения
совпадают байт-в-байт. Дефолты не кричащие (в духе Apple spring-first / Fluent 2
/ Material 3): критично-задемпфированные пружины и мягкие изинги; overshoot —
ровно в двух opt-in токенах (easing.emphasized, spring.expressive/bounce).
Значения запинены тестами как контракт.
import {
duration, easing, spring, staggerGap, distanceScale, springFromDurationBounce,
} from '@labpics/motion/tokens';
duration.base; // 200 (мс): дефолтный UI-переход
easing.decelerate.css; // 'cubic-bezier(0, 0, 0, 1)' — для CSS/WAAPI/compositor
easing.decelerate.fn; // EasingFn — для ./keyframes / ./stagger
spring.default; // { mass: 1, stiffness: 170, damping: 26 } — для ./compositor
spring.expressive; // ДС-пружина (0.5s, bounce 0.3): сдержанный overshoot ~4.6%
staggerGap.normal; // 40 (мс): шаг каскада для compileStaggerPlan({ gap })
// Каноническая пара восприятия (SwiftUI-модель, SSOT ДС): (duration, bounce) →
// физпараметры; выход гарантированно принимается всеми путями движка.
springFromDurationBounce(0.35, 0); // { mass: 1, stiffness: ~322.3, damping: ~35.9 }
// Дистанс-скейл: чем дальше путь, тем дольше движение (единообразная скорость).
distanceScale(200); // 200 (мс) в дефолтной полосе 0→400px ↦ fast(100)→slow(300)Гарантия размера — субпуть-изоляция (sideEffects-allowlist, см. шапку): не
импортируешь ./tokens — платишь ноль, ядро не растёт (проверено size-гейтом).
Сравнение размеров
Актуальные числа не копируются в Markdown: pnpm size воспроизводимо измеряет
все публичные входы и сценарный import-cost, а CI сравнивает результат с
регрессионными потолками. Gzip вычисляется закреплённым детерминированным кодеком;
сравнительный S6 использует тот же оракул, а системный Brotli привязан к
зафиксированному Node executable. Методология, источник сравнительного runtime-отчёта и
границы допустимых выводов описаны в docs/benchmark.md.
Миграция с Motion JS / Anime.js
./animate даёт похожую one-liner форму для перечисленного ниже подмножества
одиночных переходов CSS-стилей DOM- и SVG-элементов. Таблицы — карта переноса
конкретных вызовов, а не утверждение о совпадении возможностей, поведения или
lifecycle. Полный целевой пользовательский охват ведётся в roadmap #106.
Motion JS → ./animate
| Motion JS | @labpics/motion/animate | Заметка |
|---|---|---|
| animate(el, { x: 100 }) | animate(el, { x: 100 }) | совпадает этот x/y-срез; у Motion набор transform-осей шире |
| animate(el, { opacity: [0, 1] }) | animate(el, { opacity: [0, 1] }) | пара [from, to] — тот же смысл |
| animate(el, { x: 100 }, { type: 'spring', stiffness: 200 }) | animate(el, { x: 100 }, { spring: { mass: 1, stiffness: 200, damping: 20 } }) | пружина как SpringParams |
| animate(el, { x: 100 }, { duration: 0.3 }) | animate(el, { x: 100 }, { duration: 300 }) | мс, не секунды |
| animate(el, { x: 100 }, { delay: 0.1 }) | animate(el, { x: 100 }, { delay: 100 }) | мс |
| animate('.item', …, { delay: stagger(0.05) }) | animate('.item', …, { stagger: 50 }) | шаг-мс между целями |
| const a = animate(…); a.pause(); a.play() | то же | после естественного завершения Motion перезапускается, Lab Motion — нет |
| await animate(…) или animate(…).then(…) | await animate(…).finished | control Motion thenable; Lab Motion предоставляет отдельный Promise |
| a.time = 0.5 | a.seek(500) | Motion использует секунды и getter/setter; seek Lab Motion — write-only, мс |
| a.stop() | a.stop() | оба сохраняют текущую позу; в Lab Motion stop — алиас cancel |
| a.cancel() | прямого эквивалента нет | Motion возвращает initial pose; Lab Motion сохраняет текущую |
| animate(el, { '--x': 100 }) | animate(el, { '--x': ['0px', '100px'] }) | CSS-переменная с юнитом |
Anime.js (v4) → ./animate
| Anime.js v4 | @labpics/motion/animate | Заметка |
|---|---|---|
| animate(el, { translateX: 100 }) | animate(el, { x: 100 }) | Anime v4 также допускает shorthand x; Lab Motion использует x/y |
| animate(el, { opacity: [0, 1], duration: 300 }) | animate(el, { opacity: [0, 1] }, { duration: 300 }) | в Anime параметры находятся во втором объекте; в Lab Motion опции — третий аргумент |
| { ease: 'inOutCirc' } | { ease: circInOut } | circInOut импортируется из ./easing |
| { delay: stagger(50) } | { stagger: 50 } | в Anime v4 stagger — именованный импорт |
| animate(targets, parameters) | animate(targets, props, options) | разные сигнатуры, общий только one-liner характер |
Текущий ./animate объединяет одним lifecycle только from/to-переходы
поддерживаемых CSS-стилей и transform-шортхендов: spring/tween, delay/stagger
и контролы finished/play/pause/seek/cancel/stop.
Не объединены: N-keyframes и offsets, per-segment и per-property transitions,
repeat/reverse/mirror/repeatDelay, inertia/decay, sequences/timeline,
value/object targets, HTML/SVG attributes и path-specific SVG-каналы.
SVGElement при этом уже является допустимой целью для поддерживаемых
CSS-стилей. Отдельные низкоуровневые субпути не образуют общий owner,
finished и interruption/cleanup-контракт. Также отсутствуют thenable control,
time/speed/duration getters, reverse, complete и restart.
Публичного API регистрации произвольных кодеков или адаптеров целей пакет пока
не предоставляет.
Инварианты (гарантии потребителю)
- Zero-deps: в
package.jsonнет поляdependencies— фреймворки только как optional peer у биндингов. - CSS-safe: движок никогда не отдаёт
NaN/Infinity— числовые слои под property-fuzz на 10 000 входов, spring-солвер — под отдельным seeded-fuzz по рабочему боксу валидного пространства (mass/stiffness/damping/t, включая нижние края). - Детерминизм: время только через инжектируемый
requestFrame— бит-в-бит воспроизводимые прогоны. - SSR-safe: импорт любого субпутя не трогает
window/document. - A11y:
prefers-reduced-motionпереключает характер (снап/фейд), не отключает движение грубо. - Запинённый контракт: публичная поверхность математических субпутей и
litзафиксирована api-surface-pin тестами (пропавший И лишний экспорт — красный тест).
Разработка и гейты качества
pnpm install --frozen-lockfile
pnpm typecheck
pnpm build # → dist/* (tsup)
pnpm test # docs-drift гейт + vitest
pnpm size # размерный гейт (gz всех субпутей + сценарный import-cost)
pnpm pack:smoke # целостность тарбола у потребителя
pnpm pack:compat # ESM/CJS/TypeScript/Vite/SSR из реального тарбола
pnpm bench # текущий checkout → свежий build → ns/операцию + provenance
pnpm bench:ceiling # engine-only p50/p95/p99 + структурный rAF-гейтПерф-путь аналитический (O(1) на кадр, closed-form солвер). Числа pnpm bench —
справочные (машинозависимы); запечатан детерминированный инвариант работы
(test/perf-hot-path.test.ts: кадры до сходимости = вызовы солвера, машинонезависимо).
Размерный гейт (scripts/size-gate.mjs) — две метрики, обе жёсткие:
шипнутый gz статического ESM-графа каждого субпутя (entry + recursive local
JavaScript imports + CSS @import; выход локального графа за dist запрещён;
список выводится из exports автоматически) и
сценарный import-cost (esbuild bundle+minify против dist — ловит регрессию
tree-shakeability). Пороги — регрессионные потолки, не цели; их SSOT находится
рядом с измерителем в scripts/size-gate.mjs; gzip-оракул и второй
Brotli-гейт — см. «Сравнение размеров».
CI на каждый PR: typecheck → build → test → fuzz-гейт финитности (overflow/солвер/easing/projection) → size → pack-smoke. Еженедельно (или вручную) — mutation-тестирование core-физики (Stryker; break-порог 76).
Отвергнутые пути (контрфакты, НЕ реализованы)
Отвергнуты с доказательствами — документируем, чтобы не переизобретать:
- WASM/SIMD для одиночных DOM-пружин — переход JS↔WASM не убирает DOM- запись и добавляет границу вызова; SIMD раскрывается на больших однородных батчах, а не на одной автономной анимации. Hot-path остаётся в JIT-доступном JS.
- GPU compute (WebGPU) — не может писать в DOM без readback-stall; выигрыш только для canvas/WebGL при 10k+ объектов.
- Движок в Web Worker + SharedArrayBuffer — не снижает input→photon для DOM
(+hop
postMessage); SAB требует COOP/COEP, ломающих сторонние embed'ы. - Анимация CSS custom properties как «compositor-путь» —
@property/var()не ускоряются на compositor и триггерят style-invalidation каждый тик. - Ретаргет мутацией
playbackRate/currentTime— по W3C Web Animations L1 (§4.4.4, §4.4.15) и MDN обе операции живут в timing model и не трогаютKeyframeEffect:playbackRate— скаляр скорости вдоль запечённой кривой,currentTime— seek по ней. Ретаргет пружины требует НОВОЙ точки равновесия и профиля скорости из текущих (pos, vel) — это новыйKeyframeEffect, поэтому cancel + рекомпиляция через кэш + re-emit необходимы, а не упущенная оптимизация.
Числа справочны и машинозависимы (pnpm bench); проверяемый seal — тесты
(interior error ≤ tolerance, CSS/WebKit parity, effect-space continuity вне kink).
Ошибки
import { MotionParamError, spring } from '@labpics/motion';
try {
spring({ mass: -1, stiffness: 100, damping: 10 }, 0);
} catch (error) {
if (error instanceof MotionParamError) {
if (error.code === 'LM088') console.error('Масса должна быть больше нуля');
else console.error(`Ошибка движения: ${error.code}`);
}
}Внутренние сообщения содержат только стабильный LMddd (входные значения не
отражаются): ветвитесь по error.code, причина и исправление — в
полном каталоге кодов. Тип MotionParamErrorCode экспортируется
из корня; совместимый new MotionParamError('текст') сохраняет текст и получает
код LM000.
Лицензия
MIT
