Write your own driver
A driver is one function: (spec, onComplete). Zumly works out where every view starts and ends; your function moves them there and says when it is done.
Open a planet and come back. Nothing scales: this driver puts every view in its final place and crossfades. Its code is below.
Do you need one?
The built-in drivers (css, the default, waapi, none, and GSAP, Anime.js and Motion) cover most apps. Write your own when:
- The move is not a zoom: a crossfade, a flip, a WebGL effect.
- Your animation engine is not on the list.
- You want to wrap a built-in driver to log or add something, which takes a few lines (see the end of this page).
The smallest driver
Pass a function as transitions.driver. Zumly calls it on every zoom in, zoom out and sideways move:
function myDriver(spec, onComplete) {
// Animate based on spec.type, then call onComplete()
onComplete()
}
const app = new Zumly({
mount: '.canvas',
initialView: 'home',
views: { home, detail },
transitions: {
driver: myDriver,
duration: '500ms',
ease: 'ease-in-out',
},
})
await app.init()
What you receive
{
type: 'zoomIn' | 'zoomOut' | 'lateral',
currentView: HTMLElement, // incoming (zoom-in) or outgoing (zoom-out)
previousView: HTMLElement, // the parent
lastView: HTMLElement | null, // grandparent (null at depth ≤ 2)
currentStage: { views: [...] }, // pre-computed transforms (see below)
duration: '600ms',
ease: 'ease-in-out',
canvas: HTMLElement, // canvas container (zoom-out and lateral)
}
- For
'zoomIn'and'zoomOut',previousViewis the parent. For'lateral',currentViewis the sibling coming in andpreviousViewthe one going out. currentStage.viewsholds the computed states by role:[0]current,[1]parent,[2]grandparent (from three levels deep).
{
viewName: 'detail',
backwardState: {
origin: '125px 100px',
transform: 'translate(50px, 60px) scale(0.25)',
duration: '500ms',
ease: 'ease-in-out',
},
forwardState: {
origin: '125px 100px',
transform: 'translate(100px, 50px)',
duration: '500ms',
ease: 'ease-in-out',
}
}
Zoom in goes from backwardState to forwardState; zoom out goes back.
Two rules
1. Always call onComplete(), once. Navigation stays locked until you do. createFinishGuard calls it for you if your animation never ends:
import {
createFinishGuard,
parseDurationMs,
SAFETY_BUFFER_MS,
} from 'zumly/driver-helpers'
function runTransition(spec, onComplete) {
const ms = parseDurationMs(spec.duration)
const { finish } = createFinishGuard(() => {
// cleanup work…
onComplete()
}, ms + SAFETY_BUFFER_MS)
myAnimation.onfinish = finish
// If the animation cancels, the safety timer calls finish() anyway.
}
2. Leave the views in their final state. Zumly does not tidy up after you. The helpers do it:
import {
showViews,
applyZoomInEndState,
applyZoomOutPreviousState,
applyZoomOutLastState,
removeViewFromCanvas,
} from 'zumly/driver-helpers'
// Before animating — make views visible:
showViews(spec.currentView, spec.previousView, spec.lastView)
// After zoom-in completes:
applyZoomInEndState(currentView, currentStage)
applyZoomInEndState(previousView, currentStage)
if (lastView) applyZoomInEndState(lastView, currentStage)
// After zoom-out completes:
removeViewFromCanvas(currentView, canvas)
applyZoomOutPreviousState(previousView, currentStage.views[1].backwardState)
if (lastView) applyZoomOutLastState(lastView, currentStage.views[2].backwardState)
Helpers
From zumly/driver-helpers:
| Helper | Does |
|---|---|
parseDurationMs, parseDurationSec | '1s', '500ms' or a number, to ms or seconds |
showViews(...views) | Makes the views visible before animating |
applyZoomInEndState(view, stage) | Final classes and transform after a zoom in |
applyZoomOutPreviousState, applyZoomOutLastState | Final state of the parent and grandparent after a zoom out |
removeViewFromCanvas(view, canvas) | Removes the view you zoomed out of |
runLateralInstant(spec, onComplete) | A sideways move with no animation |
createFinishGuard(cleanup, ms), SAFETY_BUFFER_MS | Finish once, with a safety timeout |
cssEasing, cubicBezier, steps | CSS easings as functions of progress, to match the built-in curve |
readComputedMatrix, interpolateMatrix, matrixToString, parseMatrixString | For drivers that paint frames themselves: correct in-between frames when transform-origin changes |
A complete driver: crossfade
The driver of the live example above: every view jumps to its final place, and the new one fades in (or the old one fades out). Copy it and replace the animation.
import {
parseDurationMs, showViews, applyZoomInEndState, applyZoomOutPreviousState, applyZoomOutLastState,
removeViewFromCanvas, runLateralInstant, createFinishGuard, SAFETY_BUFFER_MS
} from 'zumly/driver-helpers'
// A crossfade instead of a zoom: views jump to their final place, opacity does the rest
export function crossfade (spec, onComplete) {
const { type, currentView, previousView, lastView, currentStage, duration, canvas } = spec
if (type === 'lateral') return runLateralInstant(spec, onComplete)
const ms = parseDurationMs(duration)
showViews(currentView, previousView, lastView)
if (type === 'zoomIn') {
for (const view of [currentView, previousView, lastView]) if (view) applyZoomInEndState(view, currentStage)
currentView.style.opacity = '0'
const { finish } = createFinishGuard(() => {
currentView.style.transition = currentView.style.opacity = ''
onComplete()
}, ms + SAFETY_BUFFER_MS)
requestAnimationFrame(() => {
currentView.style.transition = `opacity ${ms}ms ease`
currentView.style.opacity = '1'
currentView.addEventListener('transitionend', finish, { once: true })
})
return
}
if (type === 'zoomOut') {
applyZoomOutPreviousState(previousView, currentStage.views[1].backwardState)
if (lastView) applyZoomOutLastState(lastView, currentStage.views[2].backwardState)
const { finish } = createFinishGuard(() => {
removeViewFromCanvas(currentView, canvas)
onComplete()
}, ms + SAFETY_BUFFER_MS)
requestAnimationFrame(() => {
currentView.style.transition = `opacity ${ms}ms ease`
currentView.style.opacity = '0'
currentView.addEventListener('transitionend', finish, { once: true })
})
return
}
onComplete()
}
Wrapping a built-in driver
import { gsap } from 'gsap'
import Zumly, { gsapDriver } from 'zumly'
const gsapTransition = gsapDriver(gsap)
function loggedDriver(spec, onComplete) {
console.log('zumly transition', spec.type, spec.currentStage.views[0].viewName)
gsapTransition(spec, onComplete)
}
const app = new Zumly({
mount: '.canvas',
initialView: 'home',
views: { home, detail },
transitions: { driver: loggedDriver, duration: '500ms' },
})
Checking it
The 'none' driver applies the final state at once. Yours should end in the same DOM, animated:
- After a zoom in,
.is-current-viewis the new view. - After a zoom out, the parent is
.is-current-viewand the old view is gone. onComplete()runs even when a view is removed mid-animation.
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')
The full reference
Every spec field and edge case is in DRIVER_API.md, in the repository.
Open DRIVER_API.md → Open this example ↗