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.
- List
- Image
- Motion
Uses motion, clsx, tailwind-merge
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>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/hover-preview.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/hover-previewInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
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> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
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.
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.
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 |
|---|---|---|---|
imageRequired | string | None | 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 | None | HoverPreviewItem: where the row links. Each row is an <a>, and other anchor props pass through to it. |
thumbnail | string | None | 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 | None | HoverPreviewItem: classes for the touch-screen thumbnail, which defaults to size-12 rounded-md. |
className | string | None | 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. |
imageRequiredType
stringHoverPreviewItem: 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.hrefType
stringHoverPreviewItem: where the row links. Each row is an<a>, and other anchor props pass through to it.thumbnailType
stringHoverPreviewItem: a smaller copy ofimage. Touch screens can't hover, so they show it as a thumbnail in the row instead. Defaults toimage. Devices that can hover never download it.thumbnailClassNameType
stringHoverPreviewItem: classes for the touch-screen thumbnail, which defaults tosize-12 rounded-md.classNameType
stringHoverPreviewItem: classes for the row, which defaults toflex items-center gap-4. While another row shows, its direct children fade to 30% and the row's own border stays put; change that withdata-[state=dimmed]:*:opacity-50.HoverPreviewalso takesclassNameand otherdivprops for its wrapper, and the photo stays inside that box.previewClassNameType
stringDefault"aspect-[4/3] w-72 rounded-lg"HoverPreview: size, shape and rounding of the floating photo.tiltType
numberDefault5HoverPreview: the most degrees the photo leans while it moves, toward the way it's going. It straightens as it catches up.0keeps it upright.