# Zoom Spotlight

> An animated zoom spotlight for React and shadcn, pushing in on one region of live UI and dimming the rest.

Pushes in on a region of live UI the way a screen recording does: the view scales and pans on the smooth spring until the target sits centred, the rest dims behind a cutout that follows its corners, and it springs back when you let go. It never zooms past the edges and follows the target through resizes.

- Tier: Free (MIT)
- Interaction: Select
- Page: https://eased.dev/components/zoom-spotlight
- Installs: motion
- Registry: https://eased.dev/r/zoom-spotlight.json

## Install

```bash
npx shadcn add https://eased.dev/r/zoom-spotlight.json
```

Pushes in on one region of live UI the way a screen recording does, and dims the rest behind a cutout. Built on Motion and the Product demo set's target model (eased-demo-core).

## When to use

- The payoff moment of a product demo: the result a click produced.
- Drawing the eye to one card, row or chart in a dense screen for a few seconds.
- Recording a launch clip from the page itself, with the push-in done in the browser.

## When not to use

- A tour the person steps through, with a tooltip and Back and Next: use [Spotlight Tour](/components/spotlight-tour).
- Letting someone inspect detail on demand: use a [Dialog](/components/dialog) or [Image Compare](/components/image-compare).
- Highlighting something the person is about to operate: zooming moves it under their pointer.

## Usage

```tsx example.tsx
import { Card, CardContent } from "@/components/eased/card/card";
import { ZoomSpotlight } from "@/components/eased/zoom-spotlight/zoom-spotlight";

export function Example({ live }: { live: boolean }) {
  return (
    <ZoomSpotlight active={live} target="result" className="h-80">
      <div className="grid h-full grid-cols-2 gap-4 p-4">
        <Card>
          <CardContent>Usage</CardContent>
        </Card>
        <Card data-demo="result">
          <CardContent>Deployed to fathom.app</CardContent>
        </Card>
      </div>
    </ZoomSpotlight>
  );
}
```

Give the frame a size; it clips what the zoom pushes out of view.

## Examples

### Driven by a timeline

```tsx
const { step } = useDemoTimeline(STEPS, { endStep: "zoom", observe: stage });

<ZoomSpotlight active={step === "zoom"} target="result" className="min-h-0 flex-1">…</ZoomSpotlight>
```

### Light without zooming

`scale={1}` keeps the view still and only dims around the target.

```tsx
<ZoomSpotlight active target={rowRef} scale={1}>…</ZoomSpotlight>
```

## API reference

### ZoomSpotlight

| Prop | Type | Default | Description |
|---|---|---|---|
| `active` | `boolean` | | Zoom onto the target and dim the rest; off springs back. |
| `target` | `DemoTarget \| null` | | A ref, or a `data-demo` name inside the children. |
| `scale` | `number` | `1.6` | The most it zooms. It zooms less when the target would not fit. |
| `padding` | `number` | `8` | Space between the target and the edge of the lit area, in px. |
| `dim` | `boolean` | `true` | Dim everything outside the target. |
| `className` | `string` | `""` | Classes for the frame; give it a size. |
| `children` | `ReactNode` | | The UI to zoom. |

A [Demo Cursor](/components/demo-cursor) or [Click Ripple](/components/click-ripple) placed among the children zooms with the content, like a cursor in a recording.

## Accessibility

- The zoom is a transform and the dim is an `aria-hidden` layer: nothing in the content changes for assistive tech.
- Dimmed content is still there; if it must not be operated while lit, make the region `inert`.

## Motion

Scale and pan ride the `smooth` spring (no overshoot), so the target settles centred and the view never shows past its own edges. The dim is the scrim colour behind a rounded cutout concentric with the target's corner; it fades in over 300ms on the enter curve and out in 150ms on the exit curve as the view springs back. A resize mid-zoom retargets the same spring. Under reduced motion it never zooms; the dim fades in and out in 100ms.

## Notes for AI

Use Zoom Spotlight for the moment a demo should land on: wrap the stage in it, mark the result with `data-demo`, and set `active` from a `useDemoTimeline` step. Keep the target smaller than the frame (a card, not a full-width row), or there is nothing to zoom into.

