@llui/transitions
Current package version: 0.12.0
Animation helpers for LLui structural primitives. Works with show, branch, and each.
pnpm add @llui/transitions
Usage
Presets return a TransitionOptions bundle. Pass it positionally as the trailing transition argument to show/branch, or as the transition: option to each — never spread it. Element and structural helpers are module imports from @llui/dom; the view bag is { state, send }.
import { show, div, text } from '@llui/dom'
import { fade, slide, mergeTransitions } from '@llui/transitions'
// Inside a component's view({ state, send }):
// Fade + slide on a show block (transition is the 4th positional arg)
show(
state.at('visible'),
() => div([text(state.map((s) => s.message))]),
undefined, // no orElse arm
mergeTransitions(fade(), slide({ direction: 'down' })),
)
API
Core
| Function | Description |
|---|---|
transition(spec) |
Core primitive -- build a custom transition from a class/style spec (enterFrom, enterTo, enterActive, leaveFrom, leaveTo, leaveActive, plus duration, appear) |
mergeTransitions(...parts) |
Combine multiple transitions into one (chains their enter, leave, and onTransition handlers) |
All presets and the core primitive return a TransitionOptions bundle — { enter?, leave?, onTransition? } hooks that operate on raw DOM Nodes.
Presets
| Function | Options | Description |
|---|---|---|
fade(options?) |
duration, easing, appear |
Fade in/out |
slide(options?) |
direction, distance, duration, easing, fade, appear |
Slide from direction (up, down, left, right) |
scale(options?) |
from, duration, easing, fade, origin, appear |
Scale transform in/out |
collapse(options?) |
axis, duration, easing, appear |
Collapse/expand along y (height) or x (width); measures the natural size at runtime |
flip(options?) |
duration, easing |
FLIP reorder animation for each() |
Spring Physics
| Function | Options | Description |
|---|---|---|
spring(options?) |
stiffness, damping, mass, precision, property, from, to |
Spring-physics animation via rAF |
Uses a damped spring simulation instead of CSS easing. The animation runs via requestAnimationFrame and settles naturally based on physics parameters.
import { show } from '@llui/dom'
import { spring } from '@llui/transitions'
// Default: opacity 0 → 1 with react-spring-like defaults
show(state.at('open'), () => content(), undefined, spring())
// Custom spring feel
show(
state.at('open'),
() => content(),
undefined,
spring({ stiffness: 300, damping: 15, property: 'opacity' }),
)
Route Transitions
| Function | Options | Description |
|---|---|---|
routeTransition(options?) |
duration, easing, slide, slideDistance |
Fade + slide for branch() page transitions |
Convenience wrapper for animating page transitions in a branch():
// @doc-skip — uses `[...]` render-result placeholders
import { branch } from '@llui/dom'
import { routeTransition, fade } from '@llui/transitions'
// Default: fade + slight upward slide (250ms)
branch(
state.map((s) => s.route.page),
{ home: () => [...], about: () => [...] },
routeTransition(),
)
// Custom duration
branch(state.map((s) => s.route.page), arms, routeTransition({ duration: 200 }))
// Fade only (no slide)
branch(state.map((s) => s.route.page), arms, routeTransition({ duration: 200, slide: false }))
// Pass any preset directly
branch(state.map((s) => s.route.page), arms, routeTransition(fade({ duration: 200 })))
Stagger
| Function | Options | Description |
|---|---|---|
stagger(transition, options?) |
delayPerItem, leaveOrder |
Stagger enter/leave animations for each() items |
Wraps any transition preset so batch-entered items animate with incremental delays. Pass the result as each's transition: option:
// @doc-skip — uses `[...]` render-result placeholder
import { each } from '@llui/dom'
import { stagger, fade, slide } from '@llui/transitions'
each(state.at('items'), {
key: (i) => i.id,
render: (item) => [...],
transition: stagger(fade({ duration: 150 }), { delayPerItem: 30 }),
})
// Works with any preset
each(state.at('items'), {
key: (i) => i.id,
render: (item) => [...],
transition: stagger(slide({ direction: 'up' }), { delayPerItem: 50 }),
})
// Stagger leave animations too (default is simultaneous)
each(state.at('items'), {
key: (i) => i.id,
render: (item) => [...],
transition: stagger(fade(), { delayPerItem: 30, leaveOrder: 'sequential' }),
})
Items entering within the same microtask are considered a "batch" and get sequential delays. The counter resets after the microtask boundary, so the next batch starts from index 0.
Integration
Presets return a TransitionOptions object ({ enter?, leave?, onTransition? }). Pass it positionally to show/branch (the trailing transition argument) or as the transition: option to each — do not spread it. Row render callbacks receive a Signal handle (e.g. item is Signal<T>).
import { show, each, li, text } from '@llui/dom'
import { fade, flip } from '@llui/transitions'
// show with fade — transition is the 4th positional arg
show(state.at('open'), () => content(), undefined, fade())
// each with FLIP reorder — transition is an option in the second arg
each(state.at('list'), {
key: (item) => item.id,
render: (item) => li([text(item.map((i) => i.name))]),
transition: flip({ duration: 200 }),
})
Functions
collapse()
Animate an element open/closed along the y-axis (height) or x-axis (width).
Unlike CSS-only presets, collapse() measures the element's natural size
at runtime — the animation works regardless of content size. Only the
first element in each nodes group is animated.
Because it mutates overflow / height / transition inline, collapse
registers a per-element restore that runs the moment a later phase supersedes
it — so an interrupted open/close never leaves stale inline styles behind.
Like the other presets, this bundle is passed as the trailing transition
argument to the signal show/branch/each primitives (e.g.
show(state.at('open'), () => [panel()], undefined, collapse())) and is also
consumed at the route/container seam via fromTransition.
function collapse(opts: CollapseOptions = {}): TransitionOptions
fade()
function fade(opts: FadeOptions = {}): TransitionOptions
flip()
FLIP (First-Last-Invert-Play) reorder animation for keyed lists.
onTransition runs after a reconcile with { entering, leaving, parent }.
It compares each surviving child's last-known LAYOUT position (kept in a
WeakMap<Element, TrackedPosition>) against its new one and, for any that
moved, plays an inverse-then-identity transform so the row appears to glide.
A pass is split into a read phase and a write phase: every measurement
(getBoundingClientRect, offsetLeft/offsetTop, the computed transform)
happens before the first cancel()/animate(), so a K-row reorder forces
layout ONCE rather than once per row. Do not reintroduce a write between the
reads. (Measured in Chromium 143 over a 200-row list via CDP
Performance.getMetrics → LayoutCount, with layout left dirty as a
reconcile leaves it: 1 forced layout for a settled pass, 1 for a pass where
every row moves, 1 for a pass over rows already mid-glide. The same probe
reports 200 for a deliberately interleaved read/write loop.)
What the glide animates is the row's LAYOUT move, and nothing else. That is
why the bookkeeping stores the layout box rather than the box the row is
DRAWN in: the two differ by the row's own transform, which can change between
two passes entirely independently of layout, and attributing that change to a
move animates the row from a position it was never in (#185 — measured in
Chromium: a row whose author transform changed translateY(-20px) →
translateY(-50px) while settled glided 90px for a 60px move and jumped 30px
at glide start; a row whose author transform was mid CSS-transition folded the
in-flight 33.66px into dx and jumped by it). Neither available measurement
is on its own both transform-free and sub-pixel exact, so both are taken and
{@link layoutDelta} decides — see there, including why reading the author
transform per row instead is not free, and the size of the one case it gets
wrong.
The offsets are normalized through their shared offset-parent chain (#217).
Without that rebase, an ancestor going static → relative or static → sticky in the same update changed the origin under every stored pair. In the
four-row Chromium fixture that emitted 310px/190px for the reordered rows and
250px for each untouched row; normalization emits the actual +60px/−60px and
nothing for the other two. The chain contains layout coordinates only, so a
200px page scroll or a 150px inner-scroller move remains absent from the
result, preserving #185's scroll fix.
Origins are cached per distinct offset parent, so normalization adds one
offsetParent read per row but not a tree walk per row. Measured in Chromium
147 on that four-row, one-ancestor pass: each row reads one rect, two offsets,
and its offset parent; the shared chain adds the ancestor's two offsets, two
client-border reads, and its offset parent. Total: 4 rects, 10 row/ancestor
offsets, 2 client-border reads, 5 offset-parent reads, one forced layout, and
computed style only for the two rows that actually glide (zero style reads
for every settled row). Carrying the accumulated error bound is arithmetic
only and adds no DOM read. Five nested 0.49px ancestors changing to positioned
previously glided every untouched row 2px; the bound now keeps all four still.
Interruption: the live Animation is retained per element and cancelled
before the next one starts, and the translation the running glide had already
applied is ADDED back to the delta, so an interrupted reorder continues from
where the row currently appears rather than jumping. That translation is also
subtracted from the rect before storing it, so the stored pair still describes
a settled row. The run ENDS when the glide completes (or is cancelled by
anyone, including someone other than us): only while one is live is the
computed transform ours to read, and a run left registered makes every later
pass measure a row's own author transform as if it were a glide.
Composition (#144): a WAAPI animation wins the cascade for the property it
animates, so keyframes naming a bare translate(...) REPLACE the row's own
transform for the length of the glide. A row carrying a constant author
transform therefore jumped by that amount when a glide started and jumped back
when it ended (measured in Chromium: a .lift { transform: translateY(-20px) }
row resting at top: 0 reported top: 22 one frame into a 60px glide). Both
keyframes are therefore emitted as translate(…) <author transform>, which
puts the glide OUTSIDE the author's transform — the translation then means the
same thing whatever the author's linear part is, and a row with no transform
of its own still gets exactly the bare keyframes it always did.
The author transform is read in the read phase, and ONLY for a row that is
about to glide: rows that did not move keep paying nothing, which is the cost
balance #137 struck when it stopped reading the computed transform on every
settled row forever. A row that IS mid-glide cannot be read at all (our own
animation owns transform), so the value captured when its glide started is
cached and reused — an author transform that CHANGES mid-glide is therefore
carried at its old value until the row next settles. That caveat is about the
RENDERING only; the row's delta is unaffected, because the delta no longer
reads the author transform at all (#185).
The composition feeds back into the delta: the computed transform of a
composed glide carries the author's translation too, so the read phase
subtracts the cached author translation to recover our glide alone. Matrix
multiplication makes that exact — translate(g)·A has translation
g + A.translation for any A.
Element retention is deliberately weak: the tracked positions live in a
WeakMap and the working set is derived from parent's live children
(minus leaving) on each pass, so bulk-removed rows are never held and are
free to be garbage-collected. There is no independent strong Set.
Combine with an item-level appear/disappear preset via mergeTransitions:
mergeTransitions(fade(), flip())
The signal each() primitive invokes onTransition (with the entering /
leaving / parent for the reconcile), so passing flip() as each's trailing
transition argument animates surviving rows to their new positions:
each(state.at('rows'), r => r.id, row, undefined, flip({ duration: 300 }))
// or combined with an appear/disappear preset:
each(state.at('rows'), r => r.id, row, undefined, mergeTransitions(fade(), flip()))
Requires WAAPI (element.animate()). In environments without it (old
browsers, minimal jsdom) positions are still tracked but no animation runs.
function flip(opts: FlipOptions = {}): TransitionOptions
mergeTransitions()
Merge multiple TransitionOptions into one, chaining their enter,
leave, and onTransition handlers in order. leave waits for every
part's returned Promise before resolving.
Useful for combining an item-level animation (fade/slide/...) with flip():
mergeTransitions(fade(), flip())
The merged bundle is passed as the trailing transition argument to
show/branch/each (or adapted onto a route via fromTransition); each
drives the onTransition half of a flip() part. See flip().
function mergeTransitions(...parts: TransitionOptions[]): TransitionOptions
routeTransition()
Convenience wrapper that returns { enter, leave } hooks suitable for
animating page-to-page transitions.
Vike filesystem routing (@llui/vike): this is the wired consumer.
Vike's onRenderClient doesn't take { enter, leave } directly — each page
is its own component and the swap goes through dispose + clear + mount — so
fromTransition from @llui/vike/client adapts the bundle to the
onLeave / onEnter hook shape:
// pages/+onRenderClient.ts
import { createOnRenderClient, fromTransition } from '@llui/vike/client'
import { routeTransition } from '@llui/transitions'
export const onRenderClient = createOnRenderClient({
...fromTransition(routeTransition({ duration: 200 })),
})
The vike variant operates on the container / page-slot element itself — its opacity / transform fades out the whole page, then the new page fades in when it mounts.
Note: this preset targets the WHOLE page slot. For animating individual arms/rows, pass a preset bundle (
fade/slide/flip/…) as the trailing transition argument toshow/branch/eachdirectly;routeTransitionviafromTransitionis for the page-to-page/container swap.
The call form also accepts a pre-built TransitionOptions from any preset or
composition (fade, slide, scale, flip, mergeTransitions, …) —
detected by the presence of an enter, leave, or onTransition hook — and
passes it through unchanged.
function routeTransition(opts?: RouteTransitionOptions | TransitionOptions): TransitionOptions
scale()
Scale an element in/out from from to 1, optionally fading with it.
Emits the same per-property shorthand as {@link slide} — transform 200ms ease-out, opacity 200ms ease-out — for the reason spelled out there (#142).
transform-origin rides along in the active value but never transitions, so
it is not one of the properties the phase waits on.
function scale(opts: ScaleOptions = {}): TransitionOptions
slide()
Slide an element in/out along one axis, optionally fading with it.
Both animated properties carry their own duration and easing in the emitted
shorthand — transform 250ms ease-out, opacity 250ms ease-out. Writing the
properties as one list with a single trailing timing (transform, opacity 250ms ease-out) gives transform the initial duration of 0s, so it snaps
and reports no transitionend; that was #142, and it is invisible to jsdom.
function slide(opts: SlideOptions = {}): TransitionOptions
spring()
Spring-physics transition. Returns { enter, leave } that animate a CSS
property using a damped spring simulation driven by requestAnimationFrame.
When requestAnimationFrame can't drive the loop — server render, or a
hidden/background tab where rAF is paused — the animation settles instantly
to its target and the returned Promise still resolves. This matters for the
leave Promise: it gates DOM removal, so a spring leave in a hidden tab must
not hang (e.g. fromTransition(spring()) route navigation). Honoring
prefers-reduced-motion takes the same instant-settle path.
Interruption: enter and leave on the SAME element supersede each other. A new
phase cancels the previous element's loop WITHOUT letting it snap to its own
(now-stale) target, so an enter interrupted by a leave rests at the leave
target rather than being clobbered back to the enter target by the dying loop.
Either direction resumes from the element's CURRENT value — the endpoints
from/to are resting starts, used only when nothing is in flight.
Passed as the trailing transition argument to the signal show/branch/each
primitives to spring an arm/row in and defer its leave, e.g.
show(state.at('open'), () => [panel()], undefined, spring()); also consumed
at the route/container seam via fromTransition in @llui/vike/client.
function spring(opts: SpringOptions = {}): TransitionOptions
stagger()
function stagger(spec: TransitionOptions, opts?: StaggerOptions): TransitionOptions
transition()
Build a TransitionOptions bundle ({ enter, leave }) from a class/style spec.
The returned hooks operate on raw DOM Nodes and are invoked by two seams:
-
Element-level structural transitions — the signal
show/branch/eachprimitives accept thisTransitionOptionsbundle directly and drive it:enteranimates a freshly-mounted arm/row in, andleaveDEFERS the swapped-out arm/row's unmount until its promise resolves. Pass a bundle as the trailing argument:show(state.at('open'), () => [panel()], undefined, fade({ duration: 150 })) branch(state, s => s.tab, { a: () => [tabA()], b: () => [tabB()] }, slide()) each(state.at('items'), i => i.id, row, undefined, fade({ duration: 120 })) -
Route/container seam —
fromTransition(...)in@llui/vike/clientadapts the same bundle onto the page slot element (seerouteTransition) for whole-view/route navigations rather than individual arms.
Lifecycle:
- enter: apply
enterFrom+enterActive→ reflow → swapenterFrom→enterTo→ wait fortransitionend(timer fallback) → remove all transient values. - leave: apply
leaveFrom+leaveActive→ reflow → swapleaveFrom→leaveTo→ resolve ontransitionend(timer fallback) so DOM removal is deferred.
Interruption: enter/leave on a reused element are guarded by a per-element run
token — a new phase first rolls back the previous phase's transient values,
and a superseded phase's delayed cleanup is skipped. This holds for a leave
that already COMPLETED too: it keeps its resting values (the arm is about to
be detached) but stays registered, so if the element is instead reused — the
route seam calls enter on the very element it just left — the enter clears
that residue before snapshotting its own baseline.
Interrupting a phase mid-flight resumes from the element's CURRENT rendered
values in BOTH directions, by the same mechanism: the animated properties are
frozen at what the element is showing and applied in place of the phase's
from value, so neither direction re-animates from the far end. Freezing is
what makes it work — merely SKIPPING the from value is not enough, because
superseding the interrupted phase fires its rollback, which restores the
pre-phase inline value (for a fade, '' — fully visible). A phase that has
already settled counts as resting, not as an interrupt.
Completion: a phase resolves only once EVERY property it animates (the style
keys of its from/to values) has reported a transitionend on the element
itself — an unrelated transitionend (a hover background-color, or the fast
half of transition: opacity 100ms, transform 500ms) does not end the phase,
because the runtime detaches a leaving node on exactly that promise. A
class-only spec names no properties, so any end on the target resolves it.
Duration (used only for the fallback timer / when no CSS transition fires):
- If
durationis given, it is used verbatim. - Otherwise, computed
transition-duration + transition-delayis read after the active/from values are applied, taking the max across properties.
function transition(spec: TransitionSpec): TransitionOptions
Types
SlideDirection
export type SlideDirection = 'up' | 'down' | 'left' | 'right'
Styles
CSS style properties as a plain object. Numeric values are automatically
suffixed with px for known dimensional properties.
Example: { opacity: 0, transform: 'scale(0.95)', width: 200 }
export type Styles = Record<string, string | number>
TransitionValue
One "state" in a transition.
string— space-separated class names (applied via classList)Styles— inline style object (applied via element.style)Array<string | Styles>— mix both (useful for utility classes + dynamic styles)
export type TransitionValue = string | Styles | Array<string | Styles>
Interfaces
CollapseOptions
export interface CollapseOptions {
/** Axis to collapse: 'y' = height, 'x' = width (default: 'y'). */
axis?: 'x' | 'y'
duration?: number
easing?: string
appear?: boolean
/** Honor `prefers-reduced-motion` (default: true) — resolve instantly when reduced motion is requested. */
respectReducedMotion?: boolean
}
FadeOptions
export interface FadeOptions {
duration?: number
easing?: string
appear?: boolean
/** Honor `prefers-reduced-motion` (default: true) — resolve instantly when reduced motion is requested. */
respectReducedMotion?: boolean
}
FlipOptions
export interface FlipOptions {
duration?: number
easing?: string
/** Honor `prefers-reduced-motion` (default: true) — skip the reorder animation (rows jump) when reduced motion is requested. */
respectReducedMotion?: boolean
}
RouteTransitionOptions
export interface RouteTransitionOptions {
/** Duration in milliseconds (default: 250). */
duration?: number
/** Easing function (default: 'ease-out'). */
easing?: string
/** Enable a slight vertical slide alongside the fade (default: true). */
slide?: boolean
/** Slide distance in pixels (default: 12). */
slideDistance?: number
}
ScaleOptions
export interface ScaleOptions {
/** Starting scale factor (default: 0.95). */
from?: number
duration?: number
easing?: string
/** Also animate opacity (default: true). */
fade?: boolean
/** Transform origin (default: 'center'). */
origin?: string
appear?: boolean
/** Honor `prefers-reduced-motion` (default: true) — resolve instantly when reduced motion is requested. */
respectReducedMotion?: boolean
}
SlideOptions
export interface SlideOptions {
/** The direction the element slides IN from (default: 'down' — enters from below). */
direction?: SlideDirection
/** Pixel distance to slide (default: 20). */
distance?: number
duration?: number
easing?: string
/** Also animate opacity (default: true). */
fade?: boolean
appear?: boolean
/** Honor `prefers-reduced-motion` (default: true) — resolve instantly when reduced motion is requested. */
respectReducedMotion?: boolean
}
SpringOptions
export interface SpringOptions {
/** Spring stiffness (default: 170). */
stiffness?: number
/** Damping coefficient (default: 26). */
damping?: number
/** Mass (default: 1). */
mass?: number
/** Stop threshold for velocity and position (default: 0.01). */
precision?: number
/** CSS property to animate (default: 'opacity'). */
property?: string
/** Start value (default: 0). */
from?: number
/** End value (default: 1). */
to?: number
/** Honor `prefers-reduced-motion` (default: true) — jump to the target instantly when reduced motion is requested. */
respectReducedMotion?: boolean
}
StaggerOptions
export interface StaggerOptions {
/** Delay between each item in milliseconds (default: 30). */
delayPerItem?: number
/** How to stagger leave animations: 'sequential' (same order as enter),
* 'reverse', or 'simultaneous' (no stagger). Default: 'simultaneous'. */
leaveOrder?: 'sequential' | 'reverse' | 'simultaneous'
/** Honor `prefers-reduced-motion` (default: true) — drop the per-item stagger delays when reduced motion is requested. */
respectReducedMotion?: boolean
}
TransitionSpec
export interface TransitionSpec {
/** Initial state before enter animation (removed once enter completes). */
enterFrom?: TransitionValue
/** Final state during enter animation (removed once enter completes). */
enterTo?: TransitionValue
/** Applied throughout enter (typically the `transition-*` / `animation` properties). */
enterActive?: TransitionValue
/** Initial state before leave animation. */
leaveFrom?: TransitionValue
/** Final state during leave animation. */
leaveTo?: TransitionValue
/** Applied throughout leave. */
leaveActive?: TransitionValue
/**
* Explicit duration in milliseconds. When omitted, the duration is read from
* the element's computed `transition-duration` / `transition-delay` after the
* active classes are applied.
*/
duration?: number
/** If true, run the enter transition on initial mount (default: true). */
appear?: boolean
/**
* Honor the user's `prefers-reduced-motion: reduce` setting (default: true).
* When reduced motion is requested, enter/leave resolve instantly to the final
* state instead of animating. Set `false` to always animate.
*/
respectReducedMotion?: boolean
}