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.
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. One floating box per list with every image server-rendered in it; the script writes two lengths, a lean and an index, and CSS gives the follow its lag on the fast token. Keyboard focus and reduced motion show the image at a fixed spot; touch gets the list.
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
597 B raw
359 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.3 kB raw.
CSS 6.6 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 105 · Firefox 121 · Safari 15.4; needs has. Elsewhere: without :has() the box stays hidden.
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 HoverImageLinks from '@moonarc/core/HoverImageLinks';
---
<HoverImageLinks
items={[
{ href: '/work/atlas/', title: 'Atlas', meta: 'Identity · 2025', image: '/work/atlas.jpg' },
{ href: '/work/lumen/', title: 'Lumen', meta: 'Web · 2025', image: '/work/lumen.jpg' },
{ href: '/work/field/', title: 'Field notes', meta: 'Editorial · 2024', image: '/work/field.jpg' },
]}
/>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/hover-image-links.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.
---
/**
* HoverImageLinks — a list of project links; hovering one floats its image
* beside the pointer, leaning the way the pointer moves. One floating box
* per list, not per item: every image is server-rendered in it, lazy, and
* the hovered item picks which one shows through one number. The pointer
* writes two lengths and a lean; CSS gives the follow its lag on the fast
* token and the exit curve (the Cursor pattern), and :has() shows the box
* while a link is hovered. Keyboard focus shows the image at a fixed spot
* on the right; so does reduced motion; touch gets the list.
*/
import type { HTMLAttributes } from 'astro/types';
interface Item {
href: string;
title: string;
image: string;
/** A mono line beside the title (year, category). */
meta?: string;
}
interface Props extends HTMLAttributes<'div'> {
items: Item[];
/** Width of the floating image in px. */
size?: number;
/** Maximum lean in degrees. */
tilt?: number;
/** Accessible name of the list. */
label?: string;
}
const { items, size = 220, tilt = 6, label = 'Projects', class: className, style, ...rest } = Astro.props;
const list = items.slice(0, 12);
const inline = [`--ma-hil-size:${size}px`, `--ma-hil-tilt:${tilt}deg`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-hil', className]} data-ma-hil style={inline} {...rest}>
<ul class="ma-hil__list" aria-label={label}>
{
list.map((it, i) => (
<li class="ma-hil__item">
<a href={it.href} class="ma-hil__link" data-i={i}>
<span class="ma-hil__title">{it.title}</span>
{it.meta && <span class="ma-hil__meta">{it.meta}</span>}
<svg class="ma-hil__arrow" aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M3 13L13 3M6 3h7v7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
</a>
</li>
))
}
</ul>
<span class="ma-hil__float" aria-hidden="true">
{list.map((it, i) => <img src={it.image} alt="" loading="lazy" decoding="async" draggable="false" style={`--ma-hil-i:${i}`} />)}
</span>
</div>
<style is:global>
@layer components {
:where(.ma-hil) {
position: relative;
}
:where(.ma-hil__list) {
margin: 0;
padding: 0;
list-style: none;
}
:where(.ma-hil__item) {
border-top: 1px solid var(--ma-edge);
}
:where(.ma-hil__item:last-child) {
border-bottom: 1px solid var(--ma-edge);
}
:where(.ma-hil__link) {
display: flex;
align-items: baseline;
gap: 1rem;
padding: 1.1rem 0;
color: inherit;
text-decoration: none;
transition:
opacity var(--ma-duration-fast) var(--ma-ease-out),
translate var(--ma-duration) var(--ma-ease);
}
:where(.ma-hil__link:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 4px;
border-radius: 2px;
}
:where(.ma-hil__title) {
flex: 1;
font-size: clamp(1.5rem, 3vw, 2.25rem);
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.1;
}
:where(.ma-hil__meta) {
font-family: ui-monospace, monospace;
font-size: 0.6875rem;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: var(--ma-dim, 0.7);
white-space: nowrap;
}
:where(.ma-hil__arrow) {
flex: none;
align-self: center;
opacity: 0.4;
transition:
translate var(--ma-duration) var(--ma-ease),
opacity var(--ma-duration-fast) var(--ma-ease-out);
}
/* the hovered link steps forward, the rest step back, the arrow points on */
:where(.ma-hil__list:has(.ma-hil__link:hover) .ma-hil__link:not(:hover)) {
opacity: 0.4;
}
:where(.ma-hil__link:is(:hover, :focus-visible)) {
translate: 0.5rem 0;
}
:where(.ma-hil__link:is(:hover, :focus-visible) .ma-hil__arrow) {
opacity: 1;
translate: 0.25rem -0.25rem;
}
/* the float: one box, every image stacked in it; the pointer's lengths place it, the lag is CSS */
:where(.ma-hil__float) {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: var(--ma-hil-size, 220px);
aspect-ratio: 4 / 3;
border-radius: 0.75rem;
overflow: clip;
background: var(--ma-edge);
box-shadow: 0 24px 48px -24px rgb(0 0 0 / 0.5);
pointer-events: none;
opacity: 0;
scale: 0.8;
translate: calc(var(--ma-hil-x, 0px) - 50%) calc(var(--ma-hil-y, 0px) - 50%);
rotate: calc(var(--ma-hil-r, 0) * var(--ma-hil-tilt, 6deg));
transition:
translate var(--ma-duration-fast) var(--ma-ease-out),
rotate var(--ma-duration) var(--ma-ease),
opacity var(--ma-duration-fast) var(--ma-ease-out),
scale var(--ma-duration) var(--ma-ease);
}
:where(.ma-hil:has(.ma-hil__link:is(:hover, :focus-visible)) .ma-hil__float) {
opacity: 1;
scale: 1;
}
:where(.ma-hil__float img) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
/* 1 for the active index, 0 for every other — a square instead of abs() */
opacity: clamp(0, 1 - (var(--ma-hil-i, 0) - var(--ma-hil-active, 0)) * (var(--ma-hil-i, 0) - var(--ma-hil-active, 0)), 1);
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
}
/* keyboard: the image at a fixed spot on the right, no following */
:where(.ma-hil:has(.ma-hil__link:focus-visible) .ma-hil__float) {
top: 50%;
left: auto;
right: 0;
translate: 0 -50%;
rotate: 0deg;
}
@media (hover: none), (pointer: coarse) {
:where(.ma-hil__float) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-hil:has(.ma-hil__link:is(:hover, :focus-visible)) .ma-hil__float) {
top: 50%;
left: auto;
right: 0;
translate: 0 -50%;
rotate: 0deg;
scale: 1;
}
:where(.ma-hil__float),
:where(.ma-hil__link),
:where(.ma-hil__arrow) {
transition: none;
}
}
}
</style>
<script>
import { onMount } from '../lib/runtime';
onMount<HTMLElement>('[data-ma-hil]', (el, { signal }) => {
const set = (n: string, v: string) => el.style.setProperty(n, v);
// which image: the hovered or focused link's index
const pick = (e: Event) => {
const a = (e.target as Element).closest<HTMLElement>('.ma-hil__link');
if (a) set('--ma-hil-active', a.dataset.i!);
};
el.addEventListener('pointerover', pick, { signal });
el.addEventListener('focusin', pick, { signal });
// bound under reduced motion too: the reduced CSS pins the box to a fixed spot while a link is hovered, so these writes
// are unseen then, and the follow is already running when the page's switch turns motion back on
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
let lx = 0;
el.addEventListener(
'pointermove',
(e) => {
const r = el.getBoundingClientRect();
set('--ma-hil-x', `${e.clientX - r.left}px`);
set('--ma-hil-y', `${e.clientY - r.top}px`);
// the lean is the direction of travel; the curve and the angle are CSS
set('--ma-hil-r', String(Math.sign(e.clientX - lx)));
lx = e.clientX;
},
{ signal, passive: true },
);
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
items | { href: string; title: string; image: string; meta?: string }[] | none | Up to twelve links, each with the image it floats. |
size | number | 220 | Width of the floating image in px (4:3). |
tilt | number | 6 | Maximum lean in degrees, in the direction the pointer moves. |
label | string | 'Projects' | Accessible name of the list. |
The image appears at a fixed spot on the right of the list while a link is hovered or focused, and nothing follows the pointer. The follow is CSS that the reduced rule overrides, so the page's own motion switch applies both ways without a reload.
ClientRouterBound through the shared runtime; the listeners are aborted before the swap.
Replaces: HoverImageLinks (Hover.dev) · LinkPreview list (Skiper UI) · FloatingImage (Aceternity). 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.
Small images pop up along the pointer's path and fade behind it.
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.
A link underline that enters from the left on hover and leaves to the right, so the exit continues the entrance instead of rewinding it.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown