Zumly (Z over XY — focus-driven navigation, zoom into what matters) delegates animation to transition drivers. A driver controls how views move during zoom-in, zoom-out, and lateral navigation. The engine computes what moves where (transforms, origins, snapshot); the driver applies motion — CSS keyframes, WAAPI, GSAP, Motion, Anime.js, or a custom timeline.
See also: README.md (options, built-in drivers, plugins) · geometry-optimization.md (how zoom-out layout reads are batched before the driver runs).
A driver is an object with a single method:
const myDriver = {
runTransition(spec, onComplete) {
// Animate views from spec.currentStage backward → forward states
// Call onComplete() when done — this is mandatory.
}
}Register it in the constructor:
new Zumly({
mount: '.canvas',
initialView: 'home',
views: { home, detail },
transitions: {
driver: myDriver.runTransition, // pass the function directly
duration: '600ms',
ease: 'ease-in-out',
},
})Or as a factory function:
transitions: {
driver: (spec, onComplete) => myDriver.runTransition(spec, onComplete),
}Your runTransition(spec, onComplete) receives a spec with everything needed:
| Field | Type | Description |
|---|---|---|
type |
'zoomIn' | 'zoomOut' | 'lateral' |
What kind of transition |
currentView |
HTMLElement |
The incoming view (zoom-in) or outgoing view (zoom-out) |
previousView |
HTMLElement |
The parent view |
lastView |
HTMLElement | null |
Grandparent view (null at depth ≤ 2) |
currentStage |
object |
Snapshot with computed states (see below) |
duration |
string |
e.g. '500ms', '1s' |
ease |
string |
CSS easing, e.g. 'ease-in-out' |
canvas |
HTMLElement |
The canvas container (zoom-out and lateral only) |
An array of view entries, indexed by role:
views[0] → current view (the one zooming in/out)
views[1] → previous view (the parent)
views[2] → last view (grandparent, when depth > 2)
Each entry has:
{
viewName: 'detail',
backwardState: {
origin: '125px 100px', // CSS transform-origin
transform: 'translate(50px, 60px) scale(0.25)', // "start" for zoom-in, "end" for zoom-out
duration: '500ms',
ease: 'ease-in-out',
},
forwardState: {
origin: '125px 100px',
transform: 'translate(100px, 50px)', // "end" for zoom-in, "start" for zoom-out
duration: '500ms',
ease: 'ease-in-out',
}
}For zoom-in: animate from backwardState.transform → forwardState.transform.
For zoom-out: animate from forwardState.transform → backwardState.transform.
| Field | Type | Description |
|---|---|---|
backView |
HTMLElement | null |
The parent view behind current depth |
backViewState |
{ transformStart, transformEnd } |
Slide transforms for backView |
lastViewState |
{ transformStart, transformEnd } |
Slide transforms for lastView |
incomingTransformStart |
string |
Incoming view start transform |
incomingTransformEnd |
string |
Incoming view final transform |
outgoingTransform |
string |
Outgoing view current transform |
outgoingTransformEnd |
string |
Outgoing view exit transform |
slideDeltaX |
number |
Horizontal slide distance |
slideDeltaY |
number |
Vertical slide distance |
This is the #1 rule. The engine sets blockEvents = true before calling your driver and only resets it in onComplete. If you never call it, the UI freezes permanently.
Use the createFinishGuard helper to guarantee this:
import { createFinishGuard, SAFETY_BUFFER_MS, parseDurationMs } from 'zumly/driver-helpers'
function runTransition(spec, onComplete) {
const durationMs = parseDurationMs(spec.duration)
const { finish } = createFinishGuard(() => {
// cleanup work here...
onComplete()
}, durationMs + SAFETY_BUFFER_MS)
// Your animation...
myAnimation.onfinish = finish
// If the animation fails/gets cancelled, the safety timer calls finish() anyway.
}(In this repo, you can import from ../src/drivers/driver-helpers.js from your own source files.)
When the animation ends, the DOM must reflect the final state — the engine does NOT do this for you. Use the shared helpers:
import {
applyZoomInEndState,
applyZoomOutPreviousState,
applyZoomOutLastState,
removeViewFromCanvas,
showViews
} from 'zumly/driver-helpers'
// Before animating — make views visible:
showViews(spec.currentView, spec.previousView, spec.lastView)
// After zoom-in animation completes:
applyZoomInEndState(currentView, currentStage)
applyZoomInEndState(previousView, currentStage)
if (lastView) applyZoomInEndState(lastView, currentStage)
// After zoom-out animation completes:
removeViewFromCanvas(currentView, canvas)
applyZoomOutPreviousState(previousView, currentStage.views[1].backwardState)
if (lastView) applyZoomOutLastState(lastView, currentStage.views[2].backwardState)Your driver receives spec.type which is one of:
'zoomIn'— drill deeper'zoomOut'— go back'lateral'— same-level swap
For lateral, the built-in waapi driver runs a slide animation. If you author a minimal custom driver and want no lateral motion, call the instant helper:
import { runLateralInstant } from 'zumly/driver-helpers'
if (spec.type === 'lateral') {
runLateralInstant(spec, onComplete)
return
}Import from zumly/driver-helpers (published) or src/drivers/driver-helpers.js (monorepo):
| Helper | Purpose |
|---|---|
parseDurationMs(duration) |
Parse '1s'/'500ms'/number → ms |
parseDurationSec(duration) |
Same but returns seconds |
applyZoomInEndState(el, stage) |
Apply final classes + transforms after zoom-in |
applyZoomOutPreviousState(el, state) |
Final state for previous view after zoom-out |
applyZoomOutLastState(el, state) |
Final state for last view after zoom-out |
removeViewFromCanvas(el, canvas) |
Safe removal (handles wrapped elements) |
showViews(...elements) |
Remove hide class + set contentVisibility: auto |
runLateralInstant(spec, onComplete) |
Instant lateral transition (no animation) |
createFinishGuard(cleanup, timeoutMs) |
Once-only finish + safety timeout |
SAFETY_BUFFER_MS |
Default safety buffer (150ms) |
For drivers that need to interpolate through computed CSS matrices (e.g. when transform-origin varies between states):
| Helper | Purpose |
|---|---|
readComputedMatrix(el, origin, transform) |
Read browser-computed matrix ( |
interpolateMatrix(from, to, t) |
Lerp between two matrix objects |
matrixToString(m) |
{ a,b,c,d,e,f } → "matrix(...)" |
parseMatrixString(str) |
"matrix(...)" → { a,b,c,d,e,f } |
identityMatrix() |
Returns { a:1, b:0, c:0, d:1, e:0, f:0 } |
lerp(a, b, t) |
Linear interpolation |
A complete, minimal driver that does a simple opacity crossfade instead of zoom:
import {
parseDurationMs,
showViews,
applyZoomInEndState,
applyZoomOutPreviousState,
applyZoomOutLastState,
removeViewFromCanvas,
runLateralInstant,
createFinishGuard,
SAFETY_BUFFER_MS,
} from 'zumly/driver-helpers'
export function runTransition(spec, onComplete) {
const { type, currentView, previousView, lastView, currentStage, duration, canvas } = spec
if (type === 'lateral') {
runLateralInstant(spec, onComplete)
return
}
const ms = parseDurationMs(duration)
showViews(currentView, previousView, lastView)
if (type === 'zoomIn') {
// Simple crossfade: incoming fades in, outgoing fades out
currentView.style.opacity = '0'
currentView.style.transition = `opacity ${ms}ms ease`
previousView.style.transition = `opacity ${ms}ms ease`
requestAnimationFrame(() => {
currentView.style.opacity = '1'
previousView.style.opacity = '0.3'
})
const { finish } = createFinishGuard(() => {
currentView.style.transition = ''
previousView.style.transition = ''
previousView.style.opacity = ''
applyZoomInEndState(currentView, currentStage)
applyZoomInEndState(previousView, currentStage)
if (lastView) applyZoomInEndState(lastView, currentStage)
onComplete()
}, ms + SAFETY_BUFFER_MS)
currentView.addEventListener('transitionend', finish, { once: true })
return
}
if (type === 'zoomOut') {
currentView.style.transition = `opacity ${ms}ms ease`
requestAnimationFrame(() => {
currentView.style.opacity = '0'
previousView.style.opacity = '1'
})
const { finish } = createFinishGuard(() => {
currentView.style.transition = ''
removeViewFromCanvas(currentView, canvas)
applyZoomOutPreviousState(previousView, currentStage.views[1].backwardState)
if (lastView) applyZoomOutLastState(lastView, currentStage.views[2].backwardState)
onComplete()
}, ms + SAFETY_BUFFER_MS)
currentView.addEventListener('transitionend', finish, { once: true })
return
}
onComplete()
}Use driver: 'none' as a reference — it applies final state instantly and calls onComplete() synchronously. Your driver should produce the same final DOM state, just animated.
Key things to test:
- After zoom-in:
.is-current-viewexists with correctdataset.viewName - After zoom-out: previous view is now
.is-current-view, old current is removed from DOM onComplete()is always called, even if elements are removed mid-animationblockEventsis reset (the engine handles this inonComplete, but verify your driver calls it)
const app = new Zumly({
mount: '.canvas',
initialView: 'home',
views: { home, detail },
transitions: { driver: myDriver, duration: '100ms' },
})
await app.init()
await app.zoomTo('detail')
expect(app.getCurrentViewName()).toBe('detail')
app.back()
expect(app.getCurrentViewName()).toBe('home')Built-in drivers are resolved by name ('css', 'waapi', 'none', etc.) in src/drivers/index.js. Community drivers are passed as functions — no registration needed:
// Direct function — works out of the box:
transitions: { driver: myDriver.runTransition }
// If you want to publish as a package:
// npm: zumly-driver-lottie
import { runTransition } from 'zumly-driver-lottie'
new Zumly({ transitions: { driver: runTransition } })- README.md — installation and transition driver table
- roadMap.md — architecture notes and animation driver history