Published on December 06, 2021

Preview link details before navigation

6 min read

Preview link details before navigation

When I added the Tips section in this website, I noticed that I had many outbound links to some nice resources across the web. This makes it harder for a user to decide which link to navigate to at first. It can cause a drop in interaction with the site.

And because of this problem, I decided to implement a tiny link preview popup that the user can see when they hover over a link. This post talks about how you can also implement it.

The example code in this post is written for SvelteKit, but can easily be ported for other frameworks.

Expectation

When user hover’s a link:

  • Fetch link details, after a few milliseconds, so that it doesn’t happen during unintentional hovering
  • Show link title, description and a preview image (if available)
  • Cache the response since it’s (mostly) not going to change frequently

Execution Plan

Since the links can be of any website and not just internal links, it is not possible to query the metadata from the browser. That would trigger a CORS error since the link is from a different domain.

So in order to get this information, we need to do it on the server. This website is using SvelteKit, and so I can add an API route to execute a serverless function for the same.

Once the data is fetched, showing it in the client is straight forward. But since there can be users who hover the same link multiple times thinking that it’s hard-coded information, I can also add in a store in UI which can save this information temporarily in memory. This is in addition to the request caching in CDN.

The page URL for which metadata needs to be fetched is passed as a query param in GET request, allowing us to cache the exact URL’s response on the server.

Implementation

API

In order to parse the HTML to retrieve information about metatags, I tried to use metascraper. This is by far the best package available out there.

Although I managed to get it working as required, I was soon hit with a problem when I tried to deploy it. The vercel adapter for SvelteKit complained about dependency issue from a nested package @metascraper/helpers. It has something to do with node bindings for re2. After some efforts I decide to scrap it and go with url-metadata which does exactly the same operation as metascraper.

// api/metadata.js
import urlMetadata from 'url-metadata';
import type { RequestHandler } from '@sveltejs/kit';

const allowedOrigin = {
  'Access-Control-Allow-Origin': '<your-domain>'
};

export const get: RequestHandler = async (req) => {
  // Fetch the URL query param for which the metadata needs to be fetched
  
  const queryUrl = req.url.searchParams.get('url');

  // Error when no URL is passed
  if (!queryUrl) {
    return {
      status: 401,
      body: { error: 'Invalid URL' },
      headers: allowedOrigin
    };
  }

  try {
    // Fetch metadata
    const metadata = await urlMetadata(queryUrl, { maxRedirects: 1 });

    // Error response when page has no title configured.
    // Nothing to show to user
    if (!metadata.title) throw new Error();

    // Caching the response at the edge for 1 hour.
    return {
      status: 200,
      headers: {
        'Cache-Control': 's-maxage=43200', // Cache header for CDN
        ...allowedOrigin
      },
      body: JSON.stringify({
        title: metadata.title,
        image: metadata.image || '',
        description: metadata.description || ''
      })
    };
  } catch {
    // Return error
  }
};

Frontend

In order to listen to hover events (mouseover) in the page, I decide to add a single event listener to the container instead of adding multiple event listeners for each of the links in the page. And since I need to page to be loaded, I did this in the onMount lifecycle hook of svelte.

let linkElt = null; // Anchor Element which is hovered
let linkTimer; // Saving timer to clear it later
let linkDetails = null; // TO store link details to display in UI
let linkPosition = { x: 0, y: 0 }; // Position of the Popup

// Reset function when popup closes
function resetLinkPopup() {
  linkElt.removeEventListener('mouseleave', resetLinkPopup);
  linkElt = null;
  linkDetails = null;
  clearTimeout(linkTimer);
}
...
// on Mount
postContainer.addEventListener('mouseover', (event) => {
  // Skip all hover targets other than anchor
  const target = event.target;
  if (target.tagName !== 'A' || target === linkElt) return;

  // Same current anchor to calculate the popup position
  linkElt = target;
  if (linkTimer) clearTimeout(linkTimer);

  // Remove popup when mouse leaves the link, also remove this event listener
  linkElt.addEventListener('mouseleave', resetLinkPopup);

  // Timer to delay fetching data immediately
  linkTimer = setTimeout(async () => {
    // linkCacheValue - Cached data from the store
    linkDetails = linkCacheValue[linkElt.href];
    if (!linkDetails) {
      let response = await fetch(`/api/metadata?url=${encodeURIComponent(linkElt.href)}`).then((data) =>
        data.json()
      );
      if (!response.title) return;

      linkDetails = {
        title: response.title,
        image: response.image,
        description: response.description
      };

      // Update cache
      linkCache.set({
        ...linkCacheValue,
        [linkElt.href]: linkDetails
      });
    }

    `linkPosition` = {
      x: linkElt.offsetTop + 25,
      y: linkElt.offsetLeft
    };
  }, 250);
});

After this setup, I will have linkDetails populated with required details about the link and linkPosition with the exact position at which the popup needs to be displayed. With this information, I can move to template.

Template is very straight-forward. An absolutely positioned element which is rendered only when we have the above details.

{#if linkDetails}
  <div
    class="absolute shadow-xl rounded bg-zinc-50 dark:bg-gray-700 max-w-xs"
    style="top:{linkPosition.x}px;left:{linkPosition.y}px "
    bind:this={popupElt}
  >
  <!-- Render image only when its available;
       Fixed size image to avoid visible layout shift in popup -->
    {#if linkDetails.image}
      <div class="h-40">
        <img
          src={getImgUrl(linkElt.href, linkDetails.image)} // 
          class="h-full object-cover mx-auto mb-2"
          alt={linkDetails.title}
        />
      </div>
    {/if}
    <div class="font-medium px-4 my-2">{linkDetails.title}</div>
    {#if linkDetails.description}
      <div class="text-sm opacity-70 px-4 mb-4 line-clamp-3">{linkDetails.description}</div>
    {/if}
  </div>
{/if}

The image and description are rendered only if the corresponding information is available. Title will always be available, else the API will return an error and thereby not showing the popup.

If you pay attention to the above code, you would notice a helper function being used for fetching the image link getImgUrl.

function getImgUrl(link, image) {
  if (image.startsWith('/')) {
    const url = new URL(link);
    return url.origin + image;
  }
  return image;
}

This function is required because some websites use relative URL for OG Images which are used for preview. Because of it, we need to convert it to an absolute link. If not, we would end up with a broken image.

Exceptions

The websites that are purely client-side rendered will not have any details other than the title for preview. This is a caveat, but it’s very trivial since most of the publicly available websites will have some form of SEO activity.

Improvements

Since the preview images that are loaded can be data-intensive for weaker networks, It’s better to configure images to load only when the user’s network is 4G.

This is possible using Network Information API but it’s only supported in Chromium based browsers at the moment of writing this post.

Demo

Demo

🎉 Interested in Frontend or Indie-hacking?

I talk about the latest in frontend, along with my experience in building various (Indie) side-projects