mgx

how to add popover previews for links on hover

You might have seen this "[link preview](https://mgx.me/35)" thing on my website - I implemented it using PHP. But I thought I'd share another approach that's even easier to set up. ![](https://static.m64.in/images/2025/how-to-enable-popover-preview-of-links-on-hover.webp) This guide will show you how to implement a popover link preview system on your blog in minutes, without a complex backend or heavy plugins, by leveraging Cloudflare Workers (free tier) and a small piece of JavaScript. The popover preview system has two parts: 1. **A Cloudflare Worker** that fetches and parses web pages 2. **A frontend script** that handles hover events and shows popover previews ### The Back-end (Cloudflare Worker) ```javascript export default { async fetch(request) { const { searchParams } = new URL(request.url); const targetUrl = searchParams.get("url"); const origin = request.headers.get("Origin"); const allowedOrigin = "https://yourdomain.com"; // Good enough to prevent casual hotlinking. // Build CORS headers only if Origin matches const corsHeaders = { "Content-Type": "application/json", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", ...(origin === allowedOrigin ? { "Access-Control-Allow-Origin": allowedOrigin } : {}), }; // Handle preflight OPTIONS requests if (request.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } if (!targetUrl) { return new Response(JSON.stringify({ error: "No URL provided" }), { status: 400, headers: corsHeaders, }); } // Validate URL let url; try { url = new URL(targetUrl); } catch (e) { return new Response(JSON.stringify({ error: "Invalid URL" }), { status: 400, headers: corsHeaders, }); } let html; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); const resp = await fetch(url.toString(), { redirect: "follow", signal: controller.signal, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Connection": "keep-alive", }, }); clearTimeout(timeout); html = await resp.text(); } catch (e) { return new Response( JSON.stringify({ error: "Failed to fetch the URL (timeout or blocked)" }), { status: 500, headers: corsHeaders } ); } const getMeta = (pattern) => { const match = html.match(pattern); return match ? match[1] : null; }; let title = getMeta(/(.*?)<\/title>/i) || getMeta(/<meta[^>]+property=["']og:title["'][^>]+content=["'](.*?)["']/i) || getMeta(/<meta[^>]+name=["']twitter:title["'][^>]+content=["'](.*?)["']/i); let description = getMeta(/<meta[^>]+name=["']description["'][^>]+content=["'](.*?)["']/i) || getMeta(/<meta[^>]+property=["']og:description["'][^>]+content=["'](.*?)["']/i) || getMeta(/<meta[^>]+name=["']twitter:description["'][^>]+content=["'](.*?)["']/i); let ogImage = getMeta(/<meta[^>]+property=["']og:image["'][^>]+content=["'](.*?)["']/i) || getMeta(/<meta[^>]+name=["']twitter:image["'][^>]+content=["'](.*?)["']/i) || getMeta(/<meta[^>]+name=["']thumbnail["'][^>]+content=["'](.*?)["']/i); if (ogImage && !/^https?:\/\//i.test(ogImage)) { ogImage = `${url.protocol}//${url.host}${ogImage}`; } if (!title) { const h1 = html.match(/<h1[^>]*>(.*?)<\/h1>/i); title = h1 ? h1[1].replace(/<[^>]+>/g, ") : null; } if (!description) { const p = html.match(/<p[^>]*>(.*?)<\/p>/i); description = p ? p[1].replace(/<[^>]+>/g, ") : null; } if (!title && !description && !ogImage) { return new Response(JSON.stringify({ error: "No previewable content found" }), { status: 204, headers: corsHeaders, }); } return new Response( JSON.stringify({ url: targetUrl, title, description, ogImage }), { headers: corsHeaders } ); }, }; ``` ### The Front-end JS & Popover HTML ```javascript <script> async function showPreview(e) { const url = e.target.href; const previewBox = document.getElementById("preview-box"); try { const res = await fetch( "https://your-worker.your-subdomain.workers.dev/?url=" + encodeURIComponent(url) ); if (!res.ok) return; const data = await res.json(); previewBox.innerHTML = ` <div style="display:flex; flex-direction:column; gap:4px;"> <strong>${data.title || "}</strong> <em>${data.description || "}</em> ${data.ogImage ? `<img src="${data.ogImage}" style="width:100%; height:auto;">` : "} </div> `; previewBox.style.display = "block"; const boxWidth = previewBox.offsetWidth; const boxHeight = previewBox.offsetHeight; const pageWidth = window.innerWidth; const pageHeight = window.innerHeight; // Determine horizontal position let left = e.pageX + 10; if (left + boxWidth > pageWidth) { left = e.pageX - boxWidth - 10; if (left < 0) left = 10; } // Determine vertical position let top = e.pageY + 10; if (top + boxHeight > pageHeight) { top = pageHeight - boxHeight - 10; if (top < 0) top = 10; } previewBox.style.left = left + "px"; previewBox.style.top = top + "px"; } catch (err) { console.error(err); } } function hidePreview() { document.getElementById("preview-box").style.display = "none"; } // Only select links inside specific container(s) const containerSelector = "main"; // main works better for bearblog.dev users document.querySelectorAll(`${containerSelector} a`).forEach(link => { link.addEventListener("mouseover", showPreview); link.addEventListener("mouseout", hidePreview); }); </script> ``` ```html <div id="preview-box" style=" position:absolute; display:none; padding:8px; background:#fff; border:1px solid #ccc; z-index:999; width:300px; box-sizing:border-box; word-wrap:break-word; overflow-wrap:break-word; "></div> ``` ## How To Set It Up ### Step 1: Create a Cloudflare Worker 1. Sign up for Cloudflare (if you don't have an account) at [cloudflare.com](https://cloudflare.com) 2. Go to **Workers & Pages** in your Cloudflare dashboard 3. Click **"Create a Worker"** 4. Delete the default code and paste the worker code from [`worker.js`](https://github.com/verfasor/popover-preview-with-cloudflare-workers/blob/main/worker.js) - Inside the code, you’ll see: ```javascript const allowedOrigin = "https://yourdomain.com"; ``` - Update `https://yourdomain.com` to your own domain. 5. Click **"Save and Deploy"** 6. Copy your worker URL (it will look like `https://your-worker.your-subdomain.workers.dev`) ### Step 2: Add the Frontend Script + HTML snippet 1. Open your website's header or footer template 2. Add the JavaScript code and HTML snippet from [`client.html`](https://github.com/verfasor/popover-preview-with-cloudflare-workers/blob/main/client.html) to your page 3. Update the worker URL in the JavaScript: ```javascript const res = await fetch( "https://your-worker.your-subdomain.workers.dev/?url=" + encodeURIComponent(url) ); ``` 4. Add the preview box HTML to your page: ```html <!-- customize this --> <div id="preview-box" style=" position:absolute; display:none; padding:8px; background:#fff; border:1px solid #ccc; z-index:999; width:300px; box-sizing:border-box; word-wrap:break-word; overflow-wrap:break-word; "></div> ``` ### Step 3: Test It 1. Save your changes and refresh your page 2. Hover over any link - you should see a popover preview appear 3. The front-end script is designed to be easily customizable. So make it your own. Have fun. <style> .callout { font-size:95%; border-radius: 3px; padding: 16px; } @media (prefers-color-scheme: light) { .yellow_callout { background: rgb(251, 243, 219); } } @media (prefers-color-scheme: dark) { .yellow_callout { background: rgba(240,165,15,.13); } } </style> <p class="callout yellow_callout"> <strong>Note:</strong> The frontend JS for this guide has only been tested for compatibility with bearblog.dev. Users on platforms like WordPress, Ghost, or static sites may need to adjust the containerSelector variable in the script. </p></div> <div class="post-tags"> <p style="padding-top:1rem;"><strong>Tagged in</strong> <a href="/archive?q=cloudflare+workers">cloudflare workers</a>, <a href="/archive?q=tech">tech</a></p> </div> </main> <form id="upvote-form-hseaqExBvzqwknxMjPHq" style="display: inline"> <small> <input type="hidden" name="csrfmiddlewaretoken" value="u2eiuKfFFeIvxuGuJ42ISKzzZpH80Qxk48nyFLY_5Q9gNCPa5bGB-0OMO4XhRvIe"> <button class="upvote-button" id="upvote-button-hseaqExBvzqwknxMjPHq" type="button"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1" width="14" height="14"> <polyline points="17 11 12 6 7 11"></polyline> <polyline points="17 18 12 13 7 18"></polyline></svg> Upvote </button> </small> </form> <nav id="nav-below" class="post-navigation" aria-label="Posts"> <div class="nav-previous"><span class="prev"><a href="/35" data-preview="The older I get, the more I feel this desire to reconnect." data-title="35 and chill..south" id="prev-post">Previous</a></span></div> <div class="nav-next"><span class="next"><a href="/ok-but-what-even-are-vibes" data-preview="you can't be wrong about vibes. vibes just are." data-title="ok but what even are vibes?" id="next-post">Next</a></span></div></nav> <footer> <div><div id="custom-footer-text"><span id="sub"><button id="font-toggle" aria-label="Toggle font style">sans-serif</button> • <a href="/feed?type=rss">rss</a> • <a href="/feed">atom</a> • <a id="powered-by-bear" href="https://bearblog.dev?ref=mgx.me">powered by bear</a></span><div class="theme-toggle-container"><div id="theme-toggle" class="theme-toggle-switch" role="switch" aria-label="Theme toggle" aria-checked="false" tabindex="0"><div class="theme-option" data-theme="system"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"></rect><line x1="8" x2="16" y1="21" y2="21"></line><line x1="12" x2="12" y1="17" y2="21"></line></svg></div><div class="theme-option" data-theme="light"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path><circle cx="12" cy="12" r="4"></circle></svg></div><div class="theme-option" data-theme="dark"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></div><div class="theme-slider"></div></div></div></div></div> <script>const toggle=document.getElementById('font-toggle');const root=document.documentElement;const fontStyles=['sans-serif','serif','monospace'];let currentIndex=0;function updateButtonText(){toggle.textContent=fontStyles[currentIndex]}function applyFontStyle(style){root.classList.remove('serif','monospace');if(style==='serif'){root.classList.add('serif')}else if(style==='monospace'){root.classList.add('monospace')}localStorage.setItem('fontStyle',style)}function cycleFont(){currentIndex=(currentIndex+1)%fontStyles.length;const newStyle=fontStyles[currentIndex];applyFontStyle(newStyle);updateButtonText()}const savedStyle=localStorage.getItem('fontStyle');if(savedStyle&&fontStyles.includes(savedStyle)){currentIndex=fontStyles.indexOf(savedStyle);applyFontStyle(savedStyle)}updateButtonText();toggle.addEventListener('click',cycleFont)</script> <script>const themeToggle=document.getElementById('theme-toggle');const themeOptions=themeToggle.querySelectorAll('.theme-option');function applyTheme(theme){const prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;const finalTheme=theme==='system'?(prefersDark?'dark':'light'):theme;document.documentElement.setAttribute('data-theme',finalTheme);document.documentElement.style.setProperty('color-scheme',finalTheme);themeToggle.setAttribute('data-theme',theme);themeToggle.setAttribute('aria-checked',finalTheme==='dark'?'true':'false');themeOptions.forEach(option=>{option.classList.remove('active');if(option.dataset.theme===theme){option.classList.add('active')}})}function saveTheme(theme){localStorage.setItem('theme',theme)}function getSavedTheme(){return localStorage.getItem('theme')||'system'}function cycleTheme(){const themes=['system','light','dark'];const currentTheme=getSavedTheme();const currentIndex=themes.indexOf(currentTheme);const nextIndex=(currentIndex+1)%themes.length;const nextTheme=themes[nextIndex];applyTheme(nextTheme);saveTheme(nextTheme)}applyTheme(getSavedTheme());themeToggle.addEventListener('click',cycleTheme);themeOptions.forEach(option=>{option.addEventListener('click',(e)=>{e.stopPropagation();const theme=option.dataset.theme;applyTheme(theme);saveTheme(theme)})});window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change',()=>{if(getSavedTheme()==='system'){applyTheme('system')}})</script> <script>function toggleSearchPopup(){const searchPopup=document.getElementById('search-popup');if(searchPopup.style.display==='none'||searchPopup.style.display===''){openSearchPopup()}else{closeSearchPopup()}}function openSearchPopup(){document.getElementById('search-popup').style.display='flex';document.getElementById('search-input').focus();document.body.style.overflow='hidden'}function closeSearchPopup(){document.getElementById('search-popup').style.display='none';document.getElementById('search-input').value='';document.getElementById('search-results').innerHTML='';document.body.style.overflow=''} async function searchPosts(searchTerm){const response=await fetch('/api/search.php?q='+encodeURIComponent(searchTerm));const data=await response.json();return data.results}function displaySearchResults(results){const resultsHtml=results.map(post=>{let postHtml=`<a href="/${post.slug}"> <span class="post-title">${post.title}</span>${post.tags.length ? `<span class="post-tags"> in ${post.tags.join(', ')}</span>` : ''}`;if(post.contentMatch){postHtml+=` <span class="post-tags">& contains '${post.searchTerm}'</span>`} postHtml+=`</a>`;return postHtml}).join('');document.getElementById('search-results').innerHTML=resultsHtml||'<p>No results found</p>'} document.addEventListener('keydown',function(e){if((e.metaKey||e.ctrlKey)&&e.key==='k'){e.preventDefault();e.stopPropagation();toggleSearchPopup()}});document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeSearchPopup()}});document.getElementById('search-link').addEventListener('click',function(e){e.preventDefault();toggleSearchPopup()});document.getElementById('search-popup').addEventListener('click',function(e){if(e.target===this){closeSearchPopup()}});document.getElementById('search-popup').addEventListener('touchstart',function(e){if(e.target===this){closeSearchPopup()}},{passive:!0});document.getElementById('search-input').addEventListener('input',async function(e){const searchTerm=e.target.value;if(searchTerm===''){document.getElementById('search-results').innerHTML=''}else{const results=await searchPosts(searchTerm);displaySearchResults(results)}});</script> <script>document.addEventListener("DOMContentLoaded",()=>{const postId="hseaqExBvzqwknxMjPHq";const upvoteButton=document.getElementById("upvote-button-"+postId);const csrfToken=document.querySelector('#upvote-form-'+postId+' input[name="csrfmiddlewaretoken"]').value;const localStorageKey="upvoted_"+postId;if(localStorage.getItem(localStorageKey)){upvoteButton.disabled=!0;upvoteButton.textContent="Upvoted"} upvoteButton.addEventListener("click",async(event)=>{event.preventDefault();if(!upvoteButton.disabled){try{const response=await fetch(`/api/upvote.php?postId=${postId}`,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","X-CSRF-Token":csrfToken,},});const result=await response.json();if(result.success){localStorage.setItem(localStorageKey,"true");upvoteButton.disabled=!0;upvoteButton.textContent="Upvoted"}else{console.error("Upvote failed:",result.error);alert("Failed to upvote. Please try again.")}}catch(error){console.error("Error:",error);alert("Failed to upvote. Please try again.")}}})});</script> <script>(function(){function formatDateTime(dateString){const date=new Date(dateString);const now=new Date();const options={hour:'2-digit',minute:'2-digit'};const time=date.toLocaleTimeString(undefined,options);if(date.getDate()===now.getDate()&&date.getMonth()===now.getMonth()&&date.getFullYear()===now.getFullYear()){return `Today \u2022 ${time}`}else{const dateOptions={year:'numeric',month:'short',day:'numeric'};const formattedDate=date.toLocaleDateString(undefined,dateOptions);return `${formattedDate} \u2022 ${time}`}} document.addEventListener("DOMContentLoaded",()=>{const postDateElement=document.getElementById("post-date");postDateElement.textContent=formatDateTime(postDateElement.getAttribute("datetime"))})})();</script> <script>function createPreviewElement(){const preview=document.createElement('div');preview.className='link-preview';preview.style.cssText='display:none;position:absolute;line-height:1.3;background:var(--background-color);border:1px solid color-mix(in srgb,var(--text-color) 10%,transparent);border-radius:10px;padding:10px;width:calc(var(--max-width) / 2.5);z-index:1000;';document.body.appendChild(preview);return preview} const preview=createPreviewElement();let currentLink=null;let hideTimeout=null;async function showPreview(link,event){if(window.innerWidth<=768){return} clearTimeout(hideTimeout);const url=link.href;const imageRegex=/\.(jpg|jpeg|png|gif|webp)$/i;const videoRegex=/\.(mp4|webm|ogg)$/i;if(imageRegex.test(url)){preview.innerHTML=`<img src="${url}" style="margin-bottom:-5px;max-width:calc(var(--max-width) / 2.5);min-width:0;border-radius:4px;" />`;preview.style.display='block';positionPreview(event);return} if(videoRegex.test(url)){preview.innerHTML=`<video controls style="max-width:100%;border-radius:4px;"><source src="${url}" type="video/mp4">Your browser does not support the video tag.</video>`;preview.style.display='block';positionPreview(event);return} try{const response=await fetch(`/api/preview.php?url=${encodeURIComponent(url)}`);if(response.status===204){preview.style.display='none';return} const data=await response.json();if(!data.title&&!data.description&&!data.ogImage){preview.style.display='none';return} preview.innerHTML=`${data.title ? `<h4 style="margin:0;font-size:16px;">${data.title}</h4>` : ''} ${data.description ? `<p style="margin:5px 0;font-size:14px;">${data.description}</p>` : ''} ${data.ogImage ? `<img src="${data.ogImage}" style="margin-bottom:-5px;margin-top:5px;max-width:calc(var(--max-width) / 2.5);min-width:0 !important;border-radius:4px;"/>` : ''} `;preview.style.display='block';positionPreview(event)}catch(error){preview.style.display='none'}} function positionPreview(event){const rect=event.target.getBoundingClientRect();const previewHeight=preview.offsetHeight||150;const viewportHeight=window.innerHeight;const scrollY=window.scrollY;const buffer=10;const spaceAbove=rect.top;const spaceBelow=viewportHeight-rect.bottom;preview.style.left=`${rect.left}px`;if(spaceBelow>=previewHeight+buffer){preview.style.top=`${rect.bottom + scrollY + buffer}px`;preview.dataset.position='below'}else if(spaceAbove>=previewHeight+buffer){preview.style.top=`${rect.top + scrollY - previewHeight - buffer}px`;preview.dataset.position='above'}else{if(spaceBelow>spaceAbove){preview.style.top=`${rect.bottom + scrollY + buffer}px`;preview.dataset.position='below'}else{preview.style.top=`${rect.top + scrollY - previewHeight - buffer}px`;preview.dataset.position='above'}} const previewRect=preview.getBoundingClientRect();const previewWidth=preview.offsetWidth;if(previewRect.right>window.innerWidth){preview.style.left=`${window.innerWidth - previewWidth - buffer}px`} if(previewRect.left<0){preview.style.left=`${buffer}px`}} function setupLinkPreview(){document.querySelectorAll('main.single-post p a, main.single-post li a, .message a:not(.orma-date a)').forEach(link=>{link.addEventListener('mouseenter',(event)=>{currentLink=link;showPreview(link,event)});link.addEventListener('mouseleave',()=>{hideTimeout=setTimeout(()=>{if(preview.matches(':hover'))return;preview.style.display='none'},300)})});preview.addEventListener('mouseleave',()=>{hideTimeout=setTimeout(()=>{preview.style.display='none'},300)})} document.addEventListener('DOMContentLoaded',setupLinkPreview);</script> <script>document.querySelectorAll('.post-navigation a').forEach(link=>{link.addEventListener('mouseenter',()=>{link.textContent=link.getAttribute('data-title')});link.addEventListener('mouseleave',()=>{link.textContent=link.id==='prev-post' ? 'Previous':'Next'})});</script> <script>document.addEventListener('keydown',function(e){if(e.key==='ArrowLeft'){const nextLink=document.getElementById('prev-post');if(nextLink){window.location.href=nextLink.href}}else if(e.key==='ArrowRight'){const prevLink=document.getElementById('next-post');if(prevLink){window.location.href=prevLink.href}}})</script> <!--check sp#m source--><script defer data-domain="mgx.me" src="https://d.mgx.me/js/script.js"></script> </footer> </body> </html>