Skip to content

Components / UI

Link Preview

A link that shows a small card (image, title, one line, domain) after a moment on hover or focus, above everything, flipping when there is no room. The card is a manual popover anchored to the link; the script only shows and hides it, the wait is a CSS transition-delay on a duration token, and everything on the card is a prop at build time, so nothing is fetched. Touch gets the link alone.

Live demo

Built on AstroAstroThe web framework for content-driven websites.astro.build, with curves inspired by motion.devMotionThe animation library that inspired the presets, sampled into linear().motion.dev, and nothing fetched on hover.

hover or focus a link

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

671 B raw

405 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.3 kB raw.

CSS 7.1 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 125 · Firefox 147 · Safari 26; needs popover, anchor, starting-style, allow-discrete. Elsewhere: without anchor positioning the script places the card under the link; without the popover attribute the link is a link.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import LinkPreview from '@moonarc/core/LinkPreview';
---
<p>
  Built on
  <LinkPreview href="https://astro.build" title="Astro" description="The web framework for content-driven websites." image="/previews/astro.png">Astro</LinkPreview>
  and measured on every commit.
</p>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/link-preview.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

LinkPreview.astro
---
/**
 * LinkPreview — a link that shows a small card (image, title, domain) after
 * a moment on hover or focus. The card is a manual popover anchored to the
 * link, so it sits in the top layer above everything and flips when there is
 * no room; the script only shows and hides it. The hover intent is CSS: the
 * card's entrance carries a transition-delay on a duration token, so a
 * pointer crossing the link never flashes a card, and no timer runs in the
 * script. The card can be hovered (the gap to the link is part of it) and
 * Escape hides it wherever the pointer is: WCAG 1.4.13. Everything on the
 * card comes from props at build — there is no fetch, no scraping. Touch and
 * coarse pointers get the link alone.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'a'> {
  href: string;
  /** Card title. */
  title: string;
  /** Card image (URL); 16:9, covers. */
  image?: string;
  /** One line under the title. */
  description?: string;
  /** Shown as the source; default the host of href. */
  domain?: string;
  /** The wait before the card shows, as a duration token. */
  delay?: 'fast' | 'base' | 'slow';
  /** position-area of the card. */
  placement?: string;
  /** Popover id; default generated. */
  id?: string;
}

const { href, title, image, description, domain, delay = 'base', placement = 'bottom', id = `ma-lp-${Math.random().toString(36).slice(2, 7)}`, class: className, style, ...rest } = Astro.props;
const host = (() => {
  if (domain) return domain;
  try {
    const h = new URL(href, 'https://x.invalid').host.replace(/^www\./, '');
    return h === 'x.invalid' ? undefined : h;
  } catch {
    return undefined;
  }
})();
const anchor = `--ma-lp-${id}`;
const cardStyle = `position-anchor:${anchor};--ma-lpreview-area:${placement}`;
---

<span class:list={['ma-lpreview', className]} data-ma-lpreview data-delay={delay} style={typeof style === 'string' ? style : undefined}>
  <a href={href} class="ma-lpreview__link" aria-describedby={id} style={`anchor-name:${anchor}`} {...rest}><slot /></a>
  <span id={id} class="ma-lpreview__card" popover="manual" role="tooltip" style={cardStyle}>
    {image && <img class="ma-lpreview__img" src={image} alt="" loading="lazy" decoding="async" />}
    <span class="ma-lpreview__body">
      <strong class="ma-lpreview__title">{title}</strong>
      {description && <span class="ma-lpreview__desc">{description}</span>}
      {host && <span class="ma-lpreview__domain">{host}</span>}
    </span>
  </span>
</span>

