MapViewport
Zoom + pan chrome around any focal content — wheel/pinch and button zoom toward the cursor, drag-pan past a threshold, clamped so the scaled content never reveals empty edges.
Import
MapViewport is a registry composite — you own the source after installing:
npx ploxum add map-viewportimport { MapViewport, type MapViewportHandle } from "@/components/map-viewport";Depends on @roger-emerson/ploxum-icons (the control-cluster glyphs) and @roger-emerson/ploxum-utils (cn, usePrefersReducedMotion).
Usage
Wrap a single focal child — an SVG floor plan, venue map, or canvas — in a fixed-height, overflow-hidden container. MapViewport fills its parent (h-full w-full), so the parent owns the box.
<div className="h-[360px] w-full overflow-hidden rounded-sm border border-[var(--ploxum-color-border-subtle)]">
<MapViewport ariaLabel="North concourse floor plan">
<svg className="h-full w-full" viewBox="0 0 320 200" preserveAspectRatio="xMidYMid meet">
{/* floor-plan geometry, clickable zones, etc. */}
</svg>
</MapViewport>
</div>Scroll/pinch zooms toward the pointer; drag pans once the cursor moves past 4px. Clicks under that threshold pass straight through to the child, so clickable map zones stay clickable.
Driving zoom externally
Pass an apiRef to let a context menu, Sheet, or toolbar button drive the viewport. The handle exposes reset, zoomToFit, zoomIn, and zoomOut.
import { useRef } from "react";
import { MapViewport, type MapViewportHandle } from "@/components/map-viewport";
function VenueMap() {
const api = useRef<MapViewportHandle>(null);
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<button type="button" onClick={() => api.current?.zoomToFit()}>
Fit
</button>
<button type="button" onClick={() => api.current?.reset()}>
Reset
</button>
</div>
<div className="h-[360px] w-full overflow-hidden rounded-sm border border-[var(--ploxum-color-border-subtle)]">
<MapViewport apiRef={api} ariaLabel="Venue floor plan" hideControls>
<svg className="h-full w-full" viewBox="0 0 320 200">
{/* … */}
</svg>
</MapViewport>
</div>
</div>
);
}hideControls drops the built-in cluster when the host supplies its own chrome.
Props
MapViewport
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The focal content to zoom/pan. Should fill the viewport (h-full w-full). |
apiRef | Ref<MapViewportHandle> | — | Imperative zoom handle for menus/sheets to drive the viewport. |
ariaLabel | string | "Zoomable map" | Accessible label on the role="group" viewport. |
hideControls | boolean | false | Hide the zoom control cluster and the % readout. |
maxScale | number | 4 | Maximum zoom scale. |
minScale | number | 1 | Minimum zoom scale (must be >= 1). |
zoomStep | number | 1.4 | Multiplicative step for the +/− buttons and zoomIn/zoomOut. |
className | string | — | Merged onto the root <div>. |
ref | Ref<HTMLDivElement> | — | Forwarded to the root viewport <div>. |
MapViewportHandle
The shape exposed through apiRef — call these to drive zoom from outside the component.
| Method | Signature | Description |
|---|---|---|
reset | () => void | Return to minScale at the origin (no pan). |
zoomIn | () => void | Zoom in one zoomStep toward the viewport center. |
zoomOut | () => void | Zoom out one zoomStep toward the viewport center. |
zoomToFit | () => void | If already zoomed, resets; otherwise zooms to ~60% of maxScale. |
Design rules
- It is chrome around a map, never the map itself. MapViewport owns only zoom and pan — the child owns all content, geometry, and hit-testing. This mirrors ploxum's core principle: wrap the venue map, never absorb it.
- Pan is clamped on both axes so the scaled content never reveals empty space past the edges. At
scale <= minScalethe view snaps to the origin and panning is disabled. - The
4pxdrag threshold preserves click-through: a press that moves less than the threshold reaches the content underneath (a clickable map zone), while a real pan suppresses the trailing click so it doesn't fire on release. - The map-focus transition is one of the two sanctioned cinematic motion flourishes. The
140msease runs only when not dragging and not under reduced-motion —prefers-reduced-motionand active drag both drop totransition: none. - Controls hide via
hideControlswhen the host supplies its own chrome (a toolbar, context menu, or Sheet drivingapiRef). The built-in cluster is+/−/ reset plus a monospace tabular-nums%readout.
