ScreenshotNeo

BlogHow-to

How to create hover previews from website thumbnails in a link directory

Build thumbnail previews that work with a mouse, keyboard, and touch while keeping directory links usable and accessible.

By the ScreenshotNeo team4 October 202613 min read

A link directory can show a larger thumbnail preview when a visitor hovers over an entry, focuses it with a keyboard, or explicitly asks to see it on touch. Keep each entry as an ordinary, named link; make the preview supplementary; and let users move the pointer onto the preview without making it disappear.

For a small static preview, CSS can handle hover and focus. If you also need an explicit touch control, an Escape key dismissal, or controlled positioning, use JavaScript to manage open state. The example below uses one reusable preview card, so a large directory does not need one hidden card per entry.

1. Choose what “preview” means

For most link directories, a preview should be an image thumbnail and a short label or description, not a live copy of the destination website. A thumbnail is quick to display and does not execute the remote site inside your page. A live iframe is a separate browsing context and adds memory and other computing work for every frame; the browser documentation advises checking performance if you use them. MDN: iframe

Approach Use it when Trade-off
Existing thumbnail in a CSS preview The visitor needs a visual reminder of the destination. Fast and simple; the image may be stale.
JavaScript-managed image preview You need Escape dismissal, click-to-toggle, or placement logic. More state and event handling to maintain.
Popover API Your target browsers support the exact popover behavior you need. Check support for interest invokers specifically; support for the general Popover API does not imply uniform support for every related feature.
Live iframe Seeing the actual rendered page is essential. Remote pages may block embedding, behave differently in a frame, or consume substantial resources at directory scale.

Start with a thumbnail. Use a live page only when its additional value justifies the loading, security, and compatibility costs. Lazy-loading a live frame only when a visitor requests it is a reasonable design inference from the per-frame resource cost; measure it on the devices and directory sizes you support rather than assuming a universal safe limit.

2. Make the interaction accessible

Hover and keyboard focus should expose the same useful preview. The preview should remain visible while the pointer moves from the link onto the preview, persist long enough to read, and have a dismissal path that does not require leaving the trigger. These are the dismissible, hoverable, and persistent conditions described by WCAG 2.2 Success Criterion 1.4.13. W3C’s SCR39 technique uses a link content preview as an example and describes Escape as a dismissal mechanism.

  • Keep the destination as a real anchor with a visible name. The user can still follow it normally.
  • Reveal preview information on both pointer hover and keyboard focus.
  • Make the preview itself part of the hover region, with no gap that causes flicker while crossing to it.
  • For script-managed previews, support Escape and a visible close button.
  • On touch screens, provide a separate “Preview” button. Do not require a hover gesture to reveal useful information.
  • Keep the preview supplementary. If it contains interactive controls, design it as an interactive panel with appropriate focus behavior, not as a decorative tooltip.

Touch behavior varies: a touchscreen may not have convenient hover, or may emulate it after a long press. The CSS hover media feature describes how to detect whether the primary input can conveniently hover. An explicit button makes the behavior discoverable regardless of that capability.

3. Build a CSS-only preview

Use this when the content is presentational, each preview can sit inside the directory entry, and the preview can be positioned next to its own link. Because the entry contains both link and preview, the pointer can travel between them while the parent remains hovered. :focus-within gives keyboard users the same reveal behavior.

<ul class="directory">
  <li class="directory-item">
    <a class="site-link" href="https://developer.mozilla.org/">
      <img src="/thumbnails/mdn.webp" alt="" width="96" height="64">
      <span>MDN Web Docs</span>
    </a>
    <div class="preview">
      <img src="/thumbnails/mdn.webp" alt="" width="320" height="213">
      <p>Web platform documentation and learning resources.</p>
    </div>
  </li>
</ul>
.directory {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
  gap: 1rem;
  list-style: none;
  padding: 0;
}

.directory-item {
  position: relative;
}

.site-link {
  display: flex;
  align-items: center;
  gap: .75rem;
  min-height: 3rem;
  color: #152238;
}

.site-link img {
  width: 6rem;
  height: 4rem;
  object-fit: cover;
}

.preview {
  position: absolute;
  z-index: 10;
  top: calc(100% + .25rem);
  left: 0;
  width: min(20rem, calc(100vw - 2rem));
  padding: .75rem;
  border: 1px solid #cbd5e1;
  border-radius: .5rem;
  background: white;
  box-shadow: 0 .5rem 1.5rem rgb(0 0 0 / 18%);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

.preview img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}