<style is:global>
  @layer components {
    :where(.ma-lpreview) {
      --ma-lpreview-delay: var(--ma-duration);
    }
    :where(.ma-lpreview[data-delay='fast']) {
      --ma-lpreview-delay: var(--ma-duration-fast);
    }
    :where(.ma-lpreview[data-delay='slow']) {
      --ma-lpreview-delay: var(--ma-duration-slow);
    }
    :where(.ma-lpreview__link) {
      color: inherit;
      text-decoration: underline;
      text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
      text-decoration-thickness: 1px;
      text-underline-offset: 0.2em;
      transition: text-decoration-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-lpreview__link:is(:hover, :focus-visible)) {
      text-decoration-color: currentColor;
    }
    :where(.ma-lpreview__link:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
      border-radius: 2px;
    }
    /* the card: top layer, anchored, and its entrance waits on the delay token — the hover intent is CSS */
    :where(.ma-lpreview__card) {
      position: fixed;
      inset: auto;
      position-area: var(--ma-lpreview-area, bottom);
      position-try-fallbacks: flip-block, flip-inline;
      margin: 0.5rem 0;
      width: 16rem;
      padding: 0;
      border: 1px solid var(--ma-edge);
      border-radius: 0.75rem;
      background: var(--ma-panel);
      color: var(--ma-ink);
      box-shadow: 0 16px 40px -20px rgb(0 0 0 / 0.45);
      /* not the popover's own overflow: auto, which would clip the bridge below; the image rounds its own corners */
      overflow: visible;
      pointer-events: none;
      opacity: 0;
      scale: 0.96;
      translate: 0 calc(var(--ma-travel-hover, 4px) * -1);
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        scale var(--ma-duration-fast) var(--ma-ease-out),
        translate var(--ma-duration-fast) var(--ma-ease-out),
        display var(--ma-duration-fast) allow-discrete,
        overlay var(--ma-duration-fast) allow-discrete,
        visibility var(--ma-duration-fast);
    }
    /* open, the card takes the pointer, but only once the delay is over: until then it is visibility: hidden, so a pointer
       that crosses the link and moves on reaches what is under the card and never raises it */
    :where(.ma-lpreview__card:popover-open) {
      pointer-events: auto;
      opacity: 1;
      scale: 1;
      translate: 0 0;
      transition-duration: var(--ma-duration), var(--ma-duration), var(--ma-duration), 0s, 0s, 0s;
      transition-timing-function: var(--ma-ease), var(--ma-ease), var(--ma-ease), linear, linear, linear;
      transition-delay: var(--ma-lpreview-delay), var(--ma-lpreview-delay), var(--ma-lpreview-delay), 0s, 0s, var(--ma-lpreview-delay);
    }
    @starting-style {
      :where(.ma-lpreview__card:popover-open) {
        opacity: 0;
        scale: 0.96;
        translate: 0 calc(var(--ma-travel-hover, 4px) * -1);
        visibility: hidden;
      }
    }
    :where(.ma-lpreview__card)::backdrop {
      background: transparent;
    }
    /* the gap between the link and the card belongs to the card, so the pointer can cross onto it and read it (WCAG
       1.4.13: hoverable): the margin, the 1 px border (the inset starts inside it) and 1 px of rounding. Under the
       content, so the card's own text stays the target. */
    :where(.ma-lpreview__card)::before {
      content: '';
      position: absolute;
      z-index: -1;
      inset: calc(-0.5rem - 2px) 0;
    }
    :where(.ma-lpreview__img) {
      display: block;
      width: 100%;
      border-radius: calc(0.75rem - 1px) calc(0.75rem - 1px) 0 0;
      aspect-ratio: 16 / 9;
      object-fit: cover;
      background: var(--ma-edge);
    }
    :where(.ma-lpreview__body) {
      display: grid;
      gap: 0.15rem;
      padding: 0.625rem 0.75rem 0.75rem;
      font-size: 0.8125rem;
      line-height: 1.35;
      text-align: start;
    }
    :where(.ma-lpreview__title) {
      font-weight: 600;
    }
    :where(.ma-lpreview__desc) {
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 2;
      overflow: hidden;
      opacity: 0.7;
    }
    :where(.ma-lpreview__domain) {
      margin-top: 0.2rem;
      font-family: ui-monospace, monospace;
      font-size: 0.6875rem;
      letter-spacing: 0.03em;
      opacity: var(--ma-dim, 0.7);
    }
    /* no anchor positioning: the script hands the link's box over as two lengths */
    @supports not (position-area: bottom) {
      :where(.ma-lpreview__card) {
        inset: auto;
        left: var(--ma-lpreview-x, 0px);
        top: var(--ma-lpreview-y, 0px);
      }
    }
    /* touch and coarse pointers: the link alone (the script does not bind either) */
    @media (hover: none), (pointer: coarse) {
      :where(.ma-lpreview__card) {
        display: none;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-lpreview__card),
      :where(.ma-lpreview__card:popover-open) {
        scale: 1;
        translate: 0 0;
        transition-duration: var(--ma-duration-fast);
        transition-timing-function: linear;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-lpreview]', (el, { signal }) => {
    if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    const link = el.querySelector('a')!;
    const card = el.querySelector<HTMLElement>('[popover]')!;
    const place = !CSS.supports('position-area: bottom');
    const show = () => {
      if (place) {
        // no anchor positioning: under the link, from its box
        const r = link.getBoundingClientRect();
        card.style.setProperty('--ma-lpreview-x', `${r.left}px`);
        card.style.setProperty('--ma-lpreview-y', `${r.bottom}px`);
      }
      card.togglePopover(true);
    };
    const hide = () => card.togglePopover(false);
    const on = (t: string, f: () => void, target: HTMLElement = link) => target.addEventListener(t, f, { signal });
    // the pointer on the wrapper, not the link: the card is the wrapper's child, so moving from the link onto the card
    // leaves neither, and the card stays while it is read
    on('pointerenter', show, el);
    on('pointerleave', hide, el);
    on('focus', show);
    on('blur', hide);
    // Escape hides it wherever the focus is: a pointer resting on the link has no focus on it
    document.addEventListener('keydown', (e) => e.key === 'Escape' && hide(), { signal });
    signal.addEventListener('abort', hide);
  });
</script>

Props

PropTypeDefaultDescription
hrefstringnoneThe link.
titlestringnoneCard title.
imagestringnoneCard image, 16:9, covers.
descriptionstringnoneOne or two lines under the title.
domainstringnoneSource line; default the host of href.
delay'fast' | 'base' | 'slow''base'The wait before the card shows: the fast, active-preset or slow duration token.
placementstring'bottom'A position-area value.
idstringnonePopover id; generated when unset.

Reduced motion

The card cross-fades in without the scale, after the same wait.

With ClientRouter

Bound through the shared runtime; the card is hidden before the swap so nothing stays in the top layer, and the document Escape listener goes with the binding.

Why it is built this way

Replaces: LinkPreview (Aceternity) · HoverCard (shadcn / Radix) · LinkPreview (Motion Primitives). See the migration table.

Cursor

Pointer

A custom pointer that trails the real one: a dot, a ring that grows over links and buttons, or a label ("View") any element can ask for with data-cursor-label.

every browserpointer

Raw bytes of JavaScript, from a production build, measured again for every change that can move them.

A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.

newly · Chrome 125 · Firefox 147 · Safari 26click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown