Z over XY
Focus-driven navigation. Zoom into what matters.
Zumly is a JavaScript library for hierarchical zoom navigation: you move in Z (depth) through discrete views laid out in the XY plane, with spatial transitions instead of flat screen swaps. It is inspired by zoomable user interfaces (ZUI) but targets structured, trigger-driven zoom—not infinite pan/zoom canvases.
Zumly is under active development. It supports depth and lateral navigation, pluggable transition drivers (CSS, WAAPI, none, Anime.js, GSAP, Motion, custom), separate depth and lateral navigation controls, a view resolver and prefetch cache, an optional plugin API (.use()), and the hash router plugin. View sources include HTML strings, URLs, async functions, objects with render(), DOM nodes, and web component tags.
Zoom-out geometry uses batched DOM reads plus pure math where possible to cut layout thrash before animations (see Geometry optimization).
Docs: Roadmap & topics · Transition drivers · Geometry notes
Unlike free-pan ZUIs, Zumly focuses on discrete, hierarchical navigation: users zoom into a focused element (.zoom-me) to open the next view, so attention (focus) and depth (Z) stay aligned with layout (XY).
The engine is UI-agnostic—you supply markup and CSS. Transforms and timing are handled for you; design systems and frameworks integrate by resolving each view to a DOM subtree (see View sources and Framework integration below).
Zumly is not a freeform zooming canvas or map-like navigation system. It is a discrete, hierarchical zoom interface: screens are views at different depths, connected by triggers, with continuous motion between them.
It fits especially well when:
- you want focus-driven flow (zoom into what matters)
- spatial context between parent and child should persist
- you are building menus, stories, dashboards, or exploratory UIs without a classic router-only metaphor
npm install zumly
# or
yarn add zumlyInclude Zumly in your project via a <script> tag from unpkg.com/zumly.
Download the built files from unpkg.com/zumly (see the dist folder).
- Add the CSS in your
<head>:
<link rel="stylesheet" href="zumly/dist/zumly.css">
<!-- or https://unpkg.com/zumly/dist/zumly.css -->- Load the JS bundle (it exposes
window.Zumly):
<script src="zumly/dist/zumly.js"></script>
<!-- or https://unpkg.com/zumly/dist/zumly.js -->- Add a container with the class
zumly-canvas:
<div class="example zumly-canvas"></div>- Create your views and start Zumly:
const hello = `
<div class="z-view">
H E L L O <br>
W <span class="zoom-me" data-to="world">O</span> R L D!
</div>
`;
const world = `
<div class="z-view">
<img src="https://raw.githubusercontent.com/zumly/website/gh-pages/images/world.png" alt="World">
</div>
`;
const app = new Zumly({
mount: '.example',
initialView: 'hello',
views: { hello, world },
});
await app.init();- Live example: CodePen
Zumly constructor:
| Option | Type | Required | Description |
|---|---|---|---|
mount |
string | Yes | CSS selector for the canvas element (must have class zumly-canvas). |
initialView |
string | Yes | Name of the first view to show. |
views |
object | Yes | Map of view names to view sources (see View sources below). |
preload |
string[] | No | View names to resolve and cache when the app initializes. |
transitions |
object | No | Duration, ease, cover, driver, effects, stagger, hideTrigger for zoom transitions. |
deferred |
boolean | No | Defer content rendering until after animation completes (default: false). |
debug |
boolean | No | Enable debug messages (default: false). |
lateralNav |
boolean | object | No | Lateral navigation UI: { mode, arrows, dots, keepAlive, position, siblings }. |
depthNav |
boolean | object | No | Depth back button: { position }. Default: 'bottom-left'. |
inputs |
boolean | object | No | Input methods: { click, keyboard, wheel, touch }. |
componentContext |
object | No | Context passed to component-style views. |
Transitions (optional):
transitions: {
driver: 'css', // 'css' | 'waapi' | 'anime' | 'gsap' | 'motion' | 'none' or custom function(spec, onComplete)
cover: 'width', // or 'height' — how the previous view scales to cover the trigger
duration: '1s',
ease: 'ease-in-out',
effects: ['blur(3px) brightness(0.7)', 'blur(8px) saturate(0)'], // CSS filters for [previous, last] background views
stagger: 0, // delay (ms) between layers during transition
hideTrigger: false, // false | true (visibility:hidden) | 'fade' (opacity crossfade)
// threshold: { enabled: true, duration: 300, commitAt: 0.5 } // parsed but not wired in the engine yet
}transitions.parallax is accepted for compatibility but not applied (reserved; intensity is fixed to 0 in the engine).
Transition drivers: Zoom animations are handled by a pluggable driver (transitions.driver). You can swap implementations without changing app logic. To author your own, see docs/DRIVER_API.md and the zumly/driver-helpers export.
| Driver | Description |
|---|---|
'css' (default) |
CSS keyframes and animationend; uses zumly.css variables. |
'waapi' |
Web Animations API (element.animate()). No extra dependency. |
'none' |
No animation; applies final state immediately. Useful for tests or instant UX. |
'anime' |
Anime.js — requires global anime (load from CDN before use). |
'gsap' |
GSAP — requires global gsap (load from CDN before use). |
'motion' |
Motion — requires global Motion (load from CDN before use). |
function(spec, onComplete) |
Custom driver. Receives { type, currentView, previousView, lastView, currentStage, duration, ease } and must call onComplete() when done. |
Note: only the
cssandwaapidrivers animate lateral navigation with a slide;anime,gsap,motionandnoneperform lateral moves instantly.
Example with instant transitions (e.g. for tests):
const app = new Zumly({
mount: '.canvas',
initialView: 'home',
views: { home, detail },
transitions: { driver: 'none', duration: '0s' },
});Lateral navigation (arrows + dots bar):
lateralNav: true // mode: 'auto' (default), bottom-center
lateralNav: false // disabled
lateralNav: { mode: 'always' } // always show when siblings exist
lateralNav: { mode: 'auto', dots: false } // auto mode, no dots
lateralNav: { position: 'top-center' } // top instead of bottom
lateralNav: { siblings: ['today', 'tomorrow', 'week'] } // declared sibling order
lateralNav: { siblings: { home: ['today', 'tomorrow', 'week'] } } // per-parent groups| Mode | Description |
|---|---|
'auto' (default) |
Shows lateral nav only when the current view doesn't cover the full canvas — preserving spatial context. |
'always' |
Always shows lateral nav when siblings exist, regardless of coverage. |
Position: 'bottom-center' (default) or 'top-center'.
siblings declares the sibling order for lateral navigation. It takes precedence over inferring the order (and slide direction) from trigger positions in the parent view — non-adjacent jumps slide proportionally further. Use an array for a single lateral group, or a map keyed by parent view name for per-parent groups.
Depth navigation (back button):
depthNav: true // default: back button at bottom-left
depthNav: false // disabled
depthNav: { position: 'top-left' } // top instead of bottomPosition: 'bottom-left' (default) or 'top-left'. The depth and lateral nav are separate, independently positioned components.
Zoomable elements:
- Give the view root the class
z-view. - Add class
zoom-meanddata-to="viewName"to the element that triggers zoom-in. - Per-trigger overrides via
data-*attributes:
| Attribute | Description |
|---|---|
data-to |
Required. Target view name. |
data-with-duration |
Override transition duration (e.g. "2s"). |
data-with-ease |
Override easing function. |
data-with-cover |
Override cover dimension ("width" or "height"). |
data-with-stagger |
Override stagger delay in ms (e.g. "100"). |
data-with-effects |
Override effects (pipe-separated: "blur(5px)|blur(10px)"). |
data-hide-trigger |
Override hideTrigger ("fade" or presence = hide). |
data-deferred |
Override deferred rendering (presence = true). |
data-* |
Any other data attribute becomes a prop in ViewContext.props. |
<div class="z-view">
<div class="zoom-me" data-to="detail"
data-with-duration="2s"
data-with-ease="ease-in"
data-with-cover="height"
data-with-stagger="100"
data-id="42">
Zoom in
</div>
</div>Each entry in views is a view source. The resolver detects the type and resolves to a DOM node. Hyphenated view names (e.g. 'my-dashboard') are resolved as keys in views first; only raw template strings with a hyphen are treated as web components.
| Type | Example | Cached? |
|---|---|---|
| HTML string | '<div class="z-view">…</div>' |
Yes (indefinitely) |
| URL | '/views/detail.html', https://… |
Yes (5 min TTL) |
| Async function | (ctx) => fetch(...).then(r => r.text()) or return HTMLElement |
No |
Object with render() |
{ render(ctx) { return '<div>…</div>' }, mounted?() } |
No |
| Web component | 'my-view' (string with hyphen, not a key in views) |
No |
| DOM element | document.createElement('div') |
No; cloned on resolution |
View pipeline: Resolve → normalize .z-view → insert into canvas → call mounted() (if present). Static/URL views are cloned on every retrieval, including simultaneous requests. DOM element sources are also cloned; listeners attached directly to the original element are not copied.
Use function views or object views to mount framework components. Register their teardown with onCleanup() so the framework releases its resources when the view is discarded:
React
import { createRoot } from 'react-dom/client'
import Dashboard from './Dashboard'
const app = new Zumly({
mount: '.canvas',
initialView: 'home',
views: {
home: '<div class="z-view"><div class="zoom-me" data-to="dashboard" data-id="42">Open</div></div>',
dashboard: ({ target, props, onCleanup }) => {
const root = createRoot(target)
onCleanup(() => root.unmount())
root.render(<Dashboard id={props.id} />)
}
}
})Vue
import { createApp } from 'vue'
import Dashboard from './Dashboard.vue'
views: {
dashboard: ({ target, props, onCleanup }) => {
const component = createApp(Dashboard, { id: props.id })
onCleanup(() => component.unmount())
component.mount(target)
}
}Svelte
import Dashboard from './Dashboard.svelte'
views: {
dashboard: ({ target, props, onCleanup }) => {
const component = new Dashboard({ target, props: { id: props.id } })
onCleanup(() => component.$destroy())
}
}Angular
views: {
dashboard: ({ target, props, onCleanup }) => {
const compRef = viewContainerRef.createComponent(DashboardComponent)
onCleanup(() => compRef.destroy())
compRef.instance.id = props.id
target.appendChild(compRef.location.nativeElement)
}
}Key points:
- The
targetparameter is a fresh<div>created by Zumly. When a function mounts into it and returns nothing, Zumly preserves that container and all its children. It defaults to the canvas width and height; settarget.style.widthandtarget.style.heightfor a smaller view. propscontains data attributes from the trigger element (data-id="42"→props.id).componentContext(constructor option) is passed ascontextto all function/object views — use it for shared state (router, store, API client).- Function/object views are never cached or prefetched — they render only when requested by navigation. Initial views receive the same context contract, with empty
props. - Call
onCleanup(fn)to register unmount, unsubscribe, or timer cleanup. Callbacks run once when the view is permanently removed, rendering fails, an abandoned async result arrives, or the instance is destroyed. Temporarily detached depth views and kept-alive lateral views retain their resources until discarded. Async callbacks are started without waiting for them. - Use
mounted()(object views) for post-insertion setup — it runs after the node is in the DOM. - Returning an
HTMLElementfrom a function orrender()transfers that resolved view instance to Zumly; preferonCleanup()over relying on DOM removal to unmount framework components.
- Eager preload:
preload: ['viewA', 'viewB']— static HTML and URL views are resolved and cached duringinit(); dynamic views and custom elements are skipped. - Hover prefetch:
mouseoveron a.zoom-me[data-to]trigger prefetches its target in the background. - Focus prefetch:
focusinon a.zoom-me[data-to]also prefetches (for keyboard/accessibility). - Scan prefetch: When a view becomes current, its unique
.zoom-me[data-to]targets enter a background queue. This works on touch devices where hover is unavailable. The queue keeps up to 32 pending targets and starts at most two resolutions concurrently, yielding between starts.
All prefetch strategies skip functions, objects, and custom elements to avoid rendering components speculatively.
Prefetch warms a template without cloning it. Navigation gets an independent clone when it needs the view. New speculative work pauses during navigation and resumes after completion; a requested view can load immediately without waiting for a queue slot. Already-running requests may still finish. Explicit preload remains eager.
The template cache holds up to 64 entries, evicts the least recently used, and expires URL responses after five minutes. Eviction only affects reusable templates: mounted views, form values, history, and keepAlive nodes stay intact. An evicted or unprefetched target resolves normally when visited.
zoomTo(), zoomIn(), goTo(), zoomOut(), and back() return promises that settle after the transition and its completion hooks. Await each call when navigating through several views:
await app.zoomTo('detail')
await app.zoomTo('deep')
await app.back()Calls made while another view is loading or animating are ignored. Failed navigation resolves after recovery and reports the error through Zumly's diagnostics. init() is idempotent, and destroy() releases pending navigation promises and discards late view results.
Generic .zoom-me triggers receive keyboard focus and support Enter/Space. Native buttons and links retain their semantics; navigation controls also support keyboard activation. Arrow navigation ignores text inputs and editable controls. Background views become inert and hidden from assistive technology until active again. Focus moves to the first usable control in the current view, or to the view itself. Zumly uses instant transitions when prefers-reduced-motion: reduce is active.
Zumly has a lightweight plugin system. Register plugins with .use() before or after init():
app.use(plugin, options)A plugin is an object with install(instance, options) or a plain function (instance, options) => void.
Syncs the browser URL hash with Zumly's navigation state. Browser back triggers zoom-out or lateral navigation. Forward is intentionally blocked — in a ZUI, zoom-in requires a trigger element for proper origin and animation context.
// Script tag
const app = new Zumly({ ... })
app.use(Zumly.Router)
await app.init()
// ES Module (named export from the package entry)
import { Zumly, ZumlyRouter } from 'zumly'
const app = new Zumly({ ... })
app.use(ZumlyRouter)
await app.init()The UMD/IIFE bundle attaches the same plugin as Zumly.Router. There is no separate published subpath for the router; import it from 'zumly' or use Zumly.Router on window when using a script tag.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
separator |
string | '/' |
Character used to join view path segments in the hash. |
prefix |
string | '/' |
Prefix before the path in the hash. |
Behavior:
| Action | Hash update | History |
|---|---|---|
| Zoom in | pushState |
Enables browser back |
| Lateral | pushState |
Enables browser back |
| Zoom out | replaceState |
No forward entry |
| Browser back | Triggers zoomOut() or lateral goTo() |
— |
| Browser forward | Blocked (history.back()) |
— |
Example URL: #/home/showcases/mercedes
- No deep-linking: The router plugin syncs hash on navigation and supports browser back, but does not support forward or deep-linking (entering a multi-level URL directly). In a ZUI, zoom-in requires a trigger element for proper spatial context.
- Resize handling: Cheap correction when canvas resizes — translate and origin scaled by ratio; scale preserved. Correction is deferred if a transition is running.
- Remote views: URL-backed views use
innerHTML; sanitize external content to avoid XSS.
- Use Node.js 22+ for development. The distributed package entry points support Node.js >= 18; constructing a Zumly instance requires a browser DOM.
# Build the library
npm run compile
# Build and serve the demo at http://localhost:9090
npm run dev
# Run tests (Vitest + Playwright). Install browsers first:
npm run test:install-browsers
npm run test
# Run the suite in Chromium, Firefox, and WebKit
npm run test:browsers
# Compile and verify the installed npm tarball's public exports
npm run test:pack
# Run tests with coverage
npm run test:coverage
# Build and pack for publish
npm run buildTests use Vitest with the browser provider (Playwright). Run npm run test:install-browsers once (or after upgrading Playwright) to install Chromium, Firefox, and WebKit. npm test defaults to Chromium; BROWSER=firefox or BROWSER=webkit selects an individual engine. Run npm run test:browsers and npm run test:pack locally to check all three engines and the package exports.
npm run compileOutput is in the dist/ folder.
npm run build compiles via prepack and creates a local tarball. It does not commit, push, or publish.
See CHANGELOG.md for version history.
Done:
- Depth and lateral navigation (
zoomIn,zoomOut,goTo,back,zoomTo) - Lateral nav bar (
lateralNav:modeauto/always, arrows, dots,keepAlive,position) - Depth back button (
depthNav:positionbottom-left/top-left) - Inputs toggles (
inputs: wheel, keyboard, click, touch) - Plugin system (
use()), router plugin (hash sync, back, forward blocked) - Resize correction (translate/origin scaling; deferred while transitioning)
- Pluggable drivers (CSS, WAAPI, none, Anime.js, GSAP, Motion, custom)
- Batched zoom-out reads + math helpers to reduce reflow (see geometry-optimization.md)
- Keyboard trigger activation, focus management, inactive-view isolation, and reduced-motion support
- Per-view resource cleanup with
ViewContext.onCleanup()
Planned:
- Router deep-linking (open a multi-level hash cold)
- Further accessibility testing with assistive technologies
Details and more topics: docs/roadMap.md. Driver contract and helpers: docs/DRIVER_API.md.
Zumly is a reimagined, framework-agnostic zoom engine inspired by Zircle UI. Part of the Zumerlab ecosystem — use it with Orbit for radial layouts and SnapDOM for DOM-to-image capture. Use SnapDIFF to compare captures for visual regression testing.
MIT. See LICENSE.