npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/animations

Import 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 clone

playSharedElement(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 idle

Layout 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 teardown

Do 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 pop

pickAnimationLevelFromRatio / 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 unmounts

setActive 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 teardown

List 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 window

Host 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 seed defaultAnimationLevel. Stored low / med / high owns playback after that.
  • canAnimate(level) — false only for low.
  • canAnimateFull(level) — true only for high. Layout grow, list shift, send morph, and presence pop snap at med and low.
  • 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 / destroy when 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: onAnimationFrame is one rAF for JS ticks; dualRaf still waits two frames; hosts no-op non-motion subscribers while a blocking lock is held.
  • Measure / mutate: queueMeasure then queueMutate on one frame; dualRaf still means wait two frames; queueMeasureAfterMutate is the rare second read.
  • First layout is a baseline: first indicator measure and first reorder sync establish state without animating.
  • Heavy-motion lock: the host holds one createHeavyAnimationLock instance and acquires around slides / docks / list-reorder / layout-size work. Layout-grow acquires any via layout-size. Level any pauses observers/decode; blocking also defers store fan-out. This is not list-reorder isSuppressed, and it does not read an OS reduced-motion media query.
  • Layout 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.
  • Digit-flip budget: scheduleDigitFlip caps at 10 per shared animation frame (onAnimationFrame); skips (no budget consumed) while an optional heavy lock is held.