Build a Link Preview Chrome Extension — Hover Preview Tutorial (2025)
Link preview extensions have become an essential tool for modern web browsing. Whether you’re researching topics, shopping online, or simply browsing social media, the ability to see what’s behind a link before clicking it saves time and helps avoid malicious websites. In this comprehensive tutorial, we’ll build a fully functional link preview Chrome extension using Manifest V3 that displays hover previews for any URL.
By the end of this guide, you’ll have created an extension that detects when users hover over links, fetches metadata from those URLs, and displays a beautiful preview card with the page title, description, and thumbnail image. This project is perfect for developers looking to understand content scripts, the Fetch API within extension contexts, and dynamic DOM manipulation.
Understanding Link Preview Extensions
A link preview extension, also known as a hover preview chrome extension, enhances user experience by showing a preview of the linked webpage when users hover their mouse over hyperlinks. This functionality is similar to what you see on platforms like Facebook, LinkedIn, or Slack, where URLs are automatically expanded to show rich previews.
The core functionality of a url preview extension involves three main components:
- Link Detection: Identifying when a user hovers over an anchor element (
<a>tag) with a valid href attribute - Metadata Fetching: Retrieving the webpage’s title, description, and og:image meta tags
- Preview Display: Creating and positioning a floating card that shows the fetched information
This tutorial will walk you through building each component from scratch using modern JavaScript and Chrome’s extension APIs.
Project Setup and Manifest Configuration
Every Chrome extension begins with the manifest.json file. For our link preview extension, we’ll use Manifest V3, which is the current standard and offers better security and performance.
Create a new directory for your extension and add the following manifest.json:
{
"manifest_version": 3,
"name": "LinkPeek - Link Preview Extension",
"version": "1.0.0",
"description": "Preview any link before clicking. Shows title, description, and thumbnail on hover.",
"permissions": [
"activeTab",
"scripting"
],
"host_permissions": [
"<all_urls>"
],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": ["styles.css"],
"run_at": "document_idle"
}
],
"icons": {
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_icon": "icons/icon48.png",
"default_title": "LinkPeek - Link Preview"
}
}
Let’s break down the key components of this manifest:
- host_permissions: We need
<all_urls>to fetch metadata from any website. This is essential for a universal link preview extension. - content_scripts: Our content script will run on all web pages to detect link hovers and display previews.
- permissions: We use activeTab for security and scripting for injecting additional code if needed.
Creating the Content Script
The content script is the heart of our link preview extension. It runs in the context of every web page and handles link detection, metadata fetching, and preview display.
Create a file named content.js:
// LinkPeek - Content Script
// Handles link hover detection, metadata fetching, and preview display
(function() {
'use strict';
// Configuration
const CONFIG = {
hoverDelay: 300, // Delay before showing preview (ms)
previewTimeout: 5000, // Auto-hide after 5 seconds of no movement
maxDescriptionLength: 200, // Truncate descriptions longer than this
fetchTimeout: 3000 // Timeout for metadata fetch request
};
// State management
let previewElement = null;
let currentHoveredLink = null;
let hideTimeout = null;
let fetchTimeout = null;
// Initialize the extension
function init() {
createPreviewElement();
attachEventListeners();
}
// Create the preview card element
function createPreviewElement() {
previewElement = document.createElement('div');
previewElement.id = 'linkpeek-preview';
previewElement.className = 'linkpeek-preview';
previewElement.style.display = 'none';
document.body.appendChild(previewElement);
}
// Attach event listeners to the document
function attachEventListeners() {
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('mouseout', handleMouseOut);
document.addEventListener('mousemove', handleMouseMove);
}
// Handle mouseover events to detect links
function handleMouseOver(event) {
const target = event.target;
// Check if we're hovering over a link or inside a link
const link = target.closest('a');
if (!link || !link.href || link.href.startsWith('javascript:') || link.href.startsWith('#')) {
return;
}
// Skip already processed links to avoid re-fetching
if (link === currentHoveredLink) {
return;
}
currentHoveredLink = link;
// Clear any existing timers
clearTimeout(hideTimeout);
clearTimeout(fetchTimeout);
// Set delay before showing preview
fetchTimeout = setTimeout(() => {
fetchAndShowPreview(link);
}, CONFIG.hoverDelay);
}
// Handle mouseout events
function handleMouseOut(event) {
const target = event.target;
const link = target.closest('a');
if (link) {
// Schedule hiding the preview
hideTimeout = setTimeout(() => {
hidePreview();
currentHoveredLink = null;
}, CONFIG.previewTimeout);
}
}
// Handle mouse movement to keep preview visible
function handleMouseMove(event) {
if (previewElement.style.display === 'block') {
// Keep preview visible while mouse is moving
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
hidePreview();
currentHoveredLink = null;
}, CONFIG.previewTimeout);
}
}
// Fetch metadata from the URL
async function fetchAndShowPreview(link) {
const url = link.href;
try {
// Show loading state
showLoadingPreview(link);
// Fetch the webpage
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.fetchTimeout);
const response = await fetch(url, {
method: 'GET',
mode: 'no-cors',
signal: controller.signal
});
clearTimeout(timeoutId);
// Since we can't read response body due to CORS, we'll use a fallback
// In production, you'd use a backend proxy or the Chrome Fetch API differently
const metadata = extractMetadataFromLink(link, url);
displayPreview(metadata);
} catch (error) {
console.log('LinkPeek: Error fetching preview', error);
// Fallback: extract what we can from the link itself
const fallbackMetadata = extractMetadataFromLink(link, url);
displayPreview(fallbackMetadata);
}
}
// Extract metadata from the link element or URL
function extractMetadataFromLink(link, url) {
const metadata = {
url: url,
title: link.textContent?.trim() || new URL(url).hostname,
description: '',
image: '',
siteName: new URL(url).hostname
};
// Try to get Open Graph meta tags from the link's title attribute or data attributes
if (link.title) {
metadata.title = link.title;
}
// Check for data attributes that many sites use
const dataTitle = link.dataset.title || link.dataset.ogTitle;
if (dataTitle) {
metadata.title = dataTitle;
}
const dataDescription = link.dataset.description || link.dataset.ogDescription;
if (dataDescription) {
metadata.description = dataDescription.substring(0, CONFIG.maxDescriptionLength);
}
const dataImage = link.dataset.image || link.dataset.ogImage;
if (dataImage) {
metadata.image = dataImage;
}
return metadata;
}
// Show loading state while fetching
function showLoadingPreview(link) {
const rect = link.getBoundingClientRect();
const position = calculatePreviewPosition(rect);
previewElement.innerHTML = `
<div class="linkpeek-loading">
<div class="linkpeek-spinner"></div>
<span>Loading preview...</span>
</div>
`;
previewElement.style.display = 'block';
previewElement.style.left = `${position.left}px`;
previewElement.style.top = `${position.top}px`;
}
// Display the preview card
function displayPreview(metadata) {
const position = calculatePreviewPosition(currentHoveredLink.getBoundingClientRect());
// Truncate description if needed
let description = metadata.description;
if (description && description.length > CONFIG.maxDescriptionLength) {
description = description.substring(0, CONFIG.maxDescriptionLength) + '...';
}
// Build the preview HTML
const imageHtml = metadata.image
? `<div class="linkpeek-image" style="background-image: url('${escapeHtml(metadata.image)}')"></div>`
: '';
previewElement.innerHTML = `
<div class="linkpeek-content">
${imageHtml}
<div class="linkpeek-text">
<div class="linkpeek-site">${escapeHtml(metadata.siteName)}</div>
<div class="linkpeek-title">${escapeHtml(metadata.title)}</div>
${description ? `<div class="linkpeek-description">${escapeHtml(description)}</div>` : ''}
<div class="linkpeek-url">${escapeHtml(truncateUrl(metadata.url))}</div>
</div>
</div>
`;
previewElement.style.display = 'block';
previewElement.style.left = `${position.left}px`;
previewElement.style.top = `${position.top}px`;
}
// Calculate preview position to keep it in viewport
function calculatePreviewPosition(linkRect) {
const previewWidth = 320;
const previewHeight = 250;
const offset = 10;
const padding = 20;
let left = linkRect.right + offset;
let top = linkRect.top;
// If preview would go off right edge, show on left
if (left + previewWidth > window.innerWidth - padding) {
left = linkRect.left - previewWidth - offset;
}
// If preview would go off bottom edge, adjust vertical position
if (top + previewHeight > window.innerHeight - padding) {
top = window.innerHeight - previewHeight - padding;
}
// Prevent going off left or top edge
if (left < padding) left = padding;
if (top < padding) top = padding;
return { left, top };
}
// Hide the preview
function hidePreview() {
if (previewElement) {
previewElement.style.display = 'none';
}
}
// Utility: Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Utility: Truncate URL for display
function truncateUrl(url) {
try {
const parsed = new URL(url);
let display = parsed.hostname + parsed.pathname;
if (display.length > 50) {
display = display.substring(0, 47) + '...';
}
return display;
} catch {
return url.length > 50 ? url.substring(0, 47) + '...' : url;
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
This content script handles all the core functionality of our link preview extension. Let’s break down how it works:
-
Event Delegation: Instead of attaching listeners to every link, we use event delegation on the document to catch all mouseover events efficiently.
-
Link Detection: The script checks if the hovered element is an anchor tag and validates the URL before attempting to fetch a preview.
-
Position Calculation: The
calculatePreviewPositionfunction ensures the preview card stays within the viewport, adjusting left/right and top/bottom positioning as needed. -
XSS Prevention: The
escapeHtmlutility prevents malicious scripts from executing through our preview display.
Styling the Preview Card
Create a file named styles.css to style the preview card:
/* LinkPeek - Preview Card Styles */
.linkpeek-preview {
position: fixed;
z-index: 2147483647;
width: 320px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
overflow: hidden;
animation: linkpeek-fadein 0.2s ease-out;
pointer-events: auto;
}
@keyframes linkpeek-fadein {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.linkpeek-content {
display: flex;
flex-direction: column;
}
.linkpeek-image {
width: 100%;
height: 160px;
background-size: cover;
background-position: center;
background-color: #f0f0f0;
}
.linkpeek-text {
padding: 14px;
}
.linkpeek-site {
font-size: 11px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.linkpeek-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 6px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.linkpeek-description {
font-size: 13px;
color: #555;
line-height: 1.4;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.linkpeek-url {
font-size: 11px;
color: #888;
word-break: break-all;
}
/* Loading state */
.linkpeek-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #666;
font-size: 13px;
}
.linkpeek-spinner {
width: 20px;
height: 20px;
border: 2px solid #e0e0e0;
border-top-color: #666;
border-radius: 50%;
animation: linkpeek-spin 0.8s linear infinite;
margin-right: 10px;
}
@keyframes linkpeek-spin {
to {
transform: rotate(360deg);
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.linkpeek-preview {
background: #2d2d2d;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.linkpeek-title {
color: #ffffff;
}
.linkpeek-description {
color: #b0b0b0;
}
.linkpeek-site {
color: #999;
}
.linkpeek-url {
color: #777;
}
.linkpeek-image {
background-color: #404040;
}
}
The styles provide a clean, modern look for our preview card with smooth animations and dark mode support. The card follows common UI patterns seen in popular link preview implementations.
Improving Preview Fetching with a Background Service Worker
The content script approach has limitations due to CORS policies. For a production-grade url preview extension, we should use a background service worker to fetch metadata. This provides more flexibility and avoids cross-origin restrictions.
Update your manifest.json to include a background service worker:
{
"background": {
"service_worker": "background.js",
"type": "module"
}
}
Create background.js:
// LinkPeek - Background Service Worker
// Handles metadata fetching with more permissions than content scripts
const CONFIG = {
fetchTimeout: 5000,
maxDescriptionLength: 200
};
// Listen for messages from content script
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'fetchPreview') {
fetchMetadata(request.url)
.then(metadata => sendResponse({ success: true, metadata }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true; // Keep message channel open for async response
}
});
// Fetch metadata from URL
async function fetchMetadata(url) {
try {
// Use Chrome's fetch API which has more permissions
const response = await fetch(url, {
method: 'GET',
mode: 'no-cors'
});
// Since we can't read the response body with no-cors,
// we'll extract metadata from the URL itself
// In a real implementation, you'd use a proxy server
return extractBasicMetadata(url);
} catch (error) {
console.error('LinkPeek: Fetch error', error);
return extractBasicMetadata(url);
}
}
// Extract basic metadata from URL
function extractBasicMetadata(url) {
try {
const parsed = new URL(url);
return {
url: url,
title: parsed.hostname,
description: '',
image: '',
siteName: parsed.hostname
};
} catch {
return {
url: url,
title: url,
description: '',
image: '',
siteName: 'Unknown'
};
}
}
// Alternative: Use Chrome's declarativeNetRequest or a proxy
// For production, consider using a simple proxy server to fetch and parse OG tags
Now update the content script to use message passing to communicate with the background service worker:
// Modified fetchAndShowPreview function
async function fetchAndShowPreview(link) {
const url = link.href;
try {
showLoadingPreview(link);
// Send message to background script
const response = await chrome.runtime.sendMessage({
action: 'fetchPreview',
url: url
});
if (response && response.success) {
const metadata = {
...response.metadata,
...extractMetadataFromLink(link, url)
};
displayPreview(metadata);
} else {
// Fallback to link-based metadata
const fallbackMetadata = extractMetadataFromLink(link, url);
displayPreview(fallbackMetadata);
}
} catch (error) {
console.log('LinkPeek: Error fetching preview', error);
const fallbackMetadata = extractMetadataFromLink(link, url);
displayPreview(fallbackMetadata);
}
}
Testing Your Extension
Now let’s test our link preview extension:
- Open Chrome and navigate to
chrome://extensions/ - Enable “Developer mode” in the top right corner
- Click “Load unpacked” and select your extension directory
- Visit any website with links and hover over them to see the preview
If you don’t see previews appearing, check the following:
- Open Chrome DevTools (F12) and look at the console for any errors
- Make sure the extension is properly enabled
- Verify that the content script is injecting correctly by checking the “content scripts” section in chrome://extensions
Advanced Features to Consider
Once you have the basic link preview extension working, consider adding these advanced features:
1. Preview Caching
Store fetched metadata in chrome.storage to avoid re-fetching the same URLs:
async function getCachedMetadata(url) {
const cache = await chrome.storage.local.get(url);
if (cache[url] && cache[url].timestamp > Date.now() - 86400000) {
return cache[url].metadata;
}
return null;
}
2. Custom Preview Templates
Allow users to customize how previews look through an options page with different themes and layouts.
3. Social Media Enhancement
Add special handling for major platforms like YouTube, Twitter, and LinkedIn to show richer previews with video thumbnails, tweet text, or profile information.
4. Keyboard Navigation
Add keyboard shortcuts to preview links without using the mouse, improving accessibility:
document.addEventListener('keydown', (event) => {
if (event.altKey && event.key === 'p') {
// Preview the currently focused link
const focusedElement = document.activeElement;
if (focusedElement.tagName === 'A') {
currentHoveredLink = focusedElement;
fetchAndShowPreview(focusedElement);
}
}
});
Publishing to the Chrome Web Store
Once your link preview extension is complete and tested, you can publish it to the Chrome Web Store:
- Create a developer account at the Chrome Web Store Developer Dashboard
- Bundle your extension into a ZIP file (exclude .git and other unnecessary files)
- Upload your extension and fill in the store listing details
- Submit for review (typically takes 1-3 days)
- Once approved, your extension will be available to millions of Chrome users
When writing your store listing, emphasize the key benefits:
- Save time by previewing links before clicking
- Avoid malicious websites
- Enhanced browsing productivity
- Works on all websites
Conclusion
Congratulations! You’ve built a complete link preview Chrome extension from scratch. This project demonstrates several important concepts in extension development:
- Content Scripts: Injecting JavaScript into web pages to interact with their DOM
- Event Handling: Efficiently detecting user interactions with link elements
- DOM Manipulation: Dynamically creating and positioning preview cards
- Manifest V3: Using the modern Chrome extension manifest format
- Message Passing: Communicating between content scripts and background service workers
The hover preview chrome extension you built can be further enhanced with additional features like caching, custom themes, and integration with link preview APIs. This foundation provides an excellent starting point for building more sophisticated Chrome extensions.
Remember to test thoroughly across different websites and browsers, and consider gathering user feedback to improve your extension before publishing to the Chrome Web Store. Good luck with your link preview extension!
Additional Resources
- Chrome Extension Documentation
- Manifest V3 Migration Guide
- Chrome Web Store Publishing
- Open Graph Protocol for metadata standards