Canvas Zoom

The CSS zoom scale applied to the canvas. Lets the user fit a 1280px-wide breakpoint preview into a smaller editor column, or shrink the responsive canvas to see more vertical content at once. Defaults to fit-to-window on first load (Fra…

The CSS zoom scale applied to the canvas. Lets the user fit a 1280px-wide breakpoint preview into a smaller editor column, or shrink the responsive canvas to see more vertical content at once. Defaults to fit-to-window on first load (Framer/Figma-style), persisted thereafter via phStorage (localStorage).

Two atoms, one component

AtomUsed inStorage key
BreakpointZoomAtomdesktop view + breakpoint views (sm/md/lg/xl/2xl)ph-editor-breakpoint-zoom (+ -fit)
DeviceZoomAtommobile view with Device toggle on (phone bezel)ph-editor-device-zoom (+ -fit)

atoms.ts:

function readSavedZoom(key: string, fallback: number): number {
  if (typeof window === "undefined") return fallback;
  try {
    const raw = localStorage.getItem(`ph-${key}`);
    if (raw === null) return fallback;
    const n = parseFloat(raw);
    return Number.isFinite(n) && n >= 0.25 && n <= 2 ? n : fallback;
  } catch {
    return fallback;
  }
}

export const DeviceZoomAtom = atom("deviceZoom", readSavedZoom("editor-device-zoom", 0.75));
export const BreakpointZoomAtom = atom(
  "breakpointZoom",
  readSavedZoom("editor-breakpoint-zoom", 0.75)
);

Both atoms hydrate from ph-editor-*-zoom on module load. 0.75 is the fallback when no value is saved — but on first load, fitToWindow defaults to true, so the actual zoom is recomputed from window size before any render frame the user sees.

The same <CanvasZoom> component drives both. It picks up which atom and which storage key to use via props — see CanvasZoom.tsx.

How zoom is applied to the canvas

Viewport/Viewport.tsx (formerly ViewportShell.tsx):

const breakpointActive = !device && isEditorCanvasBreakpointView(view);
const canvasZoomActive = !device && (view === "desktop" || isEditorCanvasBreakpointView(view));
const deviceStyles: React.CSSProperties =
  device && view === "mobile"
    ? ({
        width: `${deviceDimensions.width + bezelX}px`,
        height: `${deviceDimensions.height + bezelY}px`,
        zoom: deviceZoom,
        "--device-zoom-inverse": 1 / deviceZoom,
      } as React.CSSProperties)
    : canvasZoomActive && breakpointZoom !== 1
      ? ({ zoom: breakpointZoom } as React.CSSProperties)
      : {};
  • Device-mobile: zoom: deviceZoom + sets a --device-zoom-inverse CSS var (used by overlays/toolbars that need to render at unscaled size).
  • Desktop or any breakpoint view: zoom: breakpointZoom. Zoom is only applied when breakpointZoom !== 1 to avoid the perf cost of a needless scale transform.
  • Otherwise: no styles → no zoom.

zoom (not transform: scale) — chosen because layout/scrollbar math stays intact, and Chromium-based rendering inside CraftJS treats it correctly. It does NOT propagate getBoundingClientRect to children proportionally, which is why we cache --device-zoom-inverse for overlay positioning.

CanvasZoom component

CanvasZoom.tsx — UI primitive used by both the header dropdown and the in-canvas device toolbar. Props:

interface CanvasZoomProps {
  zoomAtom: any; // BreakpointZoomAtom or DeviceZoomAtom
  fitMode: FitMode; // how fit-to-window is calculated
  activeKey: string; // scopes Ctrl/+/-/0 keyboard shortcuts
  disabled?: boolean;
  storageKey?: string; // phStorage key root
}

fitMode is the calc spec for the "Fit to window" option:

type FitMode =
  | { kind: "height"; target: number; chromeOffset?: number; max?: number }
  | { kind: "width"; target: number; chromeOffset?: number; max?: number };

// fit zoom = clamp(0.25, max, (window.innerSize - chromeOffset) / target)

The current call sites:

WhereAtomfitModestorageKey
Header dropdown · device-mobileDeviceZoomAtom{ height: deviceDimensions.height, chromeOffset: 350 }editor-device-zoom
Header dropdown · desktop/breakpointBreakpointZoomAtom{ width: EDITOR_CANVAS_BREAKPOINT_PX[view] ?? 1024, chromeOffset: 420, max: 1 }editor-breakpoint-zoom
Device-mobile in-canvas toolbarDeviceZoomAtom{ height: deviceDimensions.height, chromeOffset: 350 }editor-device-zoom

The breakpoint fit is capped at max: 1 — fluid canvas should never zoom past 1.0 from a fit calc (zooming in would make the canvas larger than its container).

UI controls

[ − ] [ 75% ▼ ] [ + ] [ ↺ ]
  • / + — step through zoomPresets (25% → 200%, 13 stops).
  • 75% ▼ — opens preset dropdown + "Fit to window" entry at the bottom.
  • — reset to 100% (zoom=1, fit=false). Disabled when already at 100% and not in fit mode.

Keyboard shortcuts (when the corresponding instance is mounted):

  • Ctrl/Cmd + = / + — zoom in
  • Ctrl/Cmd + - — zoom out
  • Ctrl/Cmd + 0 — reset to 100%

Shortcuts are scoped via a data-canvas-zoom-<activeKey> attribute on the component root and an activeSelector query inside the keydown handler — ensures only the currently-mounted instance handles the keys.

Persistence

Each <CanvasZoom> writes two keys to phStorage on every change:

  • <storageKey> — the zoom value as a string (e.g. "0.75")
  • <storageKey>-fit"true" or "false"

Persistence happens inside persistZoom(value, fit) which is called from:

  • handleZoomChange — preset clicks, +/- buttons (via handler)
  • The fit-to-window useEffect — every recompute (resize / mount)
  • Keyboard shortcuts — Ctrl/Cmd +/-/0
  • The "Fit to window" dropdown entry click

First load behavior

CanvasZoom.tsx:49-56:

const [fitToWindow, setFitToWindow] = useState(() => {
  if (!storageKey || typeof window === "undefined") return true;
  const savedFit = phStorage.get(`${storageKey}-fit`);
  if (savedFit === "true") return true;
  if (savedFit === "false") return false;
  return true;
});

No saved -fit key → default true → fit calc fires on mount → zoom is overwritten with the fit value before paint. So the user sees a properly fit canvas on first ever load, never the literal 0.75 fallback (unless they previously set a manual zoom and -fit is "false").

Trigger button — icon vs percentage

Header.tsx:386-427:

<span className="inline-flex h-4 min-w-6 items-center justify-center px-0.5">
  {isScaled ? (
    <span className="font-mono text-[11px] leading-none font-bold tracking-tight">
      {Math.round(activeZoom * 100)}%
    </span>
  ) : (
    <>
      {view === "mobile" && <TbDeviceMobile />}
      {(view === "desktop" || view === "tablet") && <TbDeviceDesktop />}
      {isEditorCanvasBreakpointView(view) && (
        <span>{view === "2xl" ? "2XL" : view.toUpperCase()}</span>
      )}
    </>
  )}
</span>
  • isScaled = Math.abs(activeZoom - 1) > 0.001 (epsilon to avoid float flicker at exactly 1).
  • activeZoom = device && view === "mobile" ? deviceZoom : breakpointZoom.
  • When scaled, the percentage replaces the device/breakpoint indicator — one or the other, never both. Falls back to icon at 100%.

Gotchas

  • zoom does not equal transform: scale. Don't try to swap them. getBoundingClientRect behaves differently, scrollbar math differs, and iframes inside the canvas care about zoom specifically.
  • --device-zoom-inverse is set on the device-mobile wrapper for overlays that need to ignore the parent zoom. Used by the device frame notch, scrollbar, and floating preview-edit button.
  • Fit-to-window is a re-calc, not a one-shot. The useEffect listens to window.resize while fitToWindow is true. Every resize re-fires the fit calc and re-persists.
  • The 0.25 ≤ n ≤ 2 clamp in readSavedZoom is intentional. Anything outside that range is treated as corrupt storage and falls back to 0.75.
  • Math.abs(activeZoom - 1) > 0.001 — don't use activeZoom !== 1. Fit-to-window can land on 0.9999… due to integer pixel math, and the trigger would show "100%" but isScaled would be true.
  • Two atoms, not one. Zoom out the breakpoint view → device-mobile zoom is unaffected (different sane defaults). If you ever unify them, make sure the in-canvas device toolbar still gets the right value.