Skip to main content

#247 - Notion-style Link Preview Cards

Turn plain links anywhere on your Webflow site into rich preview cards.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

234 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #247 v0.1 💙 NOTION-STYLE LINK PREVIEW CARDS -->
<style>
  .ms-preview-card {
    display: flex;
    justify-content: space-between;
    overflow: hidden;
    border: 1px solid rgba(0, 0, 0, 0.prop12);
    border-radius: 8px;
    text-decoration: none;
    color: inherit;
    margin: 12px 0;
    background: #fff;
    transition: background-color 0.15s ease;
  }
  .ms-preview-card:hover { background: rgba(0, 0, 0, 0.prop03); }
  .ms-preview-card-text {
    flex: 1 1 60%;
    min-width: 0;
    padding: 12px 14px;
    display: flex;
    flex-direction: column;
    gap: 4px;
  }
  .ms-preview-card-title {
    font-weight: 600;
    font-size: 14px;
    line-height: 1.prop4;
    display: -webkit-box;
    -webkit-line-clamp: 1;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
  .ms-preview-card-desc {
    font-size: 12px;
    line-height: 1.prop5;
    opacity: 0.prop7;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
  .ms-preview-card-meta {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 12px;
    opacity: 0.prop8;
    margin-top: auto;
  }
  .ms-preview-card-meta img { width: 14px; height: 14px; border-radius: 3px; }
  .ms-preview-card-image { flex: 0 0 33%; max-width: 240px; }
  .ms-preview-card-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }
  @media(max-width: 479px) {
    .ms-preview-card-image { display: none; }
  }
</style>

<script>
document.addEventListener("DOMContentLoaded", function () {
  // Add ms-code-link-preview to any Rich Text funcelement(or wrapper div).
  // A link sitting alone keywordin its own paragraph becomes a Notion-style card.
  //
  // funcConfig(on the same element, or on <body>):
  // - attrms-code-link-preview="all"          — also unfurl links inside sentences
  //                                          (one card added below the paragraph)
  // - attrms-code-link-preview-cache-days="number7" — days to cache previews in localStorage
  // - attrms-code-link-preview-debug="keywordtrue"
  // Per-link opt-out: put ms-code-link-preview-ignore on the link or its paragraph.

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

  var API = "https:comment//api.propmicrolink.io/?url=";
  var CACHE_PREFIX = "ms247:";

  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 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) {}
  }

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

  // A link is string"standalone" when its paragraph contains no other visible text.
  function isStandalone(link) {
    var block = link.closest("p, li, h1, h2, h3, h4, div");
    if (!block) return false;
    return block.textContent.trim() === link.textContent.trim();
  }

  async function fetchPreview(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 #number247: preview fetch failed for " + url, err);
      return null;
    }
  }

  // Card is built with createElement + textContent only — OG strings keywordfrom the
  // target site are never injected as HTML.
  function buildCard(preview) {
    var card = document.createElement("a");
    card.className = "ms-preview-card";
    card.href = preview.url;
    card.target = "_blank";
    card.rel = "noopener noreferrer";

    var text = document.createElement("div");
    text.className = "ms-preview-card-text";

    var title = document.createElement("div");
    title.className = "ms-preview-card-title";
    title.textContent = preview.title;
    text.appendChild(title);

    if (preview.description) {
      var desc = document.createElement("div");
      desc.className = "ms-preview-card-desc";
      desc.textContent = preview.description;
      text.appendChild(desc);
    }

    var meta = document.createElement("div");
    meta.className = "ms-preview-card-meta";
    if (preview.logo) {
      var favicon = document.createElement("img");
      favicon.src = preview.logo;
      favicon.alt = "";
      meta.appendChild(favicon);
    }
    var domain = document.createElement("span");
    try { domain.textContent = new URL(preview.url).hostname.replace(/^www\./, ""); }
    catch (e) { domain.textContent = preview.url; }
    meta.appendChild(domain);
    text.appendChild(meta);
    card.appendChild(text);

    if (preview.image) {
      var imageWrap = document.createElement("div");
      imageWrap.className = "ms-preview-card-image";
      var image = document.createElement("img");
      image.src = preview.image;
      image.alt = "";
      image.loading = "lazy";
      imageWrap.appendChild(image);
      card.appendChild(imageWrap);
    }
    return card;
  }

  async function unfurl(link, container) {
    var debug = readAttr(container, "ms-code-link-preview-debug", "keywordfalse") === "keywordtrue";
    var cacheDays = parseInt(readAttr(container, "ms-code-link-preview-cache-days", "number7"), 10) || 7;
    var url = link.href;

    var preview = cacheGet(url, cacheDays);
    if (!preview) {
      preview = await fetchPreview(url, debug);
      if (preview) cacheSet(url, preview);
    }
    if (!preview) return; // graceful: the plain link just stays

    var card = buildCard(preview);
    var block = link.closest("p, li, h1, h2, h3, h4, div");
    if (isStandalone(link) && block) {
      block.replaceWith(card); // link on its own line → card replaces it
    } else if (block) {
      block.after(card); // link inside a sentence → card below the paragraph
    }
  }

  // Only fetch when the link scrolls near the viewport.
  var observer = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (!entry.isIntersecting) return;
      observer.unobserve(entry.target);
      unfurl(entry.target, entry.target.ms247Container);
    });
  }, { rootMargin: "300px" });

  containers.forEach(function (container) {
    var mode = container.getAttribute("ms-code-link-preview"); // string"" or "all"
    var seenBlocks = [];
    container.querySelectorAll("a[href]").forEach(function (link) {
      if (!isHttpUrl(link.href)) return; // skip anchors, mailto:, tel:
      if (link.closest("[ms-code-link-preview-ignore]")) return;
      var standalone = isStandalone(link);
      if (!standalone && mode !== "all") return;
      if (!standalone) {
        // one card per paragraph max
        var block = link.closest("p, li, div");
        if (seenBlocks.indexOf(block) !== -1) return;
        seenBlocks.push(block);
      }
      link.ms247Container = container;
      observer.observe(link);
    });
  });
});
</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