# Hover Image Links (Moonarc)

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.

- Import: `import HoverImageLinks from '@moonarc/core/HoverImageLinks'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/hover-image-links.json`
- Tier B · category pointer · trigger pointer, hover
- Readout: `<HoverImageLinks size={220}>`
- Browser support: widely (Chrome 105 · Firefox 121 · Safari 15.4); elsewhere: without :has() the box stays hidden
- Measured cost: 597 B raw JS · 359 B gzip · + runtime (with dependencies 2.3 kB raw; CSS 6.6 kB raw)
- Page: https://moonarc.dev/components/hover-image-links/

## Props

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

## Usage

```astro
<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' },
  ]}
/>
```

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

## Craft

- One box for the whole list, not one per item: every image is in it, stacked and lazy, and the hovered link picks the visible one through a single number. There is no src swap and no image created on hover.
- The follow is the Cursor pattern: the script writes two lengths, translate transitions on the fast token and the exit curve, so the image trails the pointer with the same character as the site's cursor.
- The lean is the sign of the pointer's horizontal travel times the tilt token, transitioned on the preset curve: three values from the script, one angle from CSS.
- While one link is hovered the others fade to 40 % and the hovered one steps half a rem right with its arrow pointing on: the list itself says which one you are on, before the image does.
- The active image is chosen with clamp() and a square, 1 − (i − active)², so no abs() is needed and the rule works in every engine with clamp() (Chrome 79, Firefox 75, Safari 13.1).

## Replaces

- HoverImageLinks (Hover.dev)
- LinkPreview list (Skiper UI)
- FloatingImage (Aceternity)

## Source

```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.
 */
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>

```
