reelcn
GitHubBack to landing

Screen zoom

Zooms and pans across whatever you wrap so a rectangle of the content (a button, a chart, a card) fills the frame, eased between keyframes.

ScreenZoom, detail
ScreenZoom, detail thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/screen-zoom.json

Files land in src/reelcn/ with the items it builds on. Dependencies: remotion.

Usage

<BrowserWindow url="app.dev">
  <ScreenZoom
    focus={[
      { frame: 0, x: 0, y: 0, width: 100, height: 100 },
      { frame: 40, x: 55, y: 30, width: 30, height: 24 },
    ]}
  >
    <Dashboard />
  </ScreenZoom>
</BrowserWindow>

Props

PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse duration for an unrelated local timing. See that item's own prop docs.
exitnumber | booleanExit at the end of the parent `<Sequence>`. false keeps it on screen, a number sets the exit length in frames. Default false.
motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to false.
holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to 0.
focus*ScreenZoomFocus[]
childrenReactNode
styleCSSProperties
classNamestring

Built from

Use

  • Pushing in on a detail inside a `browser-window`, `app-window` or dashboard mockup
  • A guided tour that visits several parts of the same screen in turn

Avoid

  • Panning and zooming over a photo or footage with pixel offsets. Use camera

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/screen-zoom.tsx
/**
 * @title Screen Zoom
 * @category product
 * @description Zooms and pans across whatever you wrap so a rectangle of the content (a button, a chart, a card) fills the frame, eased between keyframes.
 * @duration data-driven
 * @use Pushing in on a detail inside a `browser-window`, `app-window` or dashboard mockup
 * @use A guided tour that visits several parts of the same screen in turn
 * @avoid Panning and zooming over a photo or footage with pixel offsets. Use `camera`
 * @tags zoom, pan, focus, screen, product, tour
 * @example
 * <BrowserWindow url="app.dev">
 *   <ScreenZoom
 *     focus={[
 *       { frame: 0, x: 0, y: 0, width: 100, height: 100 },
 *       { frame: 40, x: 55, y: 30, width: 30, height: 24 },
 *     ]}
 *   >
 *     <Dashboard />
 *   </ScreenZoom>
 * </BrowserWindow>
 */
import type React from "react";
import { AbsoluteFill } from "remotion";
import {
  type AnchorRect,
  anchorToContentPercent,
  type MotionProps,
  type PoseKey,
  requireAnchor,
  useKeyframePath,
  useMotion,
} from "./core";

export type ScreenZoomFocus =
  | {
      /** Frame the frame finishes arriving here, counted from `delay`. */
      frame: number;
      /** Rectangle to fill the canvas with, in % of the content (0–100 on each axis). */
      x: number;
      y: number;
      width: number;
      height: number;
    }
  | {
      frame: number;
      /**
       * A kit anchor's rect instead of a literal one, lets a tour visit several kit anchors in turn.
       * Anchors are always canvas-% (§2); this `ScreenZoom`'s own `x/y/width/height` above is
       * content-% (relative to whatever it wraps). `contentBox` names this `ScreenZoom`'s own
       * canvas-relative rect so the anchor can be converted correctly; omit it only when this
       * `ScreenZoom` fills the entire canvas (the identity conversion is then a no-op).
       */
      target: { anchors: Record<string, AnchorRect>; id: string; contentBox?: AnchorRect };
    };

/**
 * Timing comes from `focus`. Of the motion props, `delay` shifts every keyframe and `motion` picks the easing
 * between them; `exit` is off by default so the zoom holds its last keyframe.
 */
export type ScreenZoomProps = MotionProps & {
  focus: ScreenZoomFocus[];
  children?: React.ReactNode;
  style?: React.CSSProperties;
  className?: string;
};

const REST: { frame: number; x: number; y: number; width: number; height: number } = {
  frame: 0,
  x: 0,
  y: 0,
  width: 100,
  height: 100,
};

const CANVAS_BOX: AnchorRect = { x: 0, y: 0, width: 100, height: 100 };

export function ScreenZoom({ focus, exit = false, children, style, className, ...motion }: ScreenZoomProps) {
  const m = useMotion({ ...motion, exit });
  // Resolves each keyframe's rect independently, in this same render pass, from whatever `anchors` map
  // the caller already produced this frame, no reference held past this line, no publishing. A `target`
  // resolves via `requireAnchor` (throws on a missing id, same contract every other consumer uses) and is
  // converted from canvas-% to this ScreenZoom's own content-% via `contentBox` (identity when omitted).
  const rectOf = (p: ScreenZoomFocus) =>
    "target" in p
      ? anchorToContentPercent(
          requireAnchor(p.target.anchors, p.target.id, "ScreenZoom"),
          p.target.contentBox ?? CANVAS_BOX,
        )
      : p;
  const points = focus.slice().sort((a, b) => a.frame - b.frame);
  const keys: PoseKey[] = points.map((p) => {
    const rect = rectOf(p);
    return { frame: m.delay + p.frame, x: rect.x, y: rect.y, width: rect.width, height: rect.height };
  });
  const path = useKeyframePath(keys, { motion: m.preset });
  const rect: { frame: number; x: number; y: number; width: number; height: number } =
    points.length > 0
      ? { frame: path.frame, x: path.x, y: path.y, width: path.width ?? 100, height: path.height ?? 100 }
      : REST;

  // Percentage translate is resolved against this element's own box, so the math holds whether ScreenZoom
  // fills the whole canvas or sits nested inside a smaller frame like `browser-window`.
  const scale = Math.min(100 / Math.max(rect.width, 1), 100 / Math.max(rect.height, 1));
  const cx = rect.x + rect.width / 2;
  const cy = rect.y + rect.height / 2;

  return (
    <AbsoluteFill className={className} style={{ overflow: "hidden", opacity: 1 - m.exit, ...style }}>
      <AbsoluteFill
        style={{ transformOrigin: "50% 50%", transform: `scale(${scale}) translate(${50 - cx}%, ${50 - cy}%)` }}
      >
        {children}
      </AbsoluteFill>
    </AbsoluteFill>
  );
}

On this page