Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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-viewport
import { 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

PropTypeDefaultDescription
childrenReactNodeThe focal content to zoom/pan. Should fill the viewport (h-full w-full).
apiRefRef<MapViewportHandle>Imperative zoom handle for menus/sheets to drive the viewport.
ariaLabelstring"Zoomable map"Accessible label on the role="group" viewport.
hideControlsbooleanfalseHide the zoom control cluster and the % readout.
maxScalenumber4Maximum zoom scale.
minScalenumber1Minimum zoom scale (must be >= 1).
zoomStepnumber1.4Multiplicative step for the +/ buttons and zoomIn/zoomOut.
classNamestringMerged onto the root <div>.
refRef<HTMLDivElement>Forwarded to the root viewport <div>.

MapViewportHandle

The shape exposed through apiRef — call these to drive zoom from outside the component.

MethodSignatureDescription
reset() => voidReturn to minScale at the origin (no pan).
zoomIn() => voidZoom in one zoomStep toward the viewport center.
zoomOut() => voidZoom out one zoomStep toward the viewport center.
zoomToFit() => voidIf 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 <= minScale the view snaps to the origin and panning is disabled.
  • The 4px drag 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 140ms ease runs only when not dragging and not under reduced-motion — prefers-reduced-motion and active drag both drop to transition: none.
  • Controls hide via hideControls when the host supplies its own chrome (a toolbar, context menu, or Sheet driving apiRef). The built-in cluster is + / / reset plus a monospace tabular-nums % readout.

Storybook

Composites/MapViewport ↗