A pan and zoom viewport for images, SVG, and any DOM content — shared across Astro, Svelte, and React from one framework-agnostic core. It supports mouse-drag and touch panning, pinch and wheel zooming, keyboard navigation, a live zoom readout, fit modes, fullscreen, and an optional minimap.
Drag to pan, Ctrl/⌘ + scroll (or pinch) to zoom, and double-click to reset. Focus the viewport for full keyboard control: arrow keys pan, +/- zoom, and 0 resets. The built-in controls zoom in/out, reset, and toggle fullscreen.
---
// GENERATED from packages/demos/pan-zoom/basic.demo.tsx — edit the source, not this file.
import PanZoom from "@pindoba/astro-pan-zoom";
import Content from "./_content.astro";
---
<PanZoom>
<Content />
</PanZoom>Set maxZoom to bound how far the user can zoom in. The view always opens fitted to the content with padding.
---
// GENERATED from packages/demos/pan-zoom/image.demo.tsx — edit the source, not this file.
import PanZoom from "@pindoba/astro-pan-zoom";
import Content from "./_content.astro";
---
<PanZoom maxZoom={3}>
<Content />
</PanZoom>Any DOM works as content, including inline SVG. Use minZoom/maxZoom to frame the useful zoom range.
---
// GENERATED from packages/demos/pan-zoom/svg.demo.tsx — edit the source, not this file.
import PanZoom from "@pindoba/astro-pan-zoom";
---
<PanZoom minZoom={0.5} maxZoom={4}>
<svg
width="480"
height="320"
viewBox="0 0 480 320"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="480" height="320" fill="#1e293b"></rect>
<circle cx="160" cy="160" r="90" fill="#38bdf8"></circle>
<rect x="260" y="80" width="140" height="140" rx="16" fill="#f472b6"></rect>
<path
d="M40 280 L240 40 L440 280 Z"
fill="none"
stroke="#fde047"
stroke-width="4"
></path>
</svg>
</PanZoom>fitMode controls how the view lays content out on open and reset: contain (default, fit with padding), cover (fill the viewport), or actual-size (1:1). Enable showZoomIndicator for a live zoom-percentage readout.
---
// GENERATED from packages/demos/pan-zoom/fit-modes.demo.tsx — edit the source, not this file.
import PanZoom from "@pindoba/astro-pan-zoom";
import Content from "./_content.astro";
---
<PanZoom fitMode="cover" showZoomIndicator>
<Content />
</PanZoom>showMinimap adds an overview in the corner with a draggable rectangle marking the visible region — drag it to pan large content quickly.
---
// GENERATED from packages/demos/pan-zoom/minimap.demo.tsx — edit the source, not this file.
import PanZoom from "@pindoba/astro-pan-zoom";
import Content from "./_content.astro";
---
<PanZoom showMinimap showZoomIndicator>
<Content />
</PanZoom>The zoom level is controllable and observable. In Svelte use bind:zoom; in React pass zoom + onZoomChange; in Astro seed the initial level with the zoom prop. onTranslationChange and onFullscreenChange are also available.
---
// GENERATED from packages/demos/pan-zoom/controlled.demo.tsx — edit the source, not this file.
import PanZoom from "@pindoba/astro-pan-zoom";
import Content from "./_content.astro";
---
<PanZoom zoom={1.5} showZoomIndicator>
<Content />
</PanZoom>For editors built on pan-zoom (a photo crop, an annotator), every framework exposes the headless controller and the pan translation, so you can read the current framing and correct it:
controllerRef and onReady(controller), plus a controlled translation paired with onTranslationChange.bind:controller, onReady, and a two-way bind:translation (left unset, it adopts the fitted framing).onReady, the template ref’s controller, and a controlled translation paired with onTranslationChange.controllerRef (a callback that receives the controller) and onReady(controller), plus a controlled translation paired with onTranslationChange.translation seeds the initial framing; page scripts get the controller from getPanZoomController(root) or the pan-zoom:ready event (@pindoba/astro-pan-zoom/runtime).onReady fires once, after the first fit with real viewport dimensions, so $scale / $translate already hold the actual framing. The controller’s setTranslate, panBy, center, fitToBounds, and setZoom then drive it.
translation is the content’s top-left offset inside the viewport (the content uses transform-origin: 0 0). With constrainToBounds, the content can be panned exactly to each edge and no further: an axis where the scaled content is larger than the viewport is clamped to [viewport − content × scale, 0], and an axis where it is smaller is held centered. An out-of-range translation (like the “Bottom-right edge” button’s { x: -1e6, y: -1e6 }) lands exactly on the nearest edge.
---
// GENERATED from packages/demos/pan-zoom/framing.demo.tsx — edit the source, not this file.
import Button from "@pindoba/astro-button";
import PanZoom from "@pindoba/astro-pan-zoom";
import { css } from "@pindoba/styled-system/css";
import { flex, stack } from "@pindoba/styled-system/patterns";
import type { HeadlessPanZoom, Point } from "@pindoba/core-pan-zoom";
const cell = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "120px",
height: "120px",
fontSize: "lg",
fontWeight: "bold",
color: "neutral.text.bold",
borderWidth: "1px",
borderColor: "neutral.border",
borderStyle: "solid",
backgroundColor: "neutral.surface.peak",
});
const grid = css({
display: "grid",
gridTemplateColumns: "repeat(6, 120px)",
backgroundImage:
"linear-gradient(135deg, token(colors.primary.accent.surface.peak), token(colors.neutral.surface.ground))",
});
const cells = Array.from({ length: 24 }, (_v, i) => i + 1);
const readout = css({
fontSize: "sm",
fontVariantNumeric: "tabular-nums",
color: "neutral.text.muted",
});
const controller = undefined as HeadlessPanZoom | undefined;
const translation = undefined as Point | undefined;
const zoom = 1;
---
<div
class={stack({ gap: "sm", width: "full" })}
data-ir-root="pan-zoom/framing"
>
<PanZoom
fitMode="cover"
constrainToBounds
showZoomIndicator
translation={undefined as Point | undefined}
zoom={1}
data-ir="1"
>
<div class={grid}>
<!--ir:2-->
{cells.map((n) => (
<div class={cell} data-ir-key={n}>
<!--ir:3-->
{n}
<!--/ir-->
</div>
))}
<!--/ir-->
</div>
</PanZoom>
<div class={flex({ gap: "xs", wrap: "wrap", align: "center" })}>
<Button size="sm" emphasis="secondary" data-ir="4">
Top-left edge
</Button>
<Button
size="sm"
emphasis="secondary"
disabled={controller === undefined}
data-ir="5"
>
Center
</Button>
<Button size="sm" emphasis="secondary" data-ir="6">
Bottom-right edge
</Button>
<span class={readout}>
x <!--ir:7-->
{Math.round(translation?.x ?? 0)}
<!--/ir--> · y <!--ir:8-->
{Math.round(translation?.y ?? 0)}
<!--/ir--> · <!--ir:9-->
{Math.round(zoom * 100)}
<!--/ir-->%
</span>
</div>
</div>
<script>
import { hydrate } from "./_client/framing.ts";
document
.querySelectorAll('[data-ir-root="pan-zoom/framing"]')
.forEach((root) => hydrate([root]));
</script>A layout change (a window resize, the mobile URL bar, a side panel opening) keeps the user’s framing. By default (resizeBehavior="preserve"), a view the user hasn’t touched is fitted again to the new size. Once someone has panned or zoomed, the content point at the centre of the viewport stays at the centre at the same zoom, and constrainToBounds clamps the result so no empty band appears. Keeping the centre means both edges gain or lose the same amount, so the subject of a crop stays in place and the zoom the user picked doesn’t change. Set resizeBehavior="refit" for a passive preview that should always fit the whole content again, even after a pan or zoom.
A pan-zoom mounted while hidden (an inactive tab, a collapsed panel) measures 0×0. Its first fit waits until the component becomes visible, and a zoom or translation you passed still applies at that point.
An auto-height pan-zoom gets its height from a 220px viewport floor, set through the --pan-zoom-viewport-min-height CSS variable. There is no size prop: to make it taller, raise the floor (--pan-zoom-viewport-min-height: 480px) or give the component a height. When you size the component yourself (a fixed-aspect crop frame, a full-height pane), set it to 0 on the component or any ancestor. Images and SVGs inside the content render at their intrinsic size, and your own width, height, and object-fit always win: the component only undoes a global max-width: 100% reset, which would break the fit math.
The viewport is a focusable region with an accessible label. Once focused, arrow keys pan, +/- zoom, and 0 resets. Smooth zoom transitions are skipped automatically under prefers-reduced-motion.
---
// GENERATED from packages/demos/pan-zoom/keyboard.demo.tsx — edit the source, not this file.
import PanZoom from "@pindoba/astro-pan-zoom";
import Content from "./_content.astro";
---
<PanZoom showZoomIndicator label="Keyboard-navigable diagram">
<Content />
</PanZoom>Pan Zoom is card-optional: it renders the bare canvas by default, and a card object frames it in a Card. The canvas becomes a framed viewer:
card.heading / card.subheading / card.eyebrow / card.leading fill the card header, and card.trailing holds the viewer’s toolbar actions (an export button, a status badge). In Astro, rich header content goes in the card-trailing (and other card-*) slots.card.footer adds an optional footer (a revision note, a legend).content.padding: "none"), and it fills the card: unlike an inset panel, which normally hugs its content, the canvas region grows into whatever height the header and footer leave. Give the component a height (style="height: 380px", a sized parent) and the whole card takes it; with no height, the viewport’s 220px floor applies.The pan-zoom surface props (background, border, radius, shadow, tone, emphasis) become the card’s defaults, so a framed viewer keeps the bare look, with a divided hairline under the header. A rendered heading labels the viewport (aria-labelledby) unless you set label. The viewport keeps its role="group", its keyboard shortcuts and its pointer math (measured on the viewport, never the card).
---
// GENERATED from packages/demos/pan-zoom/card.demo.tsx — edit the source, not this file.
import Button from "@pindoba/astro-button";
import PanZoom from "@pindoba/astro-pan-zoom";
import FloorPlan from "./_floor-plan.astro";
import { css } from "@pindoba/styled-system/css";
const grid = css({
display: "grid",
gap: "md",
gridTemplateColumns: { base: "1fr", lg: "1fr 1fr" },
});
---
<div class={grid}>
<PanZoom
showMinimap
showZoomIndicator
class={css({ height: "380px" })}
card={{
heading: "Warehouse B",
subheading: "Level 2 · 15 bays",
footer: { leading: "Revised 3 hours ago" },
}}
>
<Button slot="card-trailing" size="sm" emphasis="secondary">
Export
</Button>
<FloorPlan />
</PanZoom>
<PanZoom
class={css({ height: "380px" })}
card={{
heading: "Loading docks",
subheading: "Inset content",
background: "surface.base",
contentBackground: "surface.peak",
}}
>
<FloorPlan />
</PanZoom>
</div>Set contentBackground (or card.content.background) to lift the canvas onto an inset panel inside the shell, as in the second viewer above. The canvas still fills the panel, and the panel’s rounded corners clip it.
card={false}Without a card prop (or with card={false}) Pan Zoom renders exactly the pre-card DOM: the container (data-component="pan-zoom") is the root. When framed, the root is the Card (data-component="card", data-card-kind="pan-zoom", data-pan-zoom-frame), and your id, class, style and other HTML attributes land on it. The container, with every data-* behaviour attribute and the Astro boot marker, sits inside the card content, and passThrough.container still reaches it. passThrough.card reaches the Card’s parts.
Script access works with either element. getPanZoomController(el), the data-pan-zoom-ready flag and the pan-zoom:ready event are mirrored onto the card root, so document.getElementById("my-viewer") keeps working when you add a card. The bindable element (Svelte) still points at the container.
Every slot is restylable through passThrough, and behavior is tunable via wheelZoomSpeed, pinchSensitivity, constrainToBounds, and more.
---
// GENERATED from packages/demos/pan-zoom/custom-config.demo.tsx — edit the source, not this file.
import PanZoom from "@pindoba/astro-pan-zoom";
import Content from "./_content.astro";
---
<PanZoom
wheelZoomSpeed={0.15}
maxZoom={8}
constrainToBounds
passThrough={{
container: {
style: { borderColor: "primary.border.accent", borderRadius: "lg" },
},
controls: { style: { bottom: "lg", right: "lg" } },
}}
>
<Content />
</PanZoom>