# Link Preview (Moonarc)

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.

- Import: `import LinkPreview from '@moonarc/core/LinkPreview'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/link-preview.json`
- Tier B · category ui · trigger hover
- Readout: `<LinkPreview delay="base">`
- Browser support: newly (Chrome 125 · Firefox 147 · Safari 26); elsewhere: without anchor positioning the script places the card under the link; without the popover attribute the link is a link
- Measured cost: 671 B raw JS · 405 B gzip · + runtime (with dependencies 2.3 kB raw; CSS 7.1 kB raw)
- Page: https://moonarc.dev/components/link-preview/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `href` | `string` | none | The link. |
| `title` | `string` | none | Card title. |
| `image` | `string` | none | Card image, 16:9, covers. |
| `description` | `string` | none | One or two lines under the title. |
| `domain` | `string` | none | Source 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. |
| `placement` | `string` | `'bottom'` | A position-area value. |
| `id` | `string` | none | Popover id; generated when unset. |

## Usage

```astro
<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>
```

## 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.

## Craft

- The hover intent is a transition-delay, not a setTimeout: the card enters the top layer at once but its opacity waits on the delay token, and a pointer that leaves early cancels a transition that never showed anything. No timer to clear, nothing to read from a token in JavaScript.
- A manual popover: the top layer puts the card above sticky headers and overflow: clip, and anchor positioning flips it below or above the link by itself.
- The card can be hovered: the gap between it and the link is the card's own ::before, and the pointer is watched on the wrapper that holds both, so moving onto the card to read it keeps it open. Escape hides it wherever the focus is. Together that is WCAG 1.4.13 (hoverable, dismissible, persistent).
- The card takes the pointer only once it shows. Through the wait it is visibility: hidden on the same delay token, so a pointer that crosses the link on its way elsewhere never lands on it, and a click there reaches the page.
- role="tooltip" with aria-describedby from the link: a screen reader hears the title and description as the link's description without any hover.
- interestfor (Chrome 142) would make this 0 B: a hover-intent popover with no script. It is one engine today, listed in the audit as the C route.

## Replaces

- LinkPreview (Aceternity)
- HoverCard (shadcn / Radix)
- LinkPreview (Motion Primitives)

## Source

```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>

```
