Link Peek Preview
An index list of links that floats a mini rendering of each destination beside the pointer: spring-lagged, leaning with sweep speed, shown on keyboard focus and on a touch long-press.
DOM + CSS0 deps1 file · 24 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
type Row = {
id: string;
title: string;
/** Optional screenshot for the peek card. Without it the card paints a small generated page. */
image?: string;
meta?: string;
/** Where the row links. Defaults to "#" + id. */
href?: string;
/** One or two sentences shown on the peek card (and read out as the link's description). */
description?: string;
/** Shown beside the favicon monogram. Derived from the title when omitted. */
domain?: string;
/** Hex colour for the favicon and the generated art. */
accent?: string;
};
type Anchor = "pointer" | "row" | "touch";
type Engine = {
rawX: number;
rawY: number;
rowH: number;
anchor: Anchor;
x: number;
y: number;
vx: number;
vy: number;
open: boolean;
shown: boolean;
closedAt: number;
scale: number;
tilt: number;
reduce: boolean;
last: number;
raf: number;
wake: () => void;
lp: boolean;
suppress: boolean;
startX: number;
startY: number;
timer: number;
hideTimer: number;
suppressTimer: number;
};
const CARD_W = 240;
const CARD_H = 176;
const SPRING_K = 260;
const SPRING_C = 26;
const SPRING_M = 0.5;
const LAG = 90;
const LONG_PRESS_MS = 380;
const MOVE_SLOP = 10;
const PALETTE = ["#6f8cff", "#e5a45c", "#58d0a8", "#d77aa6", "#8ab4f8"];
const DEFAULT_ROWS: Row[] = [
{ id: "01", title: "Meridian Atlas", meta: "Case study", domain: "meridian.atlas", description: "Interactive tide charts for coastal pilots, rebuilt from a decade of sonar logs." },
{ id: "02", title: "Halcyon Type", meta: "Specimen", domain: "halcyon.type", description: "A variable serif drawn for long reading on small, bright screens." },
{ id: "03", title: "Parallel Kitchen", meta: "Identity", domain: "parallel.kitchen", description: "Brand system for a supper club that cooks two menus at once." },
{ id: "04", title: "Lumen Ledger", meta: "Product", domain: "lumen.ledger", description: "Bookkeeping that explains itself, one reconciled line at a time." },
{ id: "05", title: "Tidepool Audio", meta: "Case study", domain: "tidepool.audio", description: "A field-recording library you can browse by place, season and weather." },
];
const TONES = {
dark: {
bg: "#0d0f13",
border: "rgba(255,255,255,0.13)",
text: "#f3f4f6",
sub: "rgba(243,244,246,0.62)",
bar: "rgba(255,255,255,0.05)",
barLine: "rgba(255,255,255,0.08)",
shadow: "0 28px 56px -14px rgba(0,0,0,0.62), 0 10px 22px -8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)",
sheen: "rgba(255,255,255,0.2)",
},
light: {
bg: "#ffffff",
border: "rgba(15,23,42,0.12)",
text: "#0f172a",
sub: "rgba(15,23,42,0.62)",
bar: "#f5f6f8",
barLine: "rgba(15,23,42,0.08)",
shadow: "0 26px 50px -16px rgba(15,23,42,0.32), 0 8px 18px -8px rgba(15,23,42,0.18)",
sheen: "rgba(255,255,255,0.7)",
},
} as const;
const CSS = [
".hlp-row{transition:opacity .35s cubic-bezier(.16,1,.3,1);-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;touch-action:pan-y}",
'.hlp[data-open="true"] .hlp-row:not([data-on="true"]){opacity:.36}',
".hlp-title{display:inline-block;transition:transform .45s cubic-bezier(.16,1,.3,1)}",
'.hlp-row[data-on="true"] .hlp-title{transform:translateX(8px)}',
".hlp-arrow{opacity:0;transform:translate(-6px,6px);transition:opacity .3s ease,transform .45s cubic-bezier(.16,1,.3,1)}",
'.hlp-row[data-on="true"] .hlp-arrow{opacity:.9;transform:none}',
".hlp-shell{position:absolute;left:0;top:0;z-index:30;pointer-events:none;will-change:transform}",
".hlp-shell:not([data-ready]){visibility:hidden}",
".hlp-tilt{width:100%;height:100%;transform-origin:50% 50%;--lx:50%}",
".hlp-pop{width:100%;height:100%;opacity:0;transform:scale(.86) translateY(8px);filter:blur(6px);transition:opacity .14s ease,transform .14s ease,filter .14s ease}",
'.hlp-shell[data-open="true"] .hlp-pop{opacity:1;transform:none;filter:none;transition-duration:.34s;transition-timing-function:cubic-bezier(.16,1,.3,1)}',
".hlp-pane{position:absolute;inset:0;opacity:0;transform:translateY(8px);transition:opacity .22s ease,transform .38s cubic-bezier(.16,1,.3,1)}",
'.hlp-pane[data-on="true"]{opacity:1;transform:none}',
"@media (prefers-reduced-motion:reduce){.hlp-title,.hlp-arrow,.hlp-pane,.hlp-row{transition:none}.hlp-row[data-on=\"true\"] .hlp-title{transform:none}.hlp-pop,.hlp-shell[data-open=\"true\"] .hlp-pop{transform:none;filter:none;transition:opacity .12s linear}.hlp-pane{transform:none}}",
].join("\n");
const nowMs = () => performance.now();
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
function safeHref(raw: string | undefined, fallback: string): string {
if (!raw) return fallback;
const s = raw.trim();
if (!s || /[\\\u0000-\u001f]/.test(s)) return fallback;
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return /^(https?:|mailto:)/i.test(s) ? s : fallback;
return s;
}
function safeSrc(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const s = raw.trim();
if (!s || /[\\\u0000-\u001f]/.test(s)) return undefined;
if (/^data:image\/(png|jpe?g|webp|avif|gif);/i.test(s)) return s;
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return /^https?:/i.test(s) ? s : undefined;
return s;
}
function accentFor(row: Row, i: number): string {
if (row.accent && /^#[0-9a-f]{3,8}$/i.test(row.accent)) return row.accent;
return PALETTE[i % PALETTE.length] ?? "#6f8cff";
}
function domainFor(row: Row): string {
if (row.domain) return row.domain;
const h = row.href && /^https?:\/\//i.test(row.href) ? row.href.replace(/^https?:\/\//i, "").split(/[/?#]/)[0] : "";
if (h) return h.replace(/^www\./, "");
return row.title.toLowerCase().replace(/[^a-z0-9]+/g, "") + ".com";
}
function Art({ variant, accent }: { variant: number; accent: string }) {
const hi = `color-mix(in srgb, ${accent} 88%, #ffffff)`;
const bar = (w: number, h: number, top: number, left: number, a: number): CSSProperties => ({
position: "absolute",
top,
left,
width: w,
height: h,
borderRadius: h / 2,
background: `rgba(255,255,255,${a})`,
});
if (variant === 0) {
return (
<>
<div style={{ position: "absolute", right: -26, top: -38, width: 150, height: 150, borderRadius: "50%", background: `radial-gradient(circle at 36% 36%, ${hi}, transparent 68%)`, opacity: 0.85 }} />
<div style={bar(118, 9, 18, 14, 0.9)} />
<div style={bar(84, 9, 33, 14, 0.9)} />
<div style={bar(132, 4, 52, 14, 0.32)} />
<div style={bar(46, 15, 63, 14, 0.95)} />
</>
);
}
if (variant === 1) {
return (
<>
<div style={bar(96, 8, 14, 14, 0.9)} />
<div style={bar(60, 4, 28, 14, 0.34)} />
{[0, 1, 2].map((n) => (
<div key={n} style={{ position: "absolute", left: 14 + n * 70, top: 40, width: 62, height: 38, borderRadius: 7, background: `rgba(255,255,255,${0.1 + n * 0.05})`, border: "1px solid rgba(255,255,255,0.14)" }}>
<div style={{ position: "absolute", left: 7, top: 7, width: 22, height: 4, borderRadius: 2, background: "rgba(255,255,255,0.5)" }} />
<div style={{ position: "absolute", left: 7, bottom: 7, width: 34 + n * 6, height: 3, borderRadius: 2, background: `color-mix(in srgb, ${accent} 70%, #ffffff)`, opacity: 0.9 }} />
</div>
))}
</>
);
}
return (
<>
{[84, 58, 34].map((d, n) => (
<div key={d} style={{ position: "absolute", left: "50%", top: "50%", width: d, height: d, marginLeft: -d / 2, marginTop: -d / 2 - 2, borderRadius: "50%", border: `1px solid rgba(255,255,255,${0.2 + n * 0.14})` }} />
))}
<div style={{ position: "absolute", left: "50%", top: "50%", width: 12, height: 12, marginLeft: -6, marginTop: -8, borderRadius: "50%", background: hi, boxShadow: `0 0 18px ${accent}` }} />
<div style={{ ...bar(54, 4, 72, 93, 0.4) }} />
</>
);
}
function PeekPane({ row, index, tone, on }: { row: Row; index: number; tone: "dark" | "light"; on: boolean }) {
const t = TONES[tone];
const accent = accentFor(row, index);
const domain = domainFor(row);
const src = safeSrc(row.image);
return (
<div className="hlp-pane" data-on={on ? "true" : "false"}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 7,
height: 24,
padding: "0 10px",
background: t.bar,
borderBottom: `1px solid ${t.barLine}`,
color: t.sub,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: 9.5,
letterSpacing: "0.02em",
}}
>
<span
style={{
display: "grid",
placeItems: "center",
width: 13,
height: 13,
borderRadius: 4,
color: "#fff",
fontFamily: "system-ui, sans-serif",
fontSize: 8,
fontWeight: 700,
lineHeight: 1,
background: `linear-gradient(135deg, ${accent}, color-mix(in srgb, ${accent} 40%, #000000))`,
}}
>
{domain.charAt(0).toUpperCase()}
</span>
<span style={{ minWidth: 0, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{domain}</span>
<span style={{ opacity: 0.7 }}>{"↗"}</span>
</div>
<div
style={{
position: "relative",
height: 84,
overflow: "hidden",
background: `linear-gradient(155deg, color-mix(in srgb, ${accent} 62%, #0a0c10) 0%, color-mix(in srgb, ${accent} 14%, #0a0c10) 100%)`,
}}
>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt="" draggable={false} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<Art variant={index % 3} accent={accent} />
)}
<div style={{ position: "absolute", inset: 0, background: "linear-gradient(180deg, transparent 55%, rgba(0,0,0,0.28))" }} />
</div>
<div style={{ padding: "9px 12px 0", color: t.text }}>
<div style={{ fontSize: 12.5, lineHeight: "16px", fontWeight: 600, letterSpacing: "-0.01em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.title}</div>
{row.description ? (
<div style={{ marginTop: 3, fontSize: 10, lineHeight: "14px", color: t.sub, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{row.description}</div>
) : null}
</div>
</div>
);
}
export function HoverLinkPeek({
rows = DEFAULT_ROWS,
tilt = 18,
cardSize = 104,
showMeta = true,
tone = "dark",
restingIndex,
className,
}: {
rows?: Row[];
/** Max lean of the card in degrees. */
tilt?: number;
/** Size of the peek card. 104 is the default scale (a 240px wide card); 64 to 200. */
cardSize?: number;
/** The right-hand meta column. Rows without a meta simply render blank. */
showMeta?: boolean;
/** Surface of the floating card. The list itself inherits the text colour of its page. */
tone?: "dark" | "light";
/** Keep the card resting on this row whenever nothing is hovered (a static demo state). */
restingIndex?: number;
className?: string;
}) {
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const rest = restingIndex !== undefined && restingIndex >= 0 && restingIndex < rows.length ? restingIndex : null;
const scale = clamp(cardSize, 64, 400) / 104;
const cw = CARD_W * scale;
const ch = CARD_H * scale;
const boxRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const tiltRef = useRef<HTMLDivElement>(null);
const linkRefs = useRef<(HTMLAnchorElement | null)[]>([]);
const anchorRef = useRef<(i: number) => void>(() => {});
const eng = useRef<Engine>({
rawX: 0, rawY: 0, rowH: 40, anchor: "row",
x: 0, y: 0, vx: 0, vy: 0,
open: false, shown: false, closedAt: -1e9,
scale: 1, tilt: 18, reduce: false,
last: 0, raf: 0, wake: () => {},
lp: false, suppress: false, startX: 0, startY: 0,
timer: 0, hideTimer: 0, suppressTimer: 0,
});
const [active, setActive] = useState<number | null>(rest);
useEffect(() => {
const e = eng.current;
e.scale = scale;
e.tilt = tilt;
e.wake();
}, [scale, tilt]);
useEffect(() => {
const e = eng.current;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onMq = () => {
e.reduce = mq.matches;
e.wake();
};
e.reduce = mq.matches;
mq.addEventListener("change", onMq);
const step = (t: number) => {
e.raf = 0;
const box = boxRef.current;
const shell = shellRef.current;
const tl = tiltRef.current;
if (!box || !shell || !tl) return;
const dt = e.last ? clamp((t - e.last) / 1000, 0, 0.05) : 1 / 60;
e.last = t;
const W = box.offsetWidth;
const w = CARD_W * e.scale;
const h = CARD_H * e.scale;
const maxL = Math.max(0, W - w);
let tx: number;
let ty: number;
if (e.anchor === "touch") {
tx = clamp(e.rawX - w / 2, 0, maxL);
ty = e.rawY - h - 30;
if (ty < -24) ty = e.rawY + 38;
} else {
const l = e.rawX + 22;
if (l + w <= W - 2) {
tx = l;
ty = e.rawY - h / 2;
} else if (e.anchor === "row" && e.rawX - 22 - w < 0) {
tx = clamp(e.rawX - w / 2, 0, maxL);
ty = e.rawY + e.rowH / 2 + 10;
} else {
tx = clamp(e.rawX - 22 - w, 0, maxL);
ty = e.rawY - h / 2;
}
}
if (e.open && !e.shown) {
e.x = tx;
e.y = ty;
e.vx = 0;
e.vy = 0;
e.shown = true;
}
if (e.reduce || !e.shown) {
e.x = tx;
e.y = ty;
e.vx = 0;
e.vy = 0;
} else {
const n = Math.max(1, Math.ceil(dt * 120));
const s = dt / n;
for (let k = 0; k < n; k++) {
e.vx += ((SPRING_K * (tx - e.x) - SPRING_C * e.vx) / SPRING_M) * s;
e.vy += ((SPRING_K * (ty - e.y) - SPRING_C * e.vy) / SPRING_M) * s;
e.x += e.vx * s;
e.y += e.vy * s;
}
}
const lx = tx - e.x;
const ly = ty - e.y;
const nx = e.reduce ? 0 : clamp(lx / LAG, -1, 1);
const ny = e.reduce ? 0 : clamp(ly / LAG, -1, 1);
shell.style.transform = "translate3d(" + e.x.toFixed(2) + "px," + e.y.toFixed(2) + "px,0)";
tl.style.transform = e.reduce
? "none"
: "perspective(720px) rotateX(" + (-ny * e.tilt * 0.5).toFixed(2) + "deg) rotateY(" + (nx * e.tilt * 0.5).toFixed(2) + "deg) rotate(" + (nx * e.tilt).toFixed(2) + "deg)";
tl.style.setProperty("--lx", (50 + nx * 45).toFixed(1) + "%");
if (!shell.hasAttribute("data-ready")) shell.setAttribute("data-ready", "1");
const settled = Math.abs(lx) < 0.05 && Math.abs(ly) < 0.05 && Math.abs(e.vx) < 0.05 && Math.abs(e.vy) < 0.05;
if (settled) e.last = 0;
else e.raf = requestAnimationFrame(step);
};
e.wake = () => {
if (!e.raf) e.raf = requestAnimationFrame(step);
};
anchorRef.current = (i: number) => {
const box = boxRef.current;
const a = linkRefs.current[i];
if (!box || !a) return;
const br = box.getBoundingClientRect();
const ar = a.getBoundingClientRect();
const k = br.width ? box.offsetWidth / br.width : 1;
e.rawX = (ar.left - br.left) * k + ar.width * k * 0.5;
e.rawY = (ar.top - br.top) * k + (ar.height * k) / 2;
e.rowH = ar.height * k;
e.anchor = "row";
e.wake();
};
let ro: ResizeObserver | undefined;
if (typeof ResizeObserver !== "undefined" && boxRef.current) {
ro = new ResizeObserver(() => {
if (e.anchor === "row" && e.open) {
const i = linkRefs.current.findIndex((a) => a?.getAttribute("data-on") === "true");
if (i >= 0) anchorRef.current(i);
}
e.wake();
});
ro.observe(boxRef.current);
}
return () => {
mq.removeEventListener("change", onMq);
ro?.disconnect();
if (e.raf) cancelAnimationFrame(e.raf);
e.raf = 0;
e.wake = () => {};
window.clearTimeout(e.timer);
window.clearTimeout(e.hideTimer);
window.clearTimeout(e.suppressTimer);
};
}, []);
useEffect(() => {
const e = eng.current;
const open = active !== null;
if (open && !e.open && nowMs() - e.closedAt > 240) e.shown = false;
if (!open && e.open) e.closedAt = nowMs();
e.open = open;
if (open && e.anchor === "row") anchorRef.current(active);
e.wake();
}, [active]);
const setRaw = (clientX: number, clientY: number) => {
const box = boxRef.current;
if (!box) return;
const r = box.getBoundingClientRect();
const k = r.width ? box.offsetWidth / r.width : 1;
const e = eng.current;
e.rawX = (clientX - r.left) * k;
e.rawY = (clientY - r.top) * k;
};
const settle = () => {
if (rest !== null) eng.current.anchor = "row";
setActive(rest);
};
const onRowEnter = (i: number, ev: ReactPointerEvent<HTMLAnchorElement>) => {
if (ev.pointerType === "touch") return;
const e = eng.current;
setRaw(ev.clientX, ev.clientY);
e.anchor = "pointer";
setActive(i);
e.wake();
};
const onRowDown = (i: number, ev: ReactPointerEvent<HTMLAnchorElement>) => {
if (ev.pointerType !== "touch") return;
const e = eng.current;
window.clearTimeout(e.hideTimer);
window.clearTimeout(e.timer);
e.startX = ev.clientX;
e.startY = ev.clientY;
const cx = ev.clientX;
const cy = ev.clientY;
e.timer = window.setTimeout(() => {
e.lp = true;
e.suppress = true;
window.clearTimeout(e.suppressTimer);
e.suppressTimer = window.setTimeout(() => {
e.suppress = false;
}, 1500);
setRaw(cx, cy);
e.anchor = "touch";
setActive(i);
e.wake();
try {
navigator.vibrate?.(8);
} catch {
/* vibration is optional */
}
}, LONG_PRESS_MS);
};
const onBoxMove = (ev: ReactPointerEvent<HTMLDivElement>) => {
const e = eng.current;
if (ev.pointerType === "touch") {
if (!e.lp) {
if (Math.hypot(ev.clientX - e.startX, ev.clientY - e.startY) > MOVE_SLOP) window.clearTimeout(e.timer);
return;
}
setRaw(ev.clientX, ev.clientY);
e.wake();
return;
}
if (!e.open) return;
setRaw(ev.clientX, ev.clientY);
e.anchor = "pointer";
e.wake();
};
const onTouchEnd = (ev: ReactPointerEvent<HTMLDivElement>) => {
if (ev.pointerType !== "touch") return;
const e = eng.current;
window.clearTimeout(e.timer);
if (!e.lp) return;
e.lp = false;
window.clearTimeout(e.hideTimer);
e.hideTimer = window.setTimeout(settle, ev.type === "pointercancel" ? 0 : 900);
};
const onFocusRow = (i: number, ev: FocusEvent<HTMLAnchorElement>) => {
let visible = false;
try {
visible = ev.currentTarget.matches(":focus-visible");
} catch {
visible = true;
}
if (!visible) return;
anchorRef.current(i);
setActive(i);
};
const onBlurBox = (ev: FocusEvent<HTMLDivElement>) => {
const next = ev.relatedTarget as Node | null;
if (next && ev.currentTarget.contains(next)) return;
if (eng.current.anchor === "row") settle();
};
const onKeyDown = (ev: KeyboardEvent<HTMLDivElement>) => {
if (ev.key === "Escape" && active !== null) setActive(null);
};
return (
<div
ref={boxRef}
className={"hlp relative " + (className ?? "")}
data-open={active !== null ? "true" : "false"}
onPointerMove={onBoxMove}
onPointerLeave={(ev) => {
if (ev.pointerType !== "touch") settle();
}}
onPointerUp={onTouchEnd}
onPointerCancel={onTouchEnd}
onBlur={onBlurBox}
onKeyDown={onKeyDown}
onContextMenu={(ev) => {
if (eng.current.lp || eng.current.suppress) ev.preventDefault();
}}
>
<style>{CSS}</style>
<ul className="relative z-10 m-0 list-none p-0">
{rows.map((row, i) => {
const descId = "hlp" + uid + "d" + i;
const on = active === i;
return (
<li key={row.id}>
<a
ref={(el) => {
linkRefs.current[i] = el;
}}
href={safeHref(row.href, "#" + row.id)}
data-on={on ? "true" : "false"}
aria-describedby={row.description ? descId : undefined}
onPointerEnter={(ev) => onRowEnter(i, ev)}
onPointerDown={(ev) => onRowDown(i, ev)}
onFocus={(ev) => onFocusRow(i, ev)}
onClick={(ev) => {
if (eng.current.suppress) {
ev.preventDefault();
eng.current.suppress = false;
}
}}
className="hlp-row flex min-h-[56px] w-full items-baseline gap-3 !rounded-none py-3.5 text-inherit no-underline outline-none focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-current"
style={{ color: "inherit", borderBottom: "1px solid color-mix(in srgb, currentColor 15%, transparent)" }}
>
<span className="w-6 shrink-0 font-mono text-[11px] tabular-nums tracking-[0.1em] opacity-55">{row.id}</span>
<span className="hlp-title min-w-0 truncate text-xl font-semibold tracking-tight @xl:text-[26px]">{row.title}</span>
{showMeta && (
<span className="ml-auto max-w-[38%] shrink-0 truncate font-mono text-[10px] uppercase tracking-[0.14em] opacity-55">{row.meta}</span>
)}
<svg aria-hidden viewBox="0 0 16 16" width="14" height="14" className={"hlp-arrow shrink-0 self-center" + (showMeta ? "" : " ml-auto")} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4.5 11.5 11.5 4.5M5.5 4.5h6v6" />
</svg>
{row.description ? (
<span id={descId} className="sr-only">
{row.description}
</span>
) : null}
</a>
</li>
);
})}
</ul>
<div ref={shellRef} aria-hidden className="hlp-shell" data-open={active !== null ? "true" : "false"} style={{ width: cw, height: ch }}>
<div ref={tiltRef} className="hlp-tilt">
<div className="hlp-pop">
<div
style={{
position: "relative",
width: cw,
height: ch,
borderRadius: 14 * scale,
overflow: "hidden",
background: TONES[tone].bg,
border: "1px solid " + TONES[tone].border,
boxShadow: TONES[tone].shadow,
}}
>
<div style={{ position: "absolute", left: 0, top: 0, width: CARD_W, height: CARD_H, transform: "scale(" + scale + ")", transformOrigin: "0 0" }}>
{rows.map((row, i) => (
<PeekPane key={row.id} row={row} index={i} tone={tone} on={active === i} />
))}
</div>
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
background: "radial-gradient(120% 90% at var(--lx) 0%, " + TONES[tone].sheen + ", transparent 58%)",
mixBlendMode: tone === "dark" ? "screen" : "normal",
opacity: tone === "dark" ? 0.55 : 0.5,
pointerEvents: "none",
}}
/>
</div>
</div>
</div>
</div>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this pattern
A text index that previews on hover is how a studio lists work without a wall of thumbnails, and the detail that sells it is the card. Each row links somewhere; hovering one floats a small rendering of the destination (a favicon monogram and domain, a generated page or your screenshot, the title and a two-line description) beside the pointer. The card follows on a hand-written spring (stiffness 260, damping 26, mass 0.5) and leans with how far it trails the target: a fast sweep tips it in-plane and swings it slightly in perspective, a slow move leaves it level, and a soft sheen slides across its face with the lag. The first hover places the card at the pointer, so it never flies in from a corner, and it flips to the pointer's left near the list's edge. Keyboard focus anchors the card to the focused row, Escape dismisses it, and the description is also attached to the link as an accessible description. On touch, a 380ms long-press shows the card above the finger and follows it while held, without hijacking scrolling or the ordinary tap. The list inherits your page's text colour, so it works on light and dark pages; the card is dark by default with a light tone. It is React only and runs one animation loop that sleeps whenever the card is at rest.
Curator’s note
Rebuilt in October as a link list with a real peek card: a mini page rendering that trails the pointer on its own spring, with no animation library.

