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. The core stack is stable: depth and lateral navigation, pluggable transition drivers (CSS, WAAPI, none, Anime.js, GSAP, Motion, custom), unified nav UI (depth + lateral, eight positions), view resolver and prefetch cache, 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 }. |
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. |
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| 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'.
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 |
View pipeline: Resolve → normalize .z-view → insert into canvas → call mounted() (if present). Static/URL views are cloned from cache on each get() so consumers cannot mutate the stored node.
Zumly is framework-agnostic. Since views resolve to DOM elements, any framework that can mount into a container works out of the box. Use function views or object views to bridge your framework:
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 }) => {
const root = createRoot(target)
root.render(<Dashboard id={props.id} />)
}
}
})Vue
import { createApp } from 'vue'
import Dashboard from './Dashboard.vue'
views: {
dashboard: ({ target, props }) => {
createApp(Dashboard, { id: props.id }).mount(target)
}
}Svelte
import Dashboard from './Dashboard.svelte'
views: {
dashboard: ({ target, props }) => {
new Dashboard({ target, props: { id: props.id } })
}
}Angular
views: {
dashboard: ({ target, props }) => {
const compRef = viewContainerRef.createComponent(DashboardComponent)
compRef.instance.id = props.id
target.appendChild(compRef.location.nativeElement)
}
}Key points:
- The
targetparameter is a fresh<div>created by Zumly — mount your component there. 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 views are never cached — they resolve fresh each time, so framework components get proper lifecycle management.
- Use
mounted()(object views) for post-insertion setup — it runs after the node is in the DOM. - Zumly handles wrapped elements (e.g. Svelte's extra parent div) in its cleanup logic.
- Eager preload:
preload: ['viewA', 'viewB']— those views are resolved and cached duringinit(). - 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, all
.zoom-me[data-to]targets inside it are prefetched in the background. This works on touch devices where hover is unavailable.
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.
- Node.js >= 18 (or 16+ with ES module support)
# 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 tests with coverage
npm run test:coverage
# Build and pack for publish
npm run buildTests use Vitest with the browser provider (Playwright), same setup as SnapDOM. Run npm run test:install-browsers once (or after upgrading Playwright) to install Chromium.
npm run compileOutput is in the dist/ folder.
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)
Planned:
- Router deep-linking (open a multi-level hash cold)
- Accessibility (focus moves, broader ARIA)
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 lightweight DOM diffing.
MIT. See LICENSE.