Cursor
PointerA 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.
Components / UI
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.
Built on AstroAstroThe web framework for content-driven websites.astro.build, with curves inspired by motion.dev
MotionThe 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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
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.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/link-preview.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* 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>| 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. |
The card cross-fades in without the scale, after the same wait.
ClientRouterBound 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.
Replaces: LinkPreview (Aceternity) · HoverCard (shadcn / Radix) · LinkPreview (Motion Primitives). See the migration table.
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.
A list of project links: hovering one floats its image beside the pointer, leaning the way the pointer moves, while the other links step back.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown