Hover preview index
A typographic list of projects where hovering a row reveals its image, floating near the cursor.
Grade: ARuntimes: cssStatus: code-only
Documented, no live demo yet — the code below is complete.
Intent
Gets the density of a text index with the browsability of a thumbnail grid. The list reads as a table of contents, and the image only appears for the row you are actually considering, so the page stays quiet until you engage with it.
Implementation
`focusin` is wired alongside `pointerenter`, so the preview follows keyboard navigation as well as the mouse.
Dependencies: none
<ul class="index" data-index>
<li>
<a href="/work/atlas" data-preview="/media/atlas.jpg">
<span class="index__num">01</span>
<span class="index__name">Atlas</span>
<span class="index__meta">Brand — 2025</span>
</a>
</li>
</ul>
<figure class="preview" data-preview-target aria-hidden="true"><img alt="" /></figure>
<style>
.index { list-style: none; margin: 0; padding: 0; }
.index a {
display: grid;
grid-template-columns: 4ch 1fr auto;
gap: var(--space-md);
align-items: baseline;
padding-block: var(--space-md);
border-block-end: 1px solid var(--line);
font-size: var(--fs-h2);
text-decoration: none;
color: inherit;
transition: opacity var(--dur-micro, 0.4s) ease;
}
.index:hover a:not(:hover) { opacity: 0.35; }
.preview {
position: fixed;
top: 0;
left: 0;
z-index: 100;
width: 22rem;
margin: 0;
pointer-events: none;
opacity: 0;
transform: translate3d(var(--px, 0px), var(--py, 0px), 0) translate(2rem, -50%) scale(0.96);
transition:
opacity var(--dur-micro, 0.4s) var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)),
transform var(--dur-micro, 0.4s) var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
}
.preview[data-visible="true"] {
opacity: 1;
transform: translate3d(var(--px, 0px), var(--py, 0px), 0) translate(2rem, -50%) scale(1);
}
.preview img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
@media (max-width: 48rem), (pointer: coarse) {
.preview { display: none; }
}
</style>
<script type="module">
const fine = matchMedia("(pointer: fine) and (min-width: 48rem)").matches;
if (fine) {
const preview = document.querySelector("[data-preview-target]");
const img = preview.querySelector("img");
let tx = 0;
let ty = 0;
addEventListener("pointermove", (event) => { tx = event.clientX; ty = event.clientY; }, { passive: true });
const frame = () => {
preview.style.setProperty("--px", tx + "px");
preview.style.setProperty("--py", ty + "px");
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
const show = (event) => {
const link = event.target.closest("[data-preview]");
if (!link) return;
img.src = link.dataset.preview;
preview.dataset.visible = "true";
};
const hide = () => { preview.dataset.visible = "false"; };
const list = document.querySelector("[data-index]");
list.addEventListener("pointerover", show);
list.addEventListener("focusin", show);
list.addEventListener("pointerleave", hide);
list.addEventListener("focusout", hide);
}
</script>Use it when
- Project indexes, article archives, client lists of five to thirty rows.
Avoid it when
- When users need to compare items visually — a grid is better.
- As the only presentation of the images, since touch users will never see them.
Accessibility
- The preview is decorative enrichment. The row's link text must fully identify the destination on its own.
- Rows must be keyboard focusable in order; showing the preview on `focusin` as well as `pointerenter` is a cheap win.
- Below the tablet breakpoint, render the thumbnail inline in the row instead of floating it.
Performance
- One preview element, one `<img>` whose `src` swaps — not thirty stacked images.
- Preload the images at low priority so the first hover is not a blank frame.
- Reuse the cursor rAF loop if the contextual-cursor pattern is already present rather than starting a second one.
Knobs
- preview size
- Width of the floating image. Default: 22rem
- offset
- Distance from the cursor. Default: 2rem
Composition
Conflicts with
None.
Instruction for Claude Code
Build a hover preview index: rows are links in a 4ch/1fr/auto grid with hairline separators, and non-hovered rows drop to 0.35 opacity. Use one shared fixed figure as the preview, positioned by --px/--py written in a rAF loop from a passive pointermove listener, swapping the img src from the row's data-preview attribute. Wire both pointerover and focusin to show it and both pointerleave and focusout to hide it. Hide the floating preview entirely below 48rem and under (pointer: coarse), and make sure the row link text fully identifies the destination without the image.