# Hover Preview

> A list of names that opens a photo at the pointer. It trails the cursor with a little weight, leans as it moves, and wipes to the next photo from the direction you travel, like flipping through prints.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/hover-preview
- Tags: List, Image, Motion
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/hover-preview.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/hover-preview
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/hover-preview.json
```

## Usage

The main demo shows typical usage.

### Default

A studio's work index. Point at a project: its photo opens at the pointer and trails it. Move down the list and each photo wipes in from the top; move up and it comes from the bottom. Sweep fast to flip through them. Tab through the rows to see each photo without a mouse.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/hover-preview-demo
```

```tsx title="components/hover-preview-demo.tsx"
import { HoverPreview, HoverPreviewItem } from "@/components/ui/hover-preview";

const projects = [
  { name: "Northwind", category: "Branding", year: 2025, photo: "1561070791-2526d30994b5" },
  { name: "Lumen Studio", category: "Web", year: 2024, photo: "1618005182384-a83a8bd57fbe" },
  { name: "Atlas Bank", category: "Product", year: 2024, photo: "1460925895917-afdab827c52f" },
  { name: "Field Notes", category: "Editorial", year: 2023, photo: "1455390582262-044cdead277a" },
  { name: "Halden House", category: "Architecture", year: 2023, photo: "1600585154340-be6161a56a0c" },
  { name: "Sona Audio", category: "Campaign", year: 2022, photo: "1505740420928-5e560c06d30e" },
  { name: "Maison Vert", category: "Interiors", year: 2022, photo: "1618221195710-dd6b41faaea6" },
];

const photo = (id: string, width: number, height: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${width}&h=${height}&q=80`;

const slug = (name: string) => name.toLowerCase().replace(/\s+/g, "-");

export function HoverPreviewDemo() {
  return (
    <section className="w-full max-w-3xl">
      <div className="mb-4 flex items-end justify-between gap-4">
        <p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">Selected work</p>
        <p className="font-mono text-xs text-zinc-500 tabular-nums">2022–2025</p>
      </div>

      <HoverPreview className="border-t border-zinc-200 dark:border-zinc-800">
        {projects.map((project) => (
          <HoverPreviewItem
            key={project.name}
            href={`#${slug(project.name)}`}
            image={photo(project.photo, 640, 480)}
            thumbnail={photo(project.photo, 120, 120)}
            className="border-b border-zinc-200 py-4 sm:py-5 dark:border-zinc-800"
          >
            <span className="flex-1 text-2xl font-medium tracking-tight text-zinc-900 sm:text-5xl dark:text-zinc-50">
              {project.name}
            </span>
            <span className="text-xs text-zinc-500 tabular-nums sm:text-sm">
              {project.category} · {project.year}
            </span>
          </HoverPreviewItem>
        ))}
      </HoverPreview>
    </section>
  );
}
```

## Examples

Finished sections built with Hover Preview. Each one installs whole, with the component included.

### Team

Names and roles, with a portrait on hover. The frame is taller and rounder, and leans a little less.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/hover-preview-team-demo
```

```tsx title="components/hover-preview-team-demo.tsx"
import { HoverPreview, HoverPreviewItem } from "@/components/ui/hover-preview";

const people = [
  { name: "Ines Moreau", role: "Creative Director", city: "Paris", photo: "1438761681033-6461ffad8d80" },
  { name: "Rafael Santos", role: "Design Lead", city: "Lisbon", photo: "1507003211169-0a1dd7228f2d" },
  { name: "Mei Tanaka", role: "Motion Designer", city: "Tokyo", photo: "1534528741775-53994a69daeb" },
  { name: "Luca Bianchi", role: "Engineering Lead", city: "Milan", photo: "1500648767791-00dcc994a43e" },
  { name: "Amara Okafor", role: "Strategy Director", city: "Lagos", photo: "1531123897727-8f129e1688ce" },
  { name: "Jonas Weber", role: "Brand Designer", city: "Berlin", photo: "1539571696357-5a69c17a67c6" },
];

const photo = (id: string, width: number, height: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&crop=faces&w=${width}&h=${height}&q=80`;

const slug = (name: string) => name.toLowerCase().replace(/\s+/g, "-");

export function HoverPreviewTeamDemo() {
  return (
    <section className="w-full max-w-2xl">
      <div className="mb-6">
        <p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">The studio</p>
        <h3 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
          Six people, four time zones
        </h3>
      </div>

      <HoverPreview previewClassName="aspect-[4/5] w-48 rounded-xl sm:w-56" tilt={4}>
        {people.map((person) => (
          <HoverPreviewItem
            key={person.name}
            href={`#${slug(person.name)}`}
            image={photo(person.photo, 480, 600)}
            thumbnail={photo(person.photo, 120, 120)}
            thumbnailClassName="size-10 rounded-full"
            className="border-b border-zinc-200 py-4 dark:border-zinc-800"
          >
            <span className="flex-1">
              <span className="block text-xl font-medium tracking-tight text-zinc-900 sm:text-2xl dark:text-zinc-50">
                {person.name}
              </span>
              <span className="mt-0.5 block text-sm text-zinc-500">{person.role}</span>
            </span>
            <span className="font-mono text-xs text-zinc-500">{person.city}</span>
          </HoverPreviewItem>
        ))}
      </HoverPreview>
    </section>
  );
}
```

### Journal

An archive of essays with dates and reading times. Each title shows its cover image in a wide 3:2 frame.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/hover-preview-journal-demo
```

```tsx title="components/hover-preview-journal-demo.tsx"
import { HoverPreview, HoverPreviewItem } from "@/components/ui/hover-preview";

const posts = [
  {
    title: "Designing for the second visit",
    date: "Sep 18, 2026",
    minutes: 6,
    photo: "1497032628192-86f99bcd76bc",
  },
  {
    title: "Motion is a material, not a garnish",
    date: "Aug 29, 2026",
    minutes: 9,
    photo: "1557672172-298e090bd0f1",
  },
  {
    title: "Shipping a design system in six weeks",
    date: "Aug 04, 2026",
    minutes: 12,
    photo: "1519389950473-47ba0277781c",
  },
  {
    title: "A reading list for product designers",
    date: "Jul 15, 2026",
    minutes: 4,
    photo: "1481627834876-b7833e8f5570",
  },
  {
    title: "How we price fixed-scope projects",
    date: "Jun 30, 2026",
    minutes: 7,
    photo: "1499750310107-5fef28a66643",
  },
  {
    title: "Notes on writing interface copy",
    date: "Jun 02, 2026",
    minutes: 5,
    photo: "1455390582262-044cdead277a",
  },
];

const photo = (id: string, width: number, height: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${width}&h=${height}&q=80`;

const slug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-");

export function HoverPreviewJournalDemo() {
  return (
    <section className="w-full max-w-3xl">
      <div className="mb-4 flex items-end justify-between gap-4">
        <h3 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">Journal</h3>
        <p className="font-mono text-xs text-zinc-500 tabular-nums">{posts.length} essays</p>
      </div>

      <HoverPreview
        previewClassName="aspect-[3/2] w-64 rounded-md sm:w-80"
        className="border-t border-zinc-200 dark:border-zinc-800"
      >
        {posts.map((post) => (
          <HoverPreviewItem
            key={post.title}
            href={`#${slug(post.title)}`}
            image={photo(post.photo, 720, 480)}
            thumbnail={photo(post.photo, 120, 120)}
            className="items-baseline gap-6 border-b border-zinc-200 py-5 dark:border-zinc-800"
            thumbnailClassName="self-center"
          >
            <time className="hidden w-24 shrink-0 font-mono text-xs text-zinc-500 tabular-nums sm:block">
              {post.date}
            </time>
            <span className="flex-1 text-lg font-medium tracking-tight text-pretty text-zinc-900 sm:text-2xl dark:text-zinc-50">
              {post.title}
            </span>
            <span className="shrink-0 font-mono text-xs text-zinc-500 tabular-nums">
              {post.minutes} min
            </span>
          </HoverPreviewItem>
        ))}
      </HoverPreview>
    </section>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `image` (required) | `string` |  | `HoverPreviewItem`: the photo that opens while the row is pointed at or focused. Every photo is fetched once the list nears the screen, and each shows only once it's decoded, so a wipe never shows a blank frame. |
| `href` | `string` |  | `HoverPreviewItem`: where the row links. Each row is an `<a>`, and other anchor props pass through to it. |
| `thumbnail` | `string` |  | `HoverPreviewItem`: a smaller copy of `image`. Touch screens can't hover, so they show it as a thumbnail in the row instead. Defaults to `image`. Devices that can hover never download it. |
| `thumbnailClassName` | `string` |  | `HoverPreviewItem`: classes for the touch-screen thumbnail, which defaults to `size-12 rounded-md`. |
| `className` | `string` |  | `HoverPreviewItem`: classes for the row, which defaults to `flex items-center gap-4`. While another row shows, its direct children fade to 30% and the row's own border stays put; change that with `data-[state=dimmed]:*:opacity-50`. `HoverPreview` also takes `className` and other `div` props for its wrapper, and the photo stays inside that box. |
| `previewClassName` | `string` | `"aspect-[4/3] w-72 rounded-lg"` | `HoverPreview`: size, shape and rounding of the floating photo. |
| `tilt` | `number` | `5` | `HoverPreview`: the most degrees the photo leans while it moves, toward the way it's going. It straightens as it catches up. `0` keeps it upright. |

## Source

```tsx title="components/ui/hover-preview.tsx"
"use client";

import * as React from "react";
import {
  animate,
  motion,
  useReducedMotion,
  useSpring,
  useTransform,
  useVelocity,
  type AnimationPlaybackControlsWithThen,
  type MotionValue,
} from "motion/react";

import { cn } from "@/lib/utils";

/* ─────────────────────────────────────────────────────────
 * HOVER PREVIEW STORYBOARD
 *
 *  rest    plain text: a list of names and nothing else.
 *          Photos download quietly once the list nears the
 *          screen, so the first hover doesn't wait
 *  enter   point at a row: its photo opens at the pointer,
 *          growing from 80% as a mask opens out from the
 *          pointer, in 0.3s. The other rows fade to 30%
 *  follow  the photo trails the pointer on a spring, a beat
 *          behind, and leans up to 5° the way it's moving.
 *          It straightens as it catches up
 *  switch  point at another row: the photo stays, and the
 *          next one wipes over it from the way you moved:
 *          down the list, from the top; up, from the bottom.
 *          Inside the wipe it settles from a slight zoom.
 *          Moving fast stacks wipes like flipping prints
 *  leave   the photo shrinks and closes into the point it
 *          opened from, and every row comes back
 *  focus   Tab to a row: its photo opens over the row's
 *          middle and glides from row to row
 *  touch   no hover, so each row shows a thumbnail instead
 *  reduce  the photo opens where you point and stays put;
 *          rows crossfade, nothing trails or leans
 *
 *  The photo is one element moved by a transform from motion
 *  values, so moving the pointer never renders React. Opens
 *  and wipes run as WAAPI animations. A photo joins the stack
 *  only once it's downloaded and decoded, so a wipe never
 *  shows a blank frame.
 * ───────────────────────────────────────────────────────── */

const EASE_OUT = [0.23, 1, 0.32, 1] as const;

const PREVIEW = {
  follow: { stiffness: 150, damping: 18, mass: 0.6 }, // trails by about 0.13s at speed, with almost no overshoot
  lean: { stiffness: 220, damping: 24 }, // smooths the tilt so it eases upright as the photo slows
  leanSpeed: 1000, // px/s of travel for the full tilt
  grow: 0.8, // scale the photo opens from and closes to
  zoom: 1.15, // scale a wiped-in photo settles from
  open: { duration: 0.3, ease: EASE_OUT },
  close: { duration: 0.2, ease: EASE_OUT },
  wipe: { duration: 0.5, ease: EASE_OUT },
  settle: { duration: 0.7, ease: EASE_OUT }, // the zoom inside a wipe, a little longer than the wipe
  fade: { duration: 0.2, ease: "easeOut" }, // reduced motion
  stack: 4, // most photos layered at once while wipes overlap
} as const;

const OPEN = "inset(0% 0% 0% 0%)";
// Where a wipe starts: a sliver at the edge the new photo comes from.
const WIPE = { top: "inset(0% 0% 100% 0%)", bottom: "inset(100% 0% 0% 0%)" };

type Edge = keyof typeof WIPE;
type Point = { x: number; y: number };
type RowState = "idle" | "active" | "dimmed";
type Stack = { push: (src: string, edge: Edge) => void; clear: () => void };

// Photos fetched ahead, by URL, shared by every list on the page. Holding the
// element keeps the download in memory, so an <img> with the same URL is
// complete the moment it mounts.
const photos = new Map<string, { image: HTMLImageElement; decoded?: Promise<void> }>();

function fetchPhoto(src: string) {
  let photo = photos.get(src);
  if (!photo) {
    const image = new Image();
    image.fetchPriority = "low";
    image.src = src;
    photo = { image };
    photos.set(src, photo);
  }
  return photo;
}

/** Resolves once `src` is downloaded and decoded, or has failed to. */
function decodePhoto(src: string) {
  const photo = fetchPhoto(src);
  photo.decoded ??= photo.image.decode().catch(() => {});
  return photo.decoded;
}

const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);

/** Clamps a position to `0…max`, or centers it when there's no room. */
const within = (value: number, max: number) => (max <= 0 ? max / 2 : clamp(value, 0, max));

const percent = (part: number, whole: number) =>
  whole > 0 ? Math.round(clamp(part / whole, 0, 1) * 1000) / 10 : 50;

/**
 * Everything that changes while the pointer moves lives here, outside React:
 * which row is showing, where the photo is headed, and whether it's open. Rows
 * subscribe to their own state, so only the rows that change render.
 */
function createPreview(x: MotionValue<number>, y: MotionValue<number>) {
  const rows = new Map<string, { element: HTMLElement; image: string }>();
  const listeners = new Set<() => void>();

  let root: HTMLElement | null = null;
  let plane: HTMLElement | null = null; // the list's box, which the photo moves within
  let frame: HTMLElement | null = null;
  let stack: Stack | null = null;
  let reduce = false;
  let active: string | null = null;
  let last: string | null = null; // kept while closing, so a quick return wipes from the right side
  let source: "pointer" | "keyboard" | null = null;
  let anchor: Point = { x: 0, y: 0 }; // where the photo is centered, in client px
  let bounds = { left: 0, top: 0, width: 0, height: 0 };
  let size = { width: 0, height: 0 };
  let phase: "closed" | "open" | "closing" = "closed";
  let shut = "inset(50% 50% 50% 50%)"; // a clip shrunk to the anchor: opens from it, closes to it
  let shutter: AnimationPlaybackControlsWithThen | null = null;
  let settled = false; // fully open, so the origin can move without a jump
  let request = 0; // bumps on every change, so a photo that loads late can't show for a row already left

  const setActive = (id: string | null) => {
    if (id === active) return;
    active = id;
    for (const listener of listeners) listener();
  };

  // Read once per row change and on scroll, never per pointer move.
  const measure = () => {
    if (plane) bounds = plane.getBoundingClientRect();
    if (frame) size = { width: frame.offsetWidth, height: frame.offsetHeight };
  };

  // Centers the photo on the anchor, kept inside the list.
  const place = (instant: boolean) => {
    const left = within(anchor.x - bounds.left - size.width / 2, bounds.width - size.width);
    const top = within(anchor.y - bounds.top - size.height / 2, bounds.height - size.height);
    if (instant) {
      x.jump(left);
      y.jump(top);
    } else {
      x.set(left);
      y.set(top);
    }
  };

  // Closed, the photo waits exactly where it will open. Open, it trails the
  // anchor; with reduced motion it stays where it opened.
  const follow = () => {
    if (phase === "closed") place(true);
    else if (!reduce) place(false);
  };

  // Points the open or close at the anchor: where it falls on the photo,
  // clamped to its edges. Mid-list that's the middle; near the list's edges
  // the photo is held in, and when leaving the pointer is already past it.
  const aim = (element: HTMLElement) => {
    const ox = percent(anchor.x - bounds.left - x.get(), size.width);
    const oy = percent(anchor.y - bounds.top - y.get(), size.height);
    element.style.transformOrigin = `${ox}% ${oy}%`;
    shut = `inset(${oy}% ${100 - ox}% ${100 - oy}% ${ox}%)`;
  };

  const open = () => {
    const element = frame;
    if (!element) return;
    const from = phase;
    phase = "open";
    settled = false;

    if (reduce) {
      shutter = animate(
        element,
        from === "closed"
          ? { opacity: [0, 1], clipPath: [OPEN, OPEN], transform: ["scale(1)", "scale(1)"] }
          : { opacity: 1 },
        PREVIEW.fade,
      );
      return;
    }

    let animation: AnimationPlaybackControlsWithThen;
    if (from === "closed") {
      aim(element);
      animation = animate(
        element,
        {
          opacity: [1, 1],
          clipPath: [shut, OPEN],
          transform: [`scale(${PREVIEW.grow})`, "scale(1)"],
        },
        PREVIEW.open,
      );
    } else {
      // Caught while closing: open again from wherever it got to.
      animation = animate(element, { clipPath: OPEN, transform: "scale(1)" }, PREVIEW.open);
    }
    shutter = animation;
    animation.then(() => {
      if (shutter === animation) settled = true;
    });
  };

  const close = () => {
    const element = frame;
    if (!element) return;
    phase = "closing";
    // Shrink toward where the pointer left. Mid-open, keep the origin it's
    // growing from, or the photo would jump.
    if (settled && !reduce) aim(element);
    settled = false;
    const animation = (shutter = reduce
      ? animate(element, { opacity: 0 }, PREVIEW.fade)
      : animate(element, { clipPath: shut, transform: `scale(${PREVIEW.grow})` }, PREVIEW.close));
    animation.then(() => {
      if (shutter !== animation || phase !== "closing") return;
      phase = "closed";
      stack?.clear();
    });
  };

  const show = (id: string, point: Point, from: "pointer" | "keyboard") => {
    const row = rows.get(id);
    if (!row) return;
    const previous = rows.get(active ?? last ?? "");
    source = from;
    anchor = point;
    measure();
    // Reduced motion holds the photo still, but it can't be left behind: keyboard
    // focus moves it to each row, and a row more than a photo's height away
    // (down a long list, or after a scroll) brings it over. Both are jumps.
    if (reduce && phase !== "closed") {
      const far = Math.abs(anchor.y - bounds.top - y.get() - size.height / 2) > size.height;
      if (from === "keyboard" || far) place(true);
    } else {
      follow();
    }
    setActive(id);
    last = id;

    const edge: Edge =
      previous && previous.element.compareDocumentPosition(row.element) & Node.DOCUMENT_POSITION_PRECEDING
        ? "bottom"
        : "top";
    const ticket = ++request;
    decodePhoto(row.image).then(() => {
      if (ticket !== request) return;
      stack?.push(row.image, edge);
      if (phase !== "open") open();
    });
  };

  const hide = () => {
    request += 1;
    source = null;
    setActive(null);
    if (phase === "open") close();
  };

  return {
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
    stateOf(id: string): RowState {
      return active === null ? "idle" : active === id ? "active" : "dimmed";
    },
    register(id: string, element: HTMLElement | null, image: string) {
      if (!element) return;
      rows.set(id, { element, image });
      return () => {
        rows.delete(id);
        if (active === id) hide();
      };
    },
    setReduce(value: boolean) {
      reduce = value;
    },
    enter(id: string, event: React.PointerEvent) {
      if (event.pointerType === "touch") return;
      show(id, { x: event.clientX, y: event.clientY }, "pointer");
    },
    move(event: React.PointerEvent) {
      if (source !== "pointer" || event.pointerType === "touch") return;
      anchor = { x: event.clientX, y: event.clientY };
      follow();
    },
    leave(event: React.PointerEvent) {
      if (source !== "pointer") return;
      anchor = { x: event.clientX, y: event.clientY };
      hide();
    },
    focus(id: string, element: HTMLElement) {
      if (!element.matches(":focus-visible")) return;
      const rect = element.getBoundingClientRect();
      show(id, { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, "keyboard");
    },
    blur(event: React.FocusEvent) {
      if (source !== "keyboard") return;
      if (event.relatedTarget instanceof Node && root?.contains(event.relatedTarget)) return;
      hide();
    },
    /** Hands over the elements the preview drives, and starts listening. */
    connect(elements: {
      root: HTMLElement | null;
      plane: HTMLElement | null;
      frame: HTMLElement | null;
      stack: Stack | null;
    }) {
      ({ root, plane, frame, stack } = elements);

      // The page can scroll under a resting pointer: keep the photo under it.
      const refit = () => {
        if (source !== "pointer") return;
        measure();
        follow();
      };
      window.addEventListener("scroll", refit, { capture: true, passive: true });
      window.addEventListener("resize", refit);

      // Fetch every photo once the list nears the screen. Screens that can't
      // hover never show them, so they skip it.
      let observer: IntersectionObserver | undefined;
      if (root && window.matchMedia("(hover: hover)").matches) {
        observer = new IntersectionObserver(
          (entries) => {
            if (!entries.some((entry) => entry.isIntersecting)) return;
            observer?.disconnect();
            for (const row of rows.values()) fetchPhoto(row.image);
          },
          { rootMargin: "50% 0px" },
        );
        observer.observe(root);
      }

      return () => {
        window.removeEventListener("scroll", refit, { capture: true });
        window.removeEventListener("resize", refit);
        observer?.disconnect();
        root = plane = frame = stack = null;
      };
    },
  };
}

type Preview = ReturnType<typeof createPreview>;

const PreviewContext = React.createContext<Preview | null>(null);

function usePreview() {
  const preview = React.useContext(PreviewContext);
  if (!preview) throw new Error("HoverPreviewItem must be used inside HoverPreview.");
  return preview;
}

export interface HoverPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Size, shape and rounding of the floating photo. */
  previewClassName?: string;
  /** Most degrees the photo leans while it moves. `0` keeps it upright. */
  tilt?: number;
}

/**
 * A list of `HoverPreviewItem` rows. Pointing at a row opens its photo at the
 * pointer; the photo stays inside this element's box.
 */
export function HoverPreview({
  previewClassName,
  tilt = 5,
  className,
  children,
  onPointerMove,
  onPointerLeave,
  ...props
}: HoverPreviewProps) {
  const reduce = useReducedMotion() ?? false;
  const x = useSpring(0, PREVIEW.follow);
  const y = useSpring(0, PREVIEW.follow);
  const lean = useSpring(
    useTransform(useVelocity(x), (velocity) => clamp(velocity / PREVIEW.leanSpeed, -1, 1) * tilt),
    PREVIEW.lean,
  );
  const [preview] = React.useState(() => createPreview(x, y));
  const rootRef = React.useRef<HTMLDivElement>(null);
  const planeRef = React.useRef<HTMLDivElement>(null);
  const frameRef = React.useRef<HTMLDivElement>(null);
  const stackRef = React.useRef<Stack>(null);

  React.useEffect(() => preview.setReduce(reduce), [preview, reduce]);
  React.useEffect(
    () =>
      preview.connect({
        root: rootRef.current,
        plane: planeRef.current,
        frame: frameRef.current,
        stack: stackRef.current,
      }),
    [preview],
  );

  return (
    <PreviewContext.Provider value={preview}>
      <div
        ref={rootRef}
        className={cn("relative", className)}
        onPointerMove={(event) => {
          onPointerMove?.(event);
          preview.move(event);
        }}
        onPointerLeave={(event) => {
          onPointerLeave?.(event);
          preview.leave(event);
        }}
        {...props}
      >
        <ul role="list">{children}</ul>
        <div ref={planeRef} aria-hidden className="pointer-events-none absolute inset-0 z-10">
          <motion.div
            className="absolute top-0 left-0 will-change-transform"
            style={{ x, y, rotate: reduce ? 0 : lean }}
          >
            <div
              ref={frameRef}
              className={cn("relative aspect-[4/3] w-72 overflow-hidden rounded-lg", previewClassName)}
              style={{ clipPath: "inset(50% 50% 50% 50%)", transform: `scale(${PREVIEW.grow})` }}
            >
              <PhotoStack ref={stackRef} reduce={reduce} />
            </div>
          </motion.div>
        </div>
      </div>
    </PreviewContext.Provider>
  );
}

export interface HoverPreviewItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
  /** The photo that opens while this row is pointed at or focused. */
  image: string;
  /** A smaller copy for the thumbnail touch screens show instead. Defaults to `image`. */
  thumbnail?: string;
  /** Classes for the touch-screen thumbnail. It's hidden on screens that can hover. */
  thumbnailClassName?: string;
}

/**
 * One row: a link. Lay out its contents with `className`. While another row
 * is showing, this row's direct children fade, so a border on the row itself
 * stays put; style that with `data-[state=dimmed]:*:…`.
 */
export function HoverPreviewItem({
  image,
  thumbnail,
  thumbnailClassName,
  className,
  children,
  onPointerEnter,
  onFocus,
  onBlur,
  ...props
}: HoverPreviewItemProps) {
  const preview = usePreview();
  const id = React.useId();
  const ref = React.useRef<HTMLAnchorElement>(null);
  const state = React.useSyncExternalStore(
    preview.subscribe,
    () => preview.stateOf(id),
    () => "idle",
  );

  React.useLayoutEffect(() => preview.register(id, ref.current, image), [preview, id, image]);

  return (
    <li>
      <a
        {...props}
        ref={ref}
        data-state={state}
        className={cn(
          "flex items-center gap-4",
          // Dim slowly, light up fast.
          "*:transition-opacity *:duration-300 *:ease-[cubic-bezier(0.23,1,0.32,1)] data-[state=active]:*:duration-150 data-[state=dimmed]:*:opacity-30",
          className,
        )}
        onPointerEnter={(event) => {
          onPointerEnter?.(event);
          preview.enter(id, event);
        }}
        onFocus={(event) => {
          onFocus?.(event);
          preview.focus(id, event.currentTarget);
        }}
        onBlur={(event) => {
          onBlur?.(event);
          preview.blur(event);
        }}
      >
        <img
          src={thumbnail ?? image}
          alt=""
          loading="lazy"
          decoding="async"
          draggable={false}
          className={cn(
            "hidden size-12 shrink-0 rounded-md object-cover [@media(hover:none)]:block",
            thumbnailClassName,
          )}
        />
        {typeof children === "string" ? <span>{children}</span> : children}
      </a>
    </li>
  );
}

type Layer = { key: number; src: string; edge: Edge | null };

/**
 * The photos inside the frame, newest on top. The first one is revealed by the
 * frame opening; every later one wipes in over the last, and once it covers
 * them they're dropped.
 */
function PhotoStack({ ref, reduce }: { ref: React.Ref<Stack>; reduce: boolean }) {
  const [layers, setLayers] = React.useState<Layer[]>([]);
  const nextKey = React.useRef(0);

  React.useImperativeHandle(
    ref,
    () => ({
      push(src, edge) {
        const key = ++nextKey.current;
        setLayers((current) => {
          if (current.at(-1)?.src === src) return current;
          return [...current, { key, src, edge: current.length ? edge : null }].slice(-PREVIEW.stack);
        });
      },
      clear() {
        setLayers([]);
      },
    }),
    [],
  );

  const covered = React.useCallback((key: number) => {
    setLayers((current) => {
      const index = current.findIndex((layer) => layer.key === key);
      return index > 0 ? current.slice(index) : current;
    });
  }, []);

  return layers.map((layer, index) => (
    <Photo key={layer.key} layer={layer} bottom={index === 0} reduce={reduce} onCovered={covered} />
  ));
}

function Photo({
  layer,
  bottom,
  reduce,
  onCovered,
}: {
  layer: Layer;
  bottom: boolean;
  reduce: boolean;
  onCovered: (key: number) => void;
}) {
  const ref = React.useRef<HTMLDivElement>(null);
  const imageRef = React.useRef<HTMLImageElement>(null);
  const wipe = React.useRef<AnimationPlaybackControlsWithThen | null>(null);

  // Starts before paint, so the photo never shows a frame un-wiped.
  React.useLayoutEffect(() => {
    const element = ref.current;
    const image = imageRef.current;
    if (!layer.edge || !element || !image) return;
    let stopped = false;
    const animation = reduce
      ? animate(element, { opacity: [0, 1] }, PREVIEW.fade)
      : animate(element, { clipPath: [WIPE[layer.edge], OPEN] }, PREVIEW.wipe);
    const zoom = reduce
      ? null
      : animate(image, { transform: [`scale(${PREVIEW.zoom})`, "scale(1)"] }, PREVIEW.settle);
    wipe.current = animation;
    animation.then(() => {
      if (!stopped) onCovered(layer.key);
    });
    return () => {
      stopped = true;
      animation.stop();
      zoom?.stop();
    };
  }, [layer, reduce, onCovered]);

  // Pushed to the bottom by a full stack mid-wipe: nothing is under it now,
  // so finish the wipe at once rather than show the empty frame.
  React.useEffect(() => {
    if (bottom) wipe.current?.complete();
  }, [bottom]);

  return (
    <div
      ref={ref}
      className="absolute inset-0"
      style={layer.edge ? (reduce ? { opacity: 0 } : { clipPath: WIPE[layer.edge] }) : undefined}
    >
      <img ref={imageRef} src={layer.src} alt="" draggable={false} className="size-full object-cover" />
    </div>
  );
}
```