Do not compute transforms or cutouts yourself, and do not nest a second scrim. It already measures through its own scale, follows resizes and keeps the cutout for the fade out.

## Related

- [Spotlight Tour](/components/spotlight-tour): a guided tour with steps and a tooltip (Pro).
- [Demo Cursor](/components/demo-cursor): points at what the zoom lands on.
- [Product demo set](/sets/product-demo): the pieces on one timeline.

## Example

`components/eased/zoom-spotlight/zoom-spotlight-demo.tsx`

```tsx
"use client";

import { useRef } from "react";
import { useDemoTimeline } from "../_primitives/demo-core/demo-core";
import { commits } from "../_primitives/fixtures/devtools";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { ZoomSpotlight } from "./zoom-spotlight";

/** Whole view, push in on latency, back out, light the latest deploy, back out. */
const STEPS = [
  { id: "overview", duration: 900 },
  { id: "latency", duration: 2200 },
  { id: "back", duration: 900 },
  { id: "deploy", duration: 2200 },
  { id: "rest", duration: 900 },
] as const;

const TILES = [
  { id: "requests", label: "Requests", value: "1.28M", note: "+6.2% this week" },
  { id: "latency", label: "p95 latency", value: "184 ms", note: "−22 ms since Monday" },
  { id: "errors", label: "Error rate", value: "0.21%", note: "Within budget" },
] as const;

const fmt = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" });

/**
 * Fathom's usage overview: the view pushes in on p95 latency, holds, springs back to the whole
 * page, then lights the latest deploy. Each target is a `data-demo` name, so it keeps working
 * at any width. It loops while on screen; Pause holds it.
 */
export function ZoomSpotlightDemo() {
  const stage = useRef<HTMLDivElement>(null);
  const timeline = useDemoTimeline(STEPS, { endStep: "latency", observe: stage });
  const { step } = timeline;
  const target = step === "latency" ? "latency" : step === "deploy" ? "latest" : null;

  return (
    <DemoFrame
      title="fathom/web · Usage"
      actions={
        <Button variant="ghost" size="sm" edge="end" onClick={timeline.playing ? timeline.pause : timeline.play}>
          {timeline.playing ? "Pause" : "Play"}
        </Button>
      }
    >
      <div ref={stage} inert className="relative flex min-h-0 flex-1 flex-col">
        <ZoomSpotlight active={target !== null} target={target} className="min-h-0 flex-1">
          <div className="flex h-full flex-col gap-4 p-(--pad)">
            <div className="grid grid-cols-3 gap-2">
              {TILES.map((t) => (
                <div key={t.id} data-demo={t.id} className="flex min-w-0 flex-col gap-1 rounded-panel border border-line-subtle bg-surface p-3">
                  <p className="truncate text-ui-xs text-text-muted">{t.label}</p>
                  <p className="truncate text-ui-md font-medium text-text tabular-nums">{t.value}</p>
                  <p className="truncate text-ui-xs text-text-muted">{t.note}</p>
                </div>
              ))}
            </div>
            <section className="flex min-h-0 flex-col gap-1.5">
              <p className="text-ui-sm text-text-muted">Deploys</p>
              <ul className="flex flex-col divide-y divide-line-subtle rounded-panel border border-line-subtle">
                {commits.slice(0, 3).map((c, i) => (
                  <li key={c.sha} data-demo={i === 0 ? "latest" : undefined} className="flex h-11 items-center gap-3 px-3">
                    <span aria-hidden className={`size-1.5 shrink-0 rounded-pill ${i === 0 ? "bg-success" : "bg-text-muted"}`} />
                    <span className="min-w-0 flex-1 truncate text-ui-sm text-text">{c.message}</span>
                    <span className="shrink-0 font-mono text-ui-xs text-text-muted slashed-zero">{c.sha}</span>
                    <span className="w-16 shrink-0 text-right text-ui-xs text-text-muted tabular-nums">{fmt.format(c.at)}</span>
                  </li>
                ))}
              </ul>
            </section>
          </div>
        </ZoomSpotlight>
      </div>
    </DemoFrame>
  );
}
```
