Rejouice — loading0

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

html-css-js
<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

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.