v0.1

UX
#95 - Confetti On Click
Make some fun confetti fly on click!
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.
Watch the video for step-by-step implementation instructions
<!-- π 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>More scripts in UX