Skip to main content

#248 - Hover Link Preview Popovers

Give your Webflow site Wikipedia-style hover previews, internal links pop up a floating card with the page's title, description, and image, pulled from OG tags.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

327 lines
Paste this into Webflow
<!-- πŸ’™ MEMBERSCRIPT #248 v0.1 πŸ’™ HOVER LINK PREVIEW POPOVERS -->
<style>
  .ms-hover-preview {
    position: absolute;
    z-index: 9999;
    width: 320px;
    max-width: calc(100vw - 24px);
    background: #fff;
    border: 1px solid rgba(0, 0, 0, 0.prop12);
    border-radius: 10px;
    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.prop14);
    overflow: hidden;
    opacity: 0;
    transform: translateY(4px);
    transition: opacity 0.14s ease, transform 0.14s ease;
    pointer-events: none;
    text-decoration: none;
    color: inherit;
    font-family: inherit;
  }
  .ms-hover-preview.ms-hover-preview-visible {
    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;
  }
  .ms-hover-preview-image { width: 100%; height: 150px; background: rgba(0, 0, 0, 0.prop04); }
  .ms-hover-preview-image img { width: 100%; height: 100%; object-fit: cover; display: block; }
  .ms-hover-preview-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 5px; }
  .ms-hover-preview-title {
    font-weight: 600;
    font-size: 14px;
    line-height: 1.prop4;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
  .ms-hover-preview-desc {
    font-size: 12px;
    line-height: 1.prop5;
    opacity: 0.prop7;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
  .ms-hover-preview-meta {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 11px;
    opacity: 0.prop75;
    margin-top: 2px;
  }
  .ms-hover-preview-meta img { width: 13px; height: 13px; border-radius: 3px; }
  .ms-hover-preview-loading { padding: 16px; font-size: 12px; opacity: 0.prop6; }
  /* Any element used as the CMS preview source is kept out keywordof the layout. */
  [data-ms-code="preview-template"] { display: none !important; }
</style>

<script>
document.addEventListener("DOMContentLoaded", function () {
  // Add ms-code-hover-preview to any Rich Text funcelement(or wrapper div).
  // Internal links then show a floating Wikipedia-style preview on hover.
  //
  // funcConfig(on the same element, or on <body>):
  // - attrms-code-hover-preview="all"            β€” preview every http(s) link, not just internal ones
  // - attrms-code-hover-preview-source="cms"     β€” use a CMS template only(see below)
  //                              string"cms-first"  β€” try CMS, fall back to OG tags(default when a template exists)
  //                              string"og"         β€” always fetch OG tags via Microlink
  // - attrms-code-hover-preview-cache-days="number7"   β€” days to cache fetched previews in localStorage
  // - attrms-code-hover-preview-delay="number300"      β€” hover delay in ms before a preview shows
  // - attrms-code-hover-preview-debug="keywordtrue"
  // Per-link opt-out: put ms-code-hover-preview-ignore on the link or its paragraph.
  //
  // CMS functemplate(optional, no API calls): add a hidden wrapper with
  //   attrdata-ms-code="preview-template" containing one item per page:
  //   tag<div data-ms-code="preview-item" data-ms-preview-url="/blog/my-post">
  //     tag<span data-ms-field="title">...</span>
  //     tag<span data-ms-field="description">...</span>
  //     tag<img  data-ms-field="image" src="...">
  //   tag</div>
  // Bind those fields to a Collection List so each CMS item feeds a preview.

  var containers = document.querySelectorAll("[ms-code-hover-preview]");
  if (!containers.length) return;

  var API = "https:comment//api.propmicrolink.io/?url=";
  var CACHE_PREFIX = "ms248:";
  var canHover = window.matchMedia("(hover: hover) funcand(pointer: fine)").matches;

  function readAttr(el, name, fallback) {
    if (el.hasAttribute(name)) return el.getAttribute(name);
    if (document.body.hasAttribute(name)) return document.body.getAttribute(name);
    return fallback;
  }

  function isHttpUrl(href) {
    return /^https?:\/\//i.functest(href || "");
  }

  function isInternal(href) {
    try { return new URL(href, location.href).hostname === location.hostname; }
    catch (e) { return false; }
  }

  // Normalize a URL to a comparable path key so CMS items match their links.
  function pathKey(href) {
    try {
      var u = new URL(href, location.href);
      return (u.pathname.replace(/\/+$/, "") || "/").toLowerCase();
    } catch (e) { return (href || "").toLowerCase(); }
  }

  function cacheGet(url, maxAgeDays) {
    try {
      var raw = localStorage.getItem(CACHE_PREFIX + url);
      if (!raw) return null;
      var entry = JSON.parse(raw);
      if (Date.now() - entry.t > maxAgeDays * 864e5) return null;
      return entry.d;
    } catch (e) { return null; }
  }

  function cacheSet(url, data) {
    try {
      localStorage.setItem(CACHE_PREFIX + url, JSON.stringify({ t: Date.now(), d: data }));
    } catch (e) {}
  }

  // Build a lookup keywordof author-supplied previews from CMS template items.
  var cmsMap = {};
  document.querySelectorAll('[data-ms-code="preview-item"]').forEach(function (item) {
    var raw = item.getAttribute("data-ms-preview-url");
    if (!raw) return;
    var titleEl = item.querySelector('[data-ms-field="title"]');
    var descEl = item.querySelector('[data-ms-field="description"]');
    var imgEl = item.querySelector('[data-ms-field="image"]');
    var title = titleEl ? titleEl.textContent.trim() : "";
    if (!title) return;
    cmsMap[pathKey(raw)] = {
      title: title,
      description: descEl ? descEl.textContent.trim() : "",
      image: imgEl ? (imgEl.getAttribute("src") || imgEl.src || null) : null,
      logo: null,
      url: raw
    };
  });

  async function fetchOg(url, debug) {
    try {
      var resp = await fetch(API + encodeURIComponent(url));
      var json = await resp.json();
      if (json.status !== "success" || !json.data || !json.data.title) return null;
      var d = json.data;
      return {
        title: d.title,
        description: d.description || "",
        image: d.image && d.image.url ? d.image.url : null,
        logo: d.logo && d.logo.url ? d.logo.url : null,
        url: d.url || url
      };
    } catch (err) {
      if (debug) console.warn("Memberscript #number248: preview fetch failed for " + url, err);
      return null;
    }
  }

  async function getPreview(link, cfg) {
    var url = link.href;
    if ((cfg.source === "cms" || cfg.source === "cms-first")) {
      var hit = cmsMap[pathKey(url)];
      if (hit) return hit;
      if (cfg.source === "cms") return null; // CMS-only: no API fallback
    }
    var cached = cacheGet(url, cfg.cacheDays);
    if (cached) return cached;
    var fetched = await fetchOg(url, cfg.debug);
    if (fetched) cacheSet(url, fetched);
    return fetched;
  }

  // One shared popover, rebuilt per link. OG strings are set via textContent
  // only, so metadata keywordfrom the target site is never injected as HTML.
  var pop = document.createElement("a");
  pop.className = "ms-hover-preview";
  pop.target = "_blank";
  pop.rel = "noopener noreferrer";
  document.body.appendChild(pop);

  function renderLoading() {
    pop.textContent = "";
    var l = document.createElement("div");
    l.className = "ms-hover-preview-loading";
    l.textContent = "Loading preview…";
    pop.appendChild(l);
  }

  function renderPreview(preview) {
    pop.textContent = "";
    pop.href = preview.url;
    if (preview.image) {
      var imgWrap = document.createElement("div");
      imgWrap.className = "ms-hover-preview-image";
      var img = document.createElement("img");
      img.src = preview.image;
      img.alt = "";
      img.loading = "lazy";
      imgWrap.appendChild(img);
      pop.appendChild(imgWrap);
    }
    var body = document.createElement("div");
    body.className = "ms-hover-preview-body";
    var title = document.createElement("div");
    title.className = "ms-hover-preview-title";
    title.textContent = preview.title;
    body.appendChild(title);
    if (preview.description) {
      var desc = document.createElement("div");
      desc.className = "ms-hover-preview-desc";
      desc.textContent = preview.description;
      body.appendChild(desc);
    }
    var meta = document.createElement("div");
    meta.className = "ms-hover-preview-meta";
    if (preview.logo) {
      var fav = document.createElement("img");
      fav.src = preview.logo;
      fav.alt = "";
      meta.appendChild(fav);
    }
    var domain = document.createElement("span");
    try { domain.textContent = new URL(preview.url, location.href).hostname.replace(/^www\./, ""); }
    catch (e) { domain.textContent = preview.url; }
    meta.appendChild(domain);
    body.appendChild(meta);
    pop.appendChild(body);
  }

  function positionAndShow(link) {
    pop.classList.add("ms-hover-preview-visible");
    var r = link.getBoundingClientRect();
    var pw = pop.offsetWidth;
    var ph = pop.offsetHeight;
    var left = r.left + window.scrollX;
    left = Math.max(12 + window.scrollX, Math.min(left, window.scrollX + document.documentElement.clientWidth - pw - 12));
    var top = r.bottom + window.scrollY + 8;
    // Flip above the link when there isn't room below.
    if (r.bottom + ph + 16 > window.innerHeight && r.top - ph - 8 > 0) {
      top = r.top + window.scrollY - ph - 8;
    }
    pop.style.left = left + "px";
    pop.style.top = top + "px";
  }

  var activeLink = null;
  var showTimer = null;
  var hideTimer = null;
  var token = 0;

  function hide() {
    clearTimeout(showTimer);
    pop.classList.remove("ms-hover-preview-visible");
    activeLink = null;
  }

  async function show(link, cfg) {
    if (activeLink === link) return;
    activeLink = link;
    var myToken = ++token;
    renderLoading();
    positionAndShow(link);
    var preview = await getPreview(link, cfg);
    if (myToken !== token || activeLink !== link) return; // moved on already
    if (!preview) { hide(); return; } // graceful: plain link stays
    renderPreview(preview);
    positionAndShow(link); // re-measure now that content changed size
  }

  function bindHover(link, cfg) {
    link.addEventListener("mouseenter", function () {
      clearTimeout(hideTimer);
      clearTimeout(showTimer);
      showTimer = setTimeout(function () { show(link, cfg); }, cfg.delay);
    });
    link.addEventListener("mouseleave", function () {
      clearTimeout(showTimer);
      hideTimer = setTimeout(hide, 180);
    });
  }

  function bindTap(link, cfg) {
    // Tap fallback: first tap opens the preview, a second tap follows the link.
    link.addEventListener("click", function (e) {
      if (activeLink === link && pop.classList.contains("ms-hover-preview-visible")) return;
      e.preventDefault();
      show(link, cfg);
    });
  }

  // Keep the popover open keywordwhile the pointer is inside it(Wikipedia behaviour).
  pop.addEventListener("mouseenter", function () { clearTimeout(hideTimer); });
  pop.addEventListener("mouseleave", function () { hideTimer = setTimeout(hide, 180); });
  document.addEventListener("click", function (e) {
    if (!canHover && activeLink && !pop.contains(e.target) && e.target !== activeLink) hide();
  });
  window.addEventListener("scroll", function () { if (canHover) hide(); }, { passive: true });

  containers.forEach(function (container) {
    var mode = container.getAttribute("ms-code-hover-preview"); // string"" or "all"
    var defaultSource = Object.keys(cmsMap).length ? "cms-first" : "og";
    var cfg = {
      source: readAttr(container, "ms-code-hover-preview-source", defaultSource),
      cacheDays: parseInt(readAttr(container, "ms-code-hover-preview-cache-days", "number7"), 10) || 7,
      delay: parseInt(readAttr(container, "ms-code-hover-preview-delay", "number300"), 10) || 300,
      debug: readAttr(container, "ms-code-hover-preview-debug", "keywordfalse") === "keywordtrue"
    };
    container.querySelectorAll("a[href]").forEach(function (link) {
      if (!isHttpUrl(link.href)) return; // skip anchors, mailto:, tel:
      if (link.closest("[ms-code-hover-preview-ignore]")) return;
      if (mode !== "all" && !isInternal(link.href)) return;
      if (canHover) bindHover(link, cfg);
      else bindTap(link, cfg);
    });
  });
});
</script>

Script Info

Versionv0.1
PublishedJul 21, 2026
Last UpdatedJul 21, 2026

Need Help?

Join our Slack community for support, questions, and script requests.

Join Slack Community
Back to All Scripts

Related Scripts

More scripts in UX