πŸ—ΊοΈβ€‚Zooming canvas

How do I zoom and pan across one big canvas instead of cutting between slides?

11 min readView as markdown

An atlas puts content on a plane and moves a camera between named framings. One canvas, many views β€” a system diagram you tour, a route, a UI you walk through.

```atlas
scene: ./architecture.svg
---
- id: whole
  at: 0, 0, 4000
- id: ingest
  at: 900, 400, 700
- id: storage
  at: 2600, 1200, 500
  hold: 2
```

Each waypoint is a navigation step, like a fragment: β†’ flies to the next one, and only advances past the last one to the next slide.

The fence

The head is canvas-level options, the body is the waypoints, --- separates them. A fence with no --- is all waypoints, which is the common case.

Head optionWhat it is
sceneA backdrop image or SVG. Waypoint coordinates are its pixels. Required
arcHow far the camera pulls back on the way. Default √2
holdSeconds to rest at each waypoint. Only video and autoplay use it
width, heightThe world grid. Default 1920Γ—1080
Waypoint optionWhat it is
idIts name. Required β€” it is the routing fragment and the export filename
atA framing, or a reference. Required
durationSeconds for the flight in. Omit it and the camera works it out
holdSeconds to rest here
arcThis waypoint’s own arc
labelHuman-readable, for a checklist or a progress rail
chapterA chapter in a .narrocast. React only, see below

scene is a URL the deck serves, exactly as src is for <Image> and ```replay: put the file in the deck’s public directory and name it from there. A fence does not resolve a path relative to the deck file, and it draws no content of its own β€” so an atlas without a scene is a blank slide, and narro check refuses one.

at is a rectangle, not a zoom level

at: 900, 400, 700

means centre on (900, 400) and show 700 units of world across the frame. The height follows from whatever the deck is being rendered at.

That is deliberate, and it is the one thing to understand about this fence. A scale is a ratio against a viewport, so scale: 2 frames different content in a 16:9 deck than in a 9:16 video, and re-flowing the scene silently changes what every waypoint points at. A width is a fact about the world: it survives the render target, the aspect ratio, and the deck being exported at a different size.

The other form is a reference, which the scene resolves:

at: "#ingest-box"

On a plane that is a node id. Over a recording it is a CSS selector, and that is the form to prefer there β€” see Zooming into a recorded app.

Durations are seconds, and you rarely want to set one

Omit duration and the camera computes how long the move wants to take from how far the picture actually travels. A long flight across the plane asks for more time than a small nudge, automatically, and the answer is right far more often than a number you pick per waypoint.

When you do set one, it is seconds, like every duration in @getnarro/core. duration: 1000 is not a one-second flight β€” it is a sixteen-minute one, and narro check says so by name.

Why the camera does not make people ill

The complaint about every zooming presentation tool is the same: the swoop is nauseating. That is not an inevitable property of moving a camera. It is a consequence of how almost every tool interpolates.

Tween the centre and the zoom as two independent curves β€” which is what Prezi, impress.js, Sozi and Narro’s own older TransformSlide all do β€” and the apparent speed is not what the numbers say. Apparent speed is world speed divided by the width of the frame you are seeing it through, so a flight that travels a long way while zooming a long way in starts at about one screen per second and finishes at fifty. The picture accelerates by more than an order of magnitude while the numbers say it is slowing down, the eye cannot track it, and that mismatch is the part that makes people uncomfortable. No easing curve fixes it; easing only moves the peak around.

An atlas uses van Wijk and Nuij’s interpolation instead, which takes the path that keeps perceived speed constant for the whole flight. The lurch is gone by construction rather than by tuning, and the characteristic β€œpull back, travel, drop in” arc β€” the part people actually like β€” comes out of the geometry rather than being authored. arc is how pronounced it is.

Reduced motion is still honoured. A viewer with prefers-reduced-motion: reduce gets a cut between waypoints instead of a flight. Every waypoint stays reachable and nothing is hidden; only the swoop goes.

What narro check will tell you

Camera paths have failure modes that are invisible until somebody watches the deck, and every one of them is decidable before that:

It saysBecause
is a 200.0x zoom in one moveAbove about 8Γ— in one flight, audiences lose their place
Did you mean 1?A duration above 30 is almost always milliseconds in a seconds field
reads as a lurchThe duration is far below what that move needs
will read as stalledThe duration is far above it
has no renderable framingThe third number is missing or not positive
matches nothing in the sceneA reference that resolves to nothing
is used more than onceTwo waypoints share an id

Waypoints that name a reference are not geometry-checked at build time: their rectangles do not exist until something renders the scene, and reporting a failure that cannot be established would be worse than reporting nothing.

Zooming into a recorded app

narro record captures a web app as real DOM, and a camera over one is the case this package exists for. Zooming into a control magnifies live text and vector borders, so it stays sharp β€” and stays selectable β€” at any zoom. A screenshot or a screen recording is a grid of pixels and falls apart at 3Γ—.

This is a React API today, not a fence. A scene has to be handed the document it measures selectors against, and a markdown fence has no way to do that; writing provider: replay in one is an error rather than a blank slide.

import { Atlas } from "@getnarro/atlas/react";
import { measureIn, replayProvider } from "@getnarro/atlas/replay";

const provider = replayProvider({ cast, measure: measureIn(replayDocument) });

<Atlas
  provider={provider}
  path={{
    waypoints: [
      { id: "whole-app", at: { cx: 640, cy: 360, w: 1280 } },
      { id: "the-publish-button", at: "#publish", chapter: "publish" },
    ],
  }}
/>;

Name a selector, not coordinates. A cast’s layout is fixed β€” it was computed once, at the recording viewport, and the player scales rather than reflows β€” so a rectangle around a DOM node is stable, while the coordinates it resolved to last week stop being right the moment somebody re-records and the button moves. findInCast(cast, "#publish") answers the same question with no browser, which is how a build catches a selector that matches nothing.

On a map

Web Mercator is a plane, so a map needs no second camera: project the world onto the unit square and longitude, latitude and zoom become an ordinary framing. The same interpolation, the same checker, the same timeline.

import { Atlas } from "@getnarro/atlas/react";
import { mapProvider } from "@getnarro/atlas/map";

const provider = mapProvider({
  map,                                  // a MapLibre or Mapbox map
  places: {
    reykjavik: { lng: -21.94, lat: 64.15, zoom: 11 },
    iceland: { west: -24.6, south: 63.3, east: -13.5, north: 66.6 },
  },
});

<Atlas provider={provider} path={{ waypoints: [
  { id: "iceland", at: "iceland" },
  { id: "reykjavik", at: "reykjavik" },
] }} />;

maplibre-gl is an optional peer and nothing in this package imports it β€” the map arrives as an object with jumpTo, areTilesLoaded and isStyleLoaded, so a deck with no map pays nothing. Pair it with PMTiles for a basemap in a single file with no API key.

It tells the map where to be rather than asking it to fly. flyTo decides where the camera is from the wall clock, which is exactly what a renderer advancing a frame counter cannot use. A jumpTo per frame, from a camera that is a pure function of time, works in a deck, a video and a page alike β€” and the frame is held until the tiles arrive, so a render never encodes a video of empty squares.

For routes, routeCamera follows a traveller along a line and routeTo gives you the part travelled so far, ending exactly where the traveller is:

import { pointAlong, routeCamera, routeTo } from "@getnarro/atlas/map";

Over a gigapixel image

A camera that flies close to a raster needs pixels the raster does not have. A tile pyramid inverts that: the image is cut at every power-of-two scale in advance, and only the tiles for the current level and region are fetched. A 40000Γ—30000 scan costs the same handful of tiles at every zoom.

import { imageProvider } from "@getnarro/atlas/image";

const scene = imageProvider({
  pyramid: {
    width: 40000,
    height: 30000,
    tileSize: 254,
    overlap: 1,
    url: (level, col, row) => `/scan_files/${level}/${col}_${row}.jpg`,
  },
  regions: { signature: { x: 35000, y: 27000, width: 2000, height: 800 } },
});

The layout is Deep Zoom’s, so anything vips dzsave or OpenSeadragon’s tools already emit works as-is. scene.tiles(view, viewport) is what to draw; tell it noteLoaded(key) and noteFailed(key) as tiles settle, and its readiness gate covers exactly the tiles the current framing needs β€” never the whole pyramid, which would never finish.

On a web page

<script type="module" src="/narro-atlas.js"></script>

<narro-atlas mode="scroll" world="1920x1080">
  <script type="application/json">
    { "waypoints": [
      { "id": "whole", "at": { "cx": 960, "cy": 540, "w": 1920 } },
      { "id": "pricing", "at": "#pricing" }
    ] }
  </script>

  <img src="/diagram.svg" alt="How it fits together">
</narro-atlas>

The camera follows the page’s scroll, which is the third clock after a presenter’s keypress and a renderer’s frame counter β€” and it costs almost nothing, because the camera was already a pure function of a moment.

The page’s own markup is the scene and the fallback: the element slots it rather than copying it, so with JavaScript off a reader sees the whole diagram, laid out and indexed. A waypoint can name any element in it β€” at: "#pricing" resolves the way a fragment link would. mode="steps" swaps scrolling for clicks and arrow keys.

The embed is 8.5 KB, 3.6 KB gzipped, and brings no framework.

In a video

The same path, on a frame counter:

import { AtlasScene } from "@getnarro/atlas/video";

<AtlasScene path={path} nodes={nodes} hold={1.5} />;

The camera is a pure function of the frame β€” no requestAnimationFrame anywhere β€” so a frame rendered on its own, out of order, or in another process comes out identical. atlasDurationInFrames(path, fps) gives a composition its length from the path itself, so a scene’s duration follows its camera rather than a constant somebody has to keep in step.

A scene that loads β€” a recording that has to reach a chapter β€” holds the frame until it is ready, so a render never encodes the wrong screen.

When it earns its keep

When the spatial relationship is the content: a system diagram you tour, a route, a timeline that reads as one long line, a UI you walk through, a zoom from summary into detail and back out.

It is the wrong call for a normal talk. Constant camera movement is tiring to watch and disorienting on a large screen, and audiences lose the thread when they cannot tell where they are. If the connection between two slides is logical rather than spatial, a cut says it better than a swoop. Normal slides for most of the deck, one canvas for the part that genuinely benefits.

Cost

@getnarro/atlas reaches a deck only when it has an atlas fence β€” the generated deck module imports the component on the strength of the compiler having seen one. A deck without a canvas carries nothing.

What it will not do

An export flattens a canvas to one still per waypoint; a fence reaches the plane and no other scene; the camera follows the path and offers no free navigation; there is no rotation; and Narro neither cuts a tile pyramid nor hosts a basemap for you.

Each of those is written out, with what to reach for instead, on Limitations.

Relation to TransformSlide

TransformSlide is the older, smaller version of this: a camera over sub-slides, positioned by x/y/scale, interpolated as independent tweens. It still works and is not going away.

Prefer an atlas for anything new. It is the one with the interpolation that does not lurch, culling so a big scene costs what a small one does, level-of-detail, reduced-motion handling, the checker, and a video host.

IntroductionWhat is Narro, and should I be writing markdown or React?
InstallationWhat do I install, and what does the project look like afterwards?
First DeckWhat does a working React deck look like, end to end?
Markdown ModeHow do I split a slide, style one word, or reveal a line β€” without leaving the markdown file?
Writing AI PromptsWhat do I tell an AI so the deck it writes actually builds?
Rules for AI AgentsWhat do I paste into my repo so an agent stops writing decks that build wrong?
React APIWhich component or hook do I import, and what does it take?
CLIWhich command do I run, and what are its flags?
Markdown APIHow do I read, edit, or validate a deck file from my own code instead of by hand?
Component ReferenceWhat props does this component take, and which package do I import it from?
Verifying a deckHow do I know my deck is actually correct?
AnimationHow do I reveal a list one line at a time, or move between slides with something other than a cut?
NavigationHow does the audience move through the deck, and how do I present it?
Canvas & PositioningHow do I put something at an exact position instead of in the flow?
Images & MediaHow do I use an image as a background, tint it, or embed a video?
ArchitectureWhich package owns what, and why is the seam where it is?
Zooming canvasHow do I zoom and pan across one big canvas instead of cutting between slides?
Transform ModeHow do I zoom and pan across one big canvas instead of cutting between slides?
Import & ExportHow do I get this deck out as PPTX, PDF, or one file I can email?
VideoHow do I turn a story into a video, and keep it right when the words, the product or the source changes?
Recording a web appHow do I put a demo of my product in a deck or on a landing page without shipping a video of it?
Embedding a replayHow do I embed a recorded demo of my app in my React app, my Astro site, or any other page?
TerminalHow do I put a terminal demo in a deck, a README or a video, and find out when the tool it demonstrates stops saying what the demo says?
AI IntegrationHow do I wire an AI assistant up to Narro so it can write and build decks?
ThemingHow do I change the colours, fonts, and overall look of a deck?
Deck TemplatesHow do I define one house style with named layouts my slides can reference, like a PowerPoint master?
TroubleshootingSomething is wrong with my deck. What is it, and how do I fix it?
LimitationsWhat can't Narro do, and what do I do instead?