"use client";
import * as React from "react";
import { motion, useMotionValue, useSpring, useTransform, useVelocity } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export interface HoverImageListItem {
id: string;
title: string;
/** Small mono line on the right, e.g. "Branding · 2026". */
meta?: string;
/** Image URL or data URI revealed while the row is hovered or focused. */
image: string;
/** Rows with an href render links; rows without one render buttons that call `onSelect`. */
href?: string;
}
export interface HoverImageListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
items: HoverImageListItem[];
/** Width of the floating image in px. */
imageWidth?: number;
/** Aspect ratio of the floating image (width / height), e.g. "4/5". */
ratio?: "4/5" | "1/1" | "16/10";
/** Spring the image uses to follow the pointer. */
spring?: Spring;
/** Maximum tilt in degrees, driven by horizontal pointer velocity. */
maxRotate?: number;
/** Crossfade between row images, in ms. */
crossfade?: number;
/** Dim the other rows while one is active. */
dimOthers?: boolean;
/** Show a zero-padded index at the start of each row. */
showIndex?: boolean;
/** Accent for the underline, arrow and focus ring. */
accent?: string;
/** Called when a row without an href is activated. */
onSelect?: (item: HoverImageListItem) => void;
}
const RATIOS = { "4/5": 4 / 5, "1/1": 1, "16/10": 16 / 10 } as const;
function subscribeMedia(query: string) {
return (cb: () => void) => {
const mq = window.matchMedia(query);
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
};
}
const subscribeReducedMotion = subscribeMedia("(prefers-reduced-motion: reduce)");
const subscribeNoHover = subscribeMedia("(hover: none)");
/** SSR-safe media queries: hydrate as "no preference" / "can hover", then follow the device. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function useNoHover() {
return React.useSyncExternalStore(subscribeNoHover, () => window.matchMedia("(hover: none)").matches, () => false);
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
/**
* An editorial index list. Hovering a row reveals a floating image that follows the pointer on a
* spring, tilts with horizontal velocity and crossfades between rows. Keyboard focus (and reduced
* motion) anchors the image beside the active row instead; touch screens get inline thumbnails.
*/
export function HoverImageList({
items,
imageWidth = 240,
ratio = "4/5",
spring = { stiffness: 220, damping: 24, mass: 0.6 },
maxRotate = 8,
crossfade = 280,
dimOthers = true,
showIndex = true,
accent = "#FF7448",
onSelect,
className = "",
style,
onPointerLeave,
...props
}: HoverImageListProps) {
const reduceMotion = usePrefersReducedMotion();
const noHover = useNoHover();
const rootRef = React.useRef<HTMLDivElement>(null);
const visibleRef = React.useRef(false);
const [activeId, setActiveId] = React.useState<string | null>(null);
const imageHeight = Math.round(imageWidth / RATIOS[ratio]);
// Pointer target (raw) and the sprung position the image actually renders at.
const rawX = useMotionValue(0);
const rawY = useMotionValue(0);
const x = useSpring(rawX, spring);
const y = useSpring(rawY, spring);
// Tilt from horizontal velocity, smoothed by a second spring.
const velocityX = useVelocity(x);
const tilt = useTransform(velocityX, (v) => (reduceMotion ? 0 : clamp(v / 90, -maxRotate, maxRotate)));
const rotate = useSpring(tilt, { stiffness: 300, damping: 30 });
/** Root rect plus the scale of any transformed ancestor (e.g. a zoomed preview stage). */
const frame = () => {
const root = rootRef.current;
if (!root) return null;
const rect = root.getBoundingClientRect();
const scale = root.offsetWidth ? rect.width / root.offsetWidth || 1 : 1;
return { rect, scale, width: root.offsetWidth };
};
const place = (left: number, top: number, instant: boolean) => {
rawX.set(left);
rawY.set(top);
if (instant) {
x.jump(left);
y.jump(top);
}
};
/** Centre the image on the pointer. Jumps there when it was hidden, so it never flies in from a corner. */
const follow = (clientX: number, clientY: number) => {
const f = frame();
if (!f) return;
const localX = (clientX - f.rect.left) / f.scale;
const localY = (clientY - f.rect.top) / f.scale;
place(localX - imageWidth / 2, localY - imageHeight / 2, !visibleRef.current);
};
/** Anchor the image beside a row: right of centre, vertically centred on the row. */
const anchor = (row: HTMLElement, instant: boolean) => {
const f = frame();
if (!f) return;
const left = clamp(f.width * 0.64 - imageWidth / 2, 0, Math.max(0, f.width - imageWidth));
const top = row.offsetTop + row.offsetHeight / 2 - imageHeight / 2;
place(left, top, instant || !visibleRef.current);
};
const activate = (id: string) => {
setActiveId(id);
visibleRef.current = true;
};
const deactivate = () => {
setActiveId(null);
visibleRef.current = false;
};
const interactive = !noHover;
const visible = interactive && activeId !== null;
const activeIndex = items.findIndex((item) => item.id === activeId);
return (
<div
ref={rootRef}
className={`relative w-full ${className}`}
style={{ ["--hil-accent" as string]: accent, ...style }}
onPointerLeave={(event) => {
onPointerLeave?.(event);
if (event.pointerType !== "mouse") return;
// Keep the image while a row still holds keyboard focus.
const focused = document.activeElement;
if (focused && rootRef.current?.contains(focused) && focused.matches(":focus-visible")) return;
deactivate();
}}
{...props}
>
<ul className="border-b border-zinc-900/10 dark:border-white/10">
{items.map((item, index) => {
const active = item.id === activeId;
const dimmed = dimOthers && activeId !== null && !active;
const rowClass = `group/row relative flex w-full items-center gap-4 py-5 text-left outline-hidden transition-opacity duration-300 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-solid focus-visible:outline-[var(--hil-accent)] sm:gap-6 ${dimmed ? "opacity-40" : "opacity-100"}`;
const handlers = {
onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {
if (event.pointerType !== "mouse") return;
if (reduceMotion) anchor(event.currentTarget, false);
else follow(event.clientX, event.clientY);
activate(item.id);
},
onPointerMove: (event: React.PointerEvent<HTMLElement>) => {
if (event.pointerType !== "mouse" || reduceMotion) return;
follow(event.clientX, event.clientY);
},
onFocus: (event: React.FocusEvent<HTMLElement>) => {
// Keyboard focus: anchor the image beside the row instead of following a pointer.
if (!event.currentTarget.matches(":focus-visible")) return;
anchor(event.currentTarget, reduceMotion);
activate(item.id);
},
onBlur: (event: React.FocusEvent<HTMLElement>) => {
if (!rootRef.current?.contains(event.relatedTarget as Node | null)) deactivate();
},
};
const content = (
<>
{noHover ? (
// Touch screens: an inline thumbnail replaces the floating image.
<span aria-hidden="true" className="block w-14 shrink-0 overflow-hidden rounded-md border border-zinc-900/10 bg-zinc-100 dark:border-white/10 dark:bg-zinc-800" style={{ aspectRatio: ratio }}>
{/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}
<img src={item.image} alt="" draggable={false} className="block size-full object-cover" />
</span>
) : null}
{showIndex ? (
<span aria-hidden="true" className="w-7 shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 tabular-nums dark:text-zinc-500">
{String(index + 1).padStart(2, "0")}
</span>
) : null}
<span className={`relative min-w-0 flex-1 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${active ? "translate-x-2 sm:translate-x-3" : ""}`}>
<span className="relative inline-block max-w-full truncate align-bottom text-2xl font-medium tracking-[-0.03em] text-zinc-900 sm:text-[32px] sm:leading-tight dark:text-zinc-50">
{item.title}
<span
aria-hidden="true"
className={`absolute inset-x-0 -bottom-0.5 h-px origin-left bg-[var(--hil-accent)] transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${active ? "scale-x-100" : "scale-x-0"}`}
/>
</span>
</span>
{item.meta ? (
<span className="hidden shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 sm:block dark:text-zinc-400">{item.meta}</span>
) : null}
<svg
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className={`size-4 shrink-0 text-[var(--hil-accent)] transition-[opacity,translate] duration-300 ${active ? "translate-x-0 opacity-100" : "-translate-x-2 opacity-0"}`}
>
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</>
);
return (
<li key={item.id} className="border-t border-zinc-900/10 dark:border-white/10">
{item.href ? (
<a href={item.href} className={rowClass} {...handlers}>
{content}
</a>
) : (
<button type="button" className={rowClass} onClick={() => onSelect?.(item)} {...handlers}>
{content}
</button>
)}
</li>
);
})}
</ul>
{/* Floating image: purely decorative (the row text is the accessible name), never intercepts the pointer. */}
{interactive ? (
<motion.div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-10 overflow-hidden rounded-lg bg-zinc-200 shadow-[0_30px_60px_-30px_rgb(0_0_0/0.5)] dark:bg-zinc-800"
style={{ x, y, rotate, width: imageWidth, height: imageHeight }}
initial={false}
animate={visible ? { opacity: 1, scale: 1 } : { opacity: 0, scale: reduceMotion ? 1 : 0.86 }}
transition={reduceMotion ? { duration: 0.15 } : { type: "spring", stiffness: 380, damping: 32 }}
>
{items.map((item, index) => (
// eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component
<img
key={item.id}
src={item.image}
alt=""
draggable={false}
className="absolute inset-0 block size-full object-cover transition-[opacity,scale] ease-out"
style={{
opacity: index === activeIndex ? 1 : 0,
scale: index === activeIndex || reduceMotion ? 1 : 1.08,
transitionDuration: `${crossfade}ms`,
}}
/>
))}
</motion.div>
) : null}
</div>
);
}