Guide · Driver

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.

Live example · crossfade driverOpen ↗

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 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)
}
{
  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:

HelperDoes
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, applyZoomOutLastStateFinal 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_MSFinish once, with a safety timeout
cssEasing, cubicBezier, stepsCSS easings as functions of progress, to match the built-in curve
readComputedMatrix, interpolateMatrix, matrixToString, parseMatrixStringFor 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:

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 ↗