@yorozu/animations
v1.0.151
Published
framework-agnostic compositor motion primitives
Readme
@yorozu/animations
Framework-agnostic compositor motion primitives for the browser.
Install
pnpm add @yorozu/animationsImport from the package root:
import {
createSharedElement,
playSharedElement,
createViewSlide,
createSlidingIndicator,
createListReorder,
prefersReducedMotion,
canAnimate,
resolveViewSlideMode,
} from "@yorozu/animations"What it is
Small, host-driven helpers built on the Web Animations API (WAAPI). Motion stays on compositor-friendly properties — primarily transform and opacity — so frames stay cheap.
The package is framework-agnostic: factories return plain controllers; you own the DOM, measurement timing, and when to call into the API. No framework effects, no layout thrashing inside the helpers.
Shared element
Prefer createSharedElement for gallery open/close — it owns cancel of the previous flight. playSharedElement is a module singleton and is not the gallery path.
import { createSharedElement, type Rect } from "@yorozu/animations"
const se = createSharedElement()
const from: Rect = thumb.getBoundingClientRect()
const to: Rect = stage.getBoundingClientRect()
const playback = se.play({
host: document.body,
from,
to,
imageUrl: thumb.src,
hideTarget: stage,
})
// playback?.done is Promise<boolean> — true if finished, false if cancelled
// se.cancel() aborts an in-flight cloneplaySharedElement(opts) remains exported for one-shot use without a retained controller (still returns Playback | null).
One dest-sized clone at a time (cloneCount() is 1 in flight, else 0). Pass image (CanvasImageSource) to paint the clone from a bitmap and skip imageUrl decode; without image, the clone is <img src=imageUrl>.
playOpen / playClose accept seeds and viewport insets when the host does not already have both rects. Math helpers (computeFlight, computeOpenFlight, …) are exported for custom layouts.
Cancel groups
One createCancelGroup() per shell. take(slot, playback) owns the slot: a new occupant cancels the previous one. Use named slots ("inbox-layer", "dock", …) instead of element.getAnimations() as ownership.
import { createCancelGroup, createFade } from "@yorozu/animations"
const group = createCancelGroup()
const fade = createFade(overlayEl)
group.take("overlay-fade", fade.setVisible(true))
// later: group.cancel("overlay-fade") or group.cancelAll()
// fade.destroy() cancels the in-flight fade without throwing when idleLayout size writer
Scheduled readPx / writePx under the heavy lock; not a height WAAPI helper. Prefer compositor substitutes (scaleY / clip) for chrome; do not height-tween a scrolling column.
import { createHeavyAnimationLock, createLayoutSizeTween } from "@yorozu/animations"
const lock = createHeavyAnimationLock()
const size = createLayoutSizeTween({
readPx: () => readPanelSize(),
writePx: (px) => writePanelSize(px),
applyRest: () => clearPanelSize(),
lock,
})
size.play(240, 200)snap() applies rest immediately and cancels an in-flight play — call it to interrupt, not on the next line after play.
Layout grow
FLIP-invert one chrome node’s block size (composer, shelf). Auto ResizeObserver unless observe: false. play(toPx) inverts from the last snapshot (or the initial size) then tweens. Inner clock is createLayoutSizeTween (easeOutCubic, LAYOUT_GROW_MS). med / low: pass isEnabled: () => canAnimateFull(level) so the observer snaps.
import { canAnimateFull, createLayoutGrow, createHeavyAnimationLock } from "@yorozu/animations"
const lock = createHeavyAnimationLock()
const grow = createLayoutGrow({
el: composerEl,
lock,
isEnabled: () => canAnimateFull(level),
onDelta: (deltaPx) => {
if (pinned) scroller.scrollTop += deltaPx
},
})
// destroy() on teardownDo not attach this to a scrolling column of rows — use list shift.
List shift
Variable-height FLIP for transcript rows. Geometry is getBoundingClientRect relative to root. Idle root scroll rebases stored tops without playing; snapshot() holds them until play() so stick-to-bottom still inverts. Insert/delete: snapshot() before mutate, register / destroy the row, then play(). Edit/reaction size changes can auto-play via row ResizeObserver. Duration 0 / isEnabled false: rebase only.
import { canAnimateFull, createListShift } from "@yorozu/animations"
const shift = createListShift({
root: scrollerEl,
isEnabled: () => canAnimateFull(level),
lock,
})
const handle = shift.register(rowEl, rowId)
// insert:
shift.snapshot()
scrollerEl.append(newRow)
shift.register(newRow, newId)
shift.play()Do not use createListReorder for variable-height chat rows.
Send flight
Own send composes playSendFlight with createListShift; incoming is list shift only. Dest opacity: 0 until playback.done.
Intensity
Three playback levels. The OS prefers-reduced-motion query is seed only — after the host stores a pick, that value owns playback.
| Level | Meaning |
| ------ | ---------------------------- |
| low | no motion (instant switches) |
| med | softer transitions |
| high | full transitions |
import { defaultAnimationLevel, canAnimate, resolveViewSlideMode, prefersReducedMotion } from "@yorozu/animations"
let level = stored ?? defaultAnimationLevel(prefersReducedMotion())
// first-run seed: med when the OS asks for reduce, otherwise high
resolveViewSlideMode(level, "stack") // none | crossfade | push
resolveViewSlideMode(level, "layer") // none | crossfade | cover
canAnimate(level) // false only for low
canAnimateFull(level) // true only for high — layout grow, list shift, send morph, presence poppickAnimationLevelFromRatio / stepAnimationLevel / cycleAnimationLevel drive a three-stop slider. prefersReducedMotion() stays an honest media-query probe.
View slide
Panel stack transitions driven by an active key. Built-in modes: push (full-width), crossfade (±1.5rem + opacity), cover (translate + opacity leave + 200% enter; pass coverMotion: "scale" only when the host snapshots the leaving pane), peek (~20% back + dim), lift (vertical ±100%), zoom (scale 1.1 / 0.95), reveal (clip-path inset wipe), none.
import { createViewSlide, slideDirectionByIndex, resolveViewSlideMode } from "@yorozu/animations"
const items = [{ id: "a" }, { id: "b" }, { id: "c" }]
const slide = createViewSlide({
getMode: () => resolveViewSlideMode(level, "stack"),
getDirection: (from, to) => slideDirectionByIndex(from, to, items),
mountPolicy: "active-plus-leaving", // or "keep-visited"
onChange: () => {
// re-render from slide.mountedKeys / slide.animating / slide.leavingKey
},
})
// Render only keys in slide.mountedKeys, then:
const handle = slide.attach(panelEl, key)
slide.setActive(nextKey)
// handle.update(newKey) / handle.destroy() when the node unmountssetActive starts the pair animation when both panels are attached. mountedKeys, role, and isVisible drive host rendering. Optional onChange fires on animation start, finish (including mode "none" instant settle), and whenever mountedKeys / animating / leavingKey flip — including cancel / destroy when state actually changed. Drive host state from the callback, not a poller. Call cancel / destroy on teardown.
Dock
Edge panel open/close with an optional backdrop fade.
import { createDock } from "@yorozu/animations"
const dock = createDock({
getMode: () => "slide",
onChange: () => {
// re-render from dock.mounted / dock.leaving / dock.animating
},
})
dock.attach(panelEl)
dock.attachBackdrop(backdropEl)
dock.setOpen(true)Optional onChange fires on animation start, finish (including mode "none" instant settle), and whenever mounted / animating / leaving flip — including playback cancel / destroy when state actually changed. Drive host state from the callback, not a poller.
Sliding indicator
Track an underline (or similar) under the active control. Size snaps; only position is tweened.
import { createSlidingIndicator } from "@yorozu/animations"
const indicator = createSlidingIndicator({
getTrack: () => trackEl,
getIndicator: () => indicatorEl,
getActive: () => activeTabEl,
enabled: () => !prefersReducedMotion(),
})
// After the active item changes (and layout has updated):
indicator.measure()
// ResizeObserver on the track also remeasures
// indicator.destroy() on teardownList reorder
Index-based FLIP for fixed-height lists. Geometry is delta = -orderDiff * itemHeight — no getBoundingClientRect.
import { createListReorder } from "@yorozu/animations"
const reorder = createListReorder<Item>({
getItemHeight: () => 48,
getKey: (item) => item.id,
isEnabled: () => true,
isReduced: () => prefersReducedMotion(),
isSuppressed: () => isDragging,
// Optional: FLIP a key from a host-supplied overlay translateY (skips classify).
getFromTranslateY: (key) => overlayByKey.get(key),
})
// Per mounted row:
const handle = reorder.register(rowEl, item.id)
// After the order changes:
reorder.sync(items)
// One-shot map wins for that sync over getFromTranslateY for keys it has:
reorder.sync(items, { fromTranslateY: new Map([[droppedKey, px]]) })
// handle.destroy() when the row leaves the windowHost computes px; the package does not measure layout. px is CSS translateY — positive is down from the post-layout slot. Override plays only when |px| > LIST_REORDER_EPSILON_PX. Use isSuppressed during an active drag so baseline advances without FLIP.
Classifier helpers buildOrderDiff and classifyReorderAnim are public if the host needs the same majority/minority rules outside the controller.
Motion catalog
| Name | API | Notes |
| ----------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Intensity | AnimationLevel | low / med / high; OS seed-only |
| Stack slide | createViewSlide push | Full-width 100% translate |
| Soft slide | createViewSlide crossfade | ±1.5rem + opacity |
| Cover slide | createViewSlide cover | Default translate + opacity (no scale); coverMotion: "scale" when host snapshots leave |
| Peek slide | createViewSlide peek | Incoming full-width; outgoing ~20% back + dim |
| Lift | createViewSlide lift | Vertical translateY ±100% |
| Zoom | createViewSlide zoom | Scale 1.1 / 0.95 + short opacity |
| Reveal | createViewSlide reveal | clip-path inset wipe |
| Shared element | createSharedElement | Thumb ↔ stage flight |
| Sliding indicator | createSlidingIndicator | Size snap, position tween |
| List reorder | createListReorder | Index FLIP, fixed height |
| Layout grow | createLayoutGrow | Chrome block-size FLIP invert; observer; onDelta |
| List shift | createListShift | Variable-height translateY FLIP |
| Dock | createDock | Edge open/close + backdrop fade |
| Fade | createFade | Opacity-only show/hide |
| Popover | createPopover | Scale + fade from an origin |
| Digit flip | buildDigitSlots / playDigitFlip | Right-aligned slots + rotateX; budget resets per shared frame; skip when heavy lock held |
| Presence pop | shouldPresencePop / playPresencePop | Scale-in on 0 → N; direction?: "in" \| "out" (in default) |
| Send flight | playSendFlight | Own send composes with createListShift; incoming is list shift only; dest opacity: 0 until playback.done |
| Swipe reveal | createSwipeReveal | Pointer rubber + release tween |
| Scroll tween | playScrollTween | Animate scrollLeft / scrollTop |
| Overscroll bounce | createOverscrollBounce | Edge rubber on a native overflow scroller (AppKit-stiff wheel, iOS curve on touch). |
| Ripple | playRipple | Touch ink at pointer |
| Pinch zoom | createPinchZoom | Clamp / origin zoom; pan when scale > 1 |
| Waveform | decodeWaveform / fitWaveform | Packed 5-bit samples, resampled bars |
| Spoiler | createSpoiler | Dot-field overlay; reveal fades it out |
Reduced motion
Respect user preference and host suppressors:
prefersReducedMotion()— reads(prefers-reduced-motion: reduce). Use it only to seeddefaultAnimationLevel. Storedlow/med/highowns playback after that.canAnimate(level)— false only forlow.canAnimateFull(level)— true only forhigh. Layout grow, list shift, send morph, and presence pop snap atmedandlow.- View slide
mode: "none"— skips animation; active key still updates and mount policy still applies. - List reorder
isReduced— clears the order baseline (no FLIP). - List reorder
isSuppressed— updates the baseline without animating (useful during drag). - Sliding indicator
enabled— when false, snaps transform without WAAPI.
Wire getMode, isReduced, and enabled from the stored intensity so every primitive stays consistent.
Performance notes
- Compositor-only: prefer
transform/opacity; avoid animating layout properties. - Index FLIP: list reorder uses order indices × fixed height, not layout measurement, so it stays safe for virtualized windows.
- Cancel before retarget: controllers cancel in-flight WAAPI before starting a new run; call
cancel/destroywhen unmounting hosts to avoid leaked animations and clones. dualRaf: exported for hosts that need two animation frames before measuring or starting motion after a paint.- Shared frame pump:
onAnimationFrameis one rAF for JS ticks;dualRafstill waits two frames; hosts no-op non-motion subscribers while a blocking lock is held. - Measure / mutate:
queueMeasurethenqueueMutateon one frame;dualRafstill means wait two frames;queueMeasureAfterMutateis the rare second read. - First layout is a baseline: first indicator measure and first reorder
syncestablish state without animating. - Heavy-motion lock: the host holds one
createHeavyAnimationLockinstance andacquires around slides / docks / list-reorder / layout-size work. Layout-grow acquiresanyvia layout-size. Levelanypauses observers/decode;blockingalso defers store fan-out. This is not list-reorderisSuppressed, and it does not read an OS reduced-motion media query. - Layout size writer: scheduled
readPx/writePxunder the heavy lock; not a height WAAPI helper; prefer compositor substitutes (scaleY/ clip) for chrome; do not height-tween a scrolling column. - Digit-flip budget:
scheduleDigitFlipcaps at 10 per shared animation frame (onAnimationFrame); skips (no budget consumed) while an optional heavy lock is held.