.preview p { margin: .5rem 0 0; }

.directory-item:hover .preview,
.directory-item:focus-within .preview {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

.site-link:focus-visible {
  outline: 3px solid #1463d6;
  outline-offset: 3px;
}

Do not add a CSS transition delay that makes the card difficult to reach. This minimal version has no Escape control, so use it only when leaving the hover/focus region is an adequate dismissal path for your layout. If the card obscures meaningful content, or you need explicit dismissal, use the state-managed version next.

4. Use JavaScript for touch, Escape, and one shared card

This complete example keeps each directory item as a normal link, adds a separate preview button for touch and click users, and reuses one preview card. Save it as an HTML file and replace the thumbnail paths, labels, descriptions, and destinations with your data. The example closes on Escape and outside click; focus and pointer leaving the active entry also close the card. Since the preview is noninteractive, focus remains on the trigger.

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Website directory</title>
<style>
  body { font: 1rem/1.5 system-ui, sans-serif; margin: 2rem; color: #152238; }
  .directory { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 1rem; padding: 0; list-style: none; }
  .directory-item { display: flex; align-items: center; gap: .75rem; }
  .site-link { display: flex; align-items: center; gap: .6rem; color: inherit; }
  .site-link img { width: 5rem; height: 3.5rem; object-fit: cover; }
  button { font: inherit; }
  :focus-visible { outline: 3px solid #1463d6; outline-offset: 3px; }
  .preview { position: fixed; z-index: 20; width: min(20rem, calc(100vw - 2rem)); padding: .75rem; border: 1px solid #cbd5e1; border-radius: .5rem; background: white; box-shadow: 0 .5rem 1.5rem #0003; }
  .preview[hidden] { display: none; }
  .preview img { display: block; width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
  .preview p { margin: .5rem 0; }
  .preview-close { float: right; }
</style>
<h1>Website directory</h1>
<ul class="directory" id="directory">
  <li class="directory-item">
    <a class="site-link" href="https://developer.mozilla.org/" data-title="MDN Web Docs" data-description="Web platform documentation and learning resources." data-image="/thumbnails/mdn.webp">
      <img src="/thumbnails/mdn.webp" alt="" width="80" height="56">
      <span>MDN Web Docs</span>
    </a>
    <button type="button" class="preview-trigger" aria-expanded="false">Preview</button>
  </li>
  <li class="directory-item">
    <a class="site-link" href="https://www.w3.org/" data-title="W3C" data-description="International web standards organization." data-image="/thumbnails/w3c.webp">
      <img src="/thumbnails/w3c.webp" alt="" width="80" height="56">
      <span>W3C</span>
    </a>
    <button type="button" class="preview-trigger" aria-expanded="false">Preview</button>
  </li>
</ul>
<section class="preview" id="preview" aria-label="Website preview" hidden>
  <button class="preview-close" type="button" aria-label="Close preview">Close</button>
  <strong id="preview-title"></strong>
  <img id="preview-image" alt="">
  <p id="preview-description"></p>
</section>
<script>
  const directory = document.querySelector('#directory');
  const card = document.querySelector('#preview');
  const title = document.querySelector('#preview-title');
  const image = document.querySelector('#preview-image');
  const description = document.querySelector('#preview-description');
  let activeItem = null;
  let pinned = false;

  function show(link, item, pin = false) {
    activeItem = item;
    pinned = pin;
    title.textContent = link.dataset.title;
    image.src = link.dataset.image;
    image.alt = '';
    description.textContent = link.dataset.description;
    card.hidden = false;
    for (const button of directory.querySelectorAll('.preview-trigger')) {
      button.setAttribute('aria-expanded', String(item.contains(button)));
    }
    place(item);
  }

  function place(item) {
    const anchor = item.querySelector('.site-link').getBoundingClientRect();
    const width = card.getBoundingClientRect().width;
    const left = Math.max(8, Math.min(anchor.left, innerWidth - width - 8));
    card.style.left = `${left}px`;
    card.style.top = `${Math.min(anchor.bottom + 8, innerHeight - card.offsetHeight - 8)}px`;
  }

  function close() {
    card.hidden = true;
    activeItem = null;
    pinned = false;
    for (const button of directory.querySelectorAll('.preview-trigger')) button.setAttribute('aria-expanded', 'false');
  }

  directory.addEventListener('pointerover', event => {
    const item = event.target.closest('.directory-item');
    if (!item || !directory.contains(item) || item.contains(event.relatedTarget)) return;
    if (event.pointerType === 'mouse') show(item.querySelector('.site-link'), item);
  });
  directory.addEventListener('focusin', event => {
    const item = event.target.closest('.directory-item');
    if (item) show(item.querySelector('.site-link'), item);
  });
  directory.addEventListener('click', event => {
    const button = event.target.closest('.preview-trigger');
    if (!button) return;
    const item = button.closest('.directory-item');
    if (activeItem === item && pinned) close();
    else show(item.querySelector('.site-link'), item, true);
  });
  directory.addEventListener('pointerout', event => {
    const item = event.target.closest('.directory-item');
    if (item && !item.contains(event.relatedTarget) && !card.contains(event.relatedTarget) && !pinned) close();
  });
  directory.addEventListener('focusout', () => {
    requestAnimationFrame(() => {
      if (!directory.contains(document.activeElement) && !card.contains(document.activeElement) && !pinned) close();
    });
  });
  card.addEventListener('pointerleave', () => {
    if (activeItem && !activeItem.matches(':hover') && !pinned) close();
  });
  card.querySelector('.preview-close').addEventListener('click', close);
  document.addEventListener('keydown', event => {
    if (event.key === 'Escape' && !card.hidden) close();
  });
  document.addEventListener('click', event => {
    if (!card.hidden && !card.contains(event.target) && !directory.contains(event.target)) close();
  });
  addEventListener('resize', () => { if (activeItem) place(activeItem); });
  addEventListener('scroll', () => { if (activeItem) place(activeItem); }, true);
</script>
</html>

The code uses textContent for supplied labels and descriptions, rather than inserting directory data as HTML. Keep directory metadata trusted or validate it before use. The positioning function clamps the card horizontally and moves it upward only as far as a simple bottom-edge check permits; for cards taller than the available viewport or complex layouts, use a placement strategy that measures available space and allows scrolling within the card.

5. Positioning and content details

  • Keep the trigger connected to the preview. In the CSS approach, nesting lets :hover and :focus-within cover both. With a separate overlay, treat pointer movement between trigger and card as one interaction region or use a short close delay that can be cancelled when the pointer enters the card.
  • Prevent viewport clipping. A parent with overflow: hidden can clip an absolutely positioned preview. Use a top-layer popover, render the shared card near the document root, or change the container layout. Recalculate placement on resize and relevant scrolling.
  • Keep text concise. Show the site name and, optionally, one short description. The actual link text should already identify the destination; do not make the preview the sole source of that information.
  • Use meaningful image alternatives appropriately. If the link text already names the site and the thumbnail adds no distinct information, an empty image alt avoids repeating the same name to screen readers. If it conveys additional information, provide a concise alternative.
  • Respect reduced motion. A simple opacity transition is optional. If you add one, keep the preview immediately available and disable nonessential motion under prefers-reduced-motion.

6. Consider the Popover API

The Popover API can display non-modal content in the browser’s top layer. When a popover is associated with an invoker using popovertarget, the browser can establish useful accessibility relationships and keyboard behavior. MDN’s interest-invoker guide describes declarative hover and focus behavior without JavaScript, but that related feature has its own support considerations. Check the exact target browser versions before relying on it, and retain a click or focus fallback where needed. MDN: Using the Popover API · MDN: Using interest invokers

Use a popover when escaping clipping and stacking contexts is valuable, but do not assume that putting content in the top layer automatically makes the interaction accessible. Verify keyboard access, dismissal, pointer travel, and touch behavior in the supported browsers.

7. Keep large directories responsive

  • Reuse the thumbnail already loaded for the directory entry when its quality is sufficient. Otherwise use a suitably sized preview asset rather than downloading a full page image for every item.
  • Do not create an iframe for every directory link. If live previews are required, create or load a frame only after a user asks for it and remove or reuse it when no longer needed. This is a design recommendation based on the additional per-iframe resource cost documented by MDN, not a universal threshold.
  • Use fixed image dimensions or aspect-ratio to avoid layout movement when the image loads.
  • For large lists, delegate pointer and click events from the directory container, as the example does, rather than registering multiple listeners per entry.
  • Keep remote thumbnail hosting and caching strategy separate from the hover interaction. A broken thumbnail should not break the link.
  • Measure scrolling, memory use, and image loading on representative low-powered phones and desktop devices. There is no source-backed universal number of safe previews or iframes.

8. Troubleshooting

Symptom Likely cause Fix
Preview vanishes while moving the pointer to it The preview is outside the hover region, or a gap separates it from the trigger. Nest it inside the hovered parent for CSS, or track pointer entry into both trigger and card. Remove the gap or bridge it with padding.
Keyboard users cannot see the preview Only :hover is styled or the focusable link is not inside the tracked region. Add :focus-within or listen for focusin, and verify a visible focus indicator.
Touch users cannot open it The feature depends on hover. Add a real preview button with a sufficiently large target and an explicit open/close state.
Escape does nothing The implementation relies entirely on CSS. Add script-managed state and a document-level Escape handler, or use a browser-supported popover dismissal path.
Preview appears underneath nearby content A stacking context or clipping ancestor limits the overlay. Inspect ancestor z-index, transform, and overflow. Move the overlay to a suitable root or use the Popover API.
Card is cut off at the right or bottom edge Position is based only on the trigger’s left and bottom coordinates. Measure the card and viewport, clamp both axes, and choose above/below based on available space. Permit internal scrolling if needed.
Wrong thumbnail or description appears after rapid movement Multiple delayed callbacks or asynchronous requests complete out of order. Cancel the prior timer/request when changing active item; associate async results with the active item and ignore stale responses.
Live site is blank in an iframe The destination may disallow framing or rely on behavior unavailable in the embedded context. Use a static thumbnail, or confirm that the destination permits embedding. Do not treat iframe rendering as equivalent to a normal visit.
Directory scrolling becomes sluggish Many large images or active iframe browsing contexts compete for resources. Use appropriately sized thumbnails, avoid eager iframe creation, and profile the actual directory on target devices.
Preview text is announced twice The image alternative or accessible naming repeats the visible link text, or the preview is exposed redundantly. Use empty alt text for decorative duplicate images and check the accessibility tree with the intended semantics.

9. Do-it-yourself checklist

  1. Render each directory item as a normal link with a visible site name and thumbnail.
  2. Reveal a static image preview on both pointer hover and keyboard focus.
  3. Ensure the pointer can move into the card and the card stays visible while it is being read.
  4. Provide Escape and a visible close button if JavaScript manages open state.
  5. Provide an explicit touch-friendly preview button; keep link navigation separate.
  6. Check viewport edges, overflow ancestors, stacking contexts, broken images, and long descriptions.
  7. Test keyboard-only use and touch behavior as well as mouse movement.
  8. Use live iframes only when necessary, load them on demand, and assess their resource cost.

Or skip the browser setup

If you need to create or refresh the thumbnail assets themselves, ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request returns a screenshot image or PDF. This creates the asset; your directory still controls how the preview opens and how links work. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://developer.mozilla.org/ -o mdn.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://developer.mozilla.org/"},
    timeout=90,
)
open("mdn.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://developer.mozilla.org/' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('mdn.webp', res);
  • Cookie banners, popups, and chat widgets are removed before the shot, and each step can be turned off.
  • Bot checks, blank pages, timeouts, failed loads, and cache hits are never billed; response headers report the page verdict and billing status.
  • An MCP server lets AI agents such as Claude, Cursor, and other MCP clients take screenshots.
  • The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Every feature is on every plan.

Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.

FAQ

It can be, but include a visible site name in the same anchor so the destination is clear without interpreting the image.

Should I open the destination when someone taps “Preview”?

No. Keep preview activation on a separate button and leave the anchor’s ordinary navigation behavior intact.

It can, but then it is an interactive panel. Define its focus order, keyboard behavior, and dismissal rules rather than treating it like a tooltip.

Can CSS alone meet every requirement?

CSS is suitable for a simple nested show/hide card. Use JavaScript or an appropriately supported popover implementation when you need explicit close controls, touch toggling, or controlled positioning.

Do I need a ScreenshotNeo request on every hover?

No. Capture thumbnails as assets and serve them through your normal site or image delivery setup; the hover interaction should display the existing asset.