Skip to content

Components / Pointer

Hover Image Links

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 demo

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

Measured

JavaScript of its own

596 B raw

354 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

Baseline · widely available

Chrome 105 · Firefox 121 · Safari 15.4; needs has. Elsewhere: without :has() the box stays hidden.

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 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' },
  ]}
/>
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/hover-image-links.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.

HoverImageLinks.astro
---
/**
 * 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.
 *
 * The default slot replaces the built-in list with a list of your own (a
 * numbered index, a table of posts): any element in it that carries
 * data-hil="<index>" picks that item's image, and the float, the follow and
 * the keyboard spot work as they do for the built-in links. An item's `name`
 * is the view transition name of its floating image (encoded as Astro's
 * transition:name encodes it), so with Astro's router the image a reader
 * clicked can grow into the page it opens.
 */
import type { HTMLAttributes } from 'astro/types';

/** An image URL, or one with its own srcset (astro:assets' getImage() output fits). */
type Img = string | { src: string; srcset?: string; width?: number; height?: number };

interface Item {
  href: string;
  title: string;
  image: Img;
  /** A mono line beside the title (year, category). */
  meta?: string;
  /** View transition name of the floating image (transition:name, morph), paired with the same name on the page the link opens. */
  name?: 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;
  /** Aspect ratio of the floating image (CSS). */
  ratio?: string;
}

const { items, size = 220, tilt = 6, label = 'Projects', ratio, class: className, style, ...rest } = Astro.props;
const list = items.slice(0, 12);
const inline = [`--ma-hil-size:${size}px`, `--ma-hil-tilt:${tilt}deg`, ratio && `--ma-hil-ratio:${ratio}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
// the float is `size` wide, so a srcset needs no sizes beyond it
const img = (i: Img) => (typeof i === 'string' ? { src: i } : { src: i.src, srcset: i.srcset, sizes: i.srcset ? `${size}px` : undefined, width: i.width, height: i.height });
// a view transition name written the way Astro's transition:name writes it (every ASCII character outside
// [a-zA-Z0-9-] as _ and its hex code, _ doubled, a leading digit, - or _ behind a _), so an image named here pairs with the
// same name given through the directive on the page the link opens. Inline, not the directive: the directive brings
// Astro's transition keyframes to every page that renders this component, named or not
const vt = (name: string) => {
  const out = [...name].map((c) => (c === '_' ? '__' : /[a-zA-Z0-9-]/.test(c) || c.charCodeAt(0) > 127 ? c : `_${c.charCodeAt(0).toString(16).padStart(2, '0')}`)).join('');
  return /^[-0-9_]/.test(out) ? `_${out}` : out;
};
---

<div class:list={['ma-hil', className]} data-ma-hil style={inline} {...rest}>
  {
    Astro.slots.has('default') ? (
      <slot />
    ) : (
      <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-hil={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 {...img(it.image)} alt="" loading="lazy" decoding="async" draggable="false" style={`--ma-hil-i:${i}${it.name ? `;view-transition-name:${vt(it.name)}` : ''}`} />)}
  </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: var(--ma-hil-ratio, 4 / 3);
      border-radius: var(--ma-hil-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([data-hil]: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;
      /* the box's corners on the image too: a view transition captures a named image without its parent's clip */
      border-radius: inherit;
      /* 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([data-hil]: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([data-hil]: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>('[data-hil]');
      if (a) set('--ma-hil-active', a.dataset.hil!);
    };
    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>

Props

PropTypeDefaultDescription
items{ href: string; title: string; image: string | { src, srcset?, width?, height? }; meta?: string; name?: string }[]noneUp to twelve links, each with the image it floats. An image with a srcset (astro:assets' getImage() output) is loaded at the float's width. name is the floating image's view transition name, encoded the way Astro's transition:name encodes it: the same name given through transition:name on the page the link opens pairs the two under Astro's router, and the image grows into its counterpart. Or fill the default slot with a list of your own (a numbered index, a table of posts): every element in it that carries data-hil="<index>" picks that item's image, and the float, the follow and the keyboard spot work as for the built-in links, whose styles (the dimming, the arrow) it does not get.
sizenumber220Width of the floating image in px.
ratiostring'4 / 3'Aspect ratio of the floating image.
tiltnumber6Maximum lean in degrees, in the direction the pointer moves.
labelstring'Projects'Accessible name of the built-in list.

Reduced motion

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.

With ClientRouter

Bound through the shared runtime; the listeners are aborted before the swap.

Why it is built this way

Replaces: HoverImageLinks (Hover.dev) · LinkPreview list (Skiper UI) · FloatingImage (Aceternity). 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

Small images pop up along the pointer's path and fade behind it.

every browserpointer

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.

newly · Chrome 125 · Firefox 147 · Safari 26hover

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.

every browserhover

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