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.
The work as a numbered index: one line per project with the title at display size and the client, the discipline and the year in mono, the whole line a link. Over a line the project's cover floats by the pointer and the pointer becomes a label (View); the other titles step back. Each cover can carry the project's view transition name, so with Astro's router the cover a reader chose grows into the case study (CaseStudyHero, same name). On a touch screen each line shows a thumbnail instead, and keyboard focus shows the cover at a fixed spot. The float is HoverImageLinks, the label Cursor; zero JavaScript of its own.
Live, from the library itself, at full size: scroll, hover, navigate away and back. The demo is never gated.
Variants · each row writes the attribute the prop writes; the section's own CSS answers
JavaScript of its own
0 B
Uses HoverImageLinks and the shared runtime (1.9 kB raw, once per site) and Cursor. With those included: 3.3 kB raw.
CSS 14.9 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 111 · Firefox 144 · Safari 18; needs has, view-transitions. Elsewhere: without :has() the cover does not float; without view transitions (or Astro's router) the case study opens as a new page, with the router's own fade where it runs.
The demo above is never gated; the source is. With a key in components.json (see account), the registry serves it like any other item:
npx shadcn@latest add @moonarc-pro/project-index---
import ProjectIndex from '@/components/moonarc/pro/ProjectIndex.astro';
import { ClientRouter } from 'astro:transitions';
import { getImage } from 'astro:assets';
import cover from '../assets/work/harbour/cover.webp';
// the float loads the width it shows; the same name on the case study's CaseStudyHero pairs the two
const img = await getImage({ src: cover, widths: [400, 800], width: 800 });
---
<ClientRouter />
<ProjectIndex
headline="Selected work"
head={['Project', 'Client', 'Discipline', 'Year']}
projects={[
{ href: '/work/harbour/', title: 'Harbour Lines', client: 'Harbour Transit', discipline: 'Identity, wayfinding', year: 2026, cover: { src: img.src, srcset: img.srcSet.attribute }, name: 'work-harbour' },
{ href: '/work/field-notes/', title: 'Field Notes', client: 'Field Press', discipline: 'Editorial', year: 2025, cover: '/work/field-notes.webp', name: 'work-field-notes' },
]}
more={{ href: '/work/', label: 'Every project' }}
/>Without a key the registry answers 401 with an upgrade message. Everything this section composes is free: hover-image-links, cursor, underline-hover.
| Prop | Type | Default | Description |
|---|---|---|---|
projects | { href: string; title: string; client?: string; discipline?: string; year?: string | number; cover: string | { src, srcset?, width?, height? }; thumb?: string | { src, srcset?, width?, height? }; name?: string }[] | none | The projects in order, numbered 01, 02 …. cover floats by the pointer (alt empty: the title names the link); thumb is the line's picture on a touch screen (default the cover); name is the cover's view transition name, written as Astro's transition:name writes it, the same name CaseStudyHero carries on the project's page. |
headline | string | none | The section's heading. Slot "headline" wins. |
eyebrow | string | none | A short line above the headline, in mono. Slot "eyebrow" wins. |
copy | string | none | A paragraph under the headline. Slot "copy" wins. |
level | 1 | 2 | 2 | Heading level of the headline: 2 under a page's h1, 1 when the index opens its page. |
label | string | 'Projects' | Accessible name of the list. |
cursorLabel | string | 'View' | The pointer's label over a line. |
cursor | boolean | true | The label cursor (Cursor in label mode, confined to the section, the native pointer hidden inside it). Never shown on touch screens or under reduced motion; false keeps the plain pointer everywhere. |
head | [string, string, string, string] | none | Column heads over the lines, in mono (project, client, discipline, year), shown from 48 rem of the section's width. aria-hidden: each line already reads in that order. |
more | { href: string; label: string } | none | A link after the list, such as every project. Slot "actions" wins. |
size | number | 380 | Width of the floating cover in px. |
ratio | string | '16 / 10' | Aspect ratio of the floating cover and the thumbnails. |
tone | 'surface' | 'muted' | 'ink' | 'brand' | unset (paints nothing) | The ground: the page surface, a muted band, an always-dark ink panel, or a deep brand panel. The label cursor is the theme's primary pair, and the tone's own text pair on ink and brand. |
density | 'compact' | 'cozy' | 'roomy' | 'cozy' | Block padding and the lines' height ×0.8 / ×1 / ×1.25 through --ma-density. |
The section reads the theme contract (shadcn's variable names with the base tokens as fallback), so a pasted theme or one --ma-brand recolours it. Its class names are the override surface and stay stable; a named slot replaces the matching prop with your own markup.
.ma-pindex.ma-pindex__actions.ma-pindex__col--end.ma-pindex__columns.ma-pindex__copy.ma-pindex__eyebrow.ma-pindex__facts.ma-pindex__head.ma-pindex__headline.ma-pindex__index.ma-pindex__inner.ma-pindex__item.ma-pindex__link.ma-pindex__list.ma-pindex__more.ma-pindex__thumb.ma-pindex__title.ma-pindex__year
slot="eyebrow"slot="headline"slot="copy"slot="actions"
The cover appears at a fixed spot on the right while a line is hovered or focused, with no follow and no lean; the label cursor is not installed and the native pointer stays; the lines do not fade. Astro's router skips the view transition, so the case study simply opens.
ClientRouterMade for it: with <ClientRouter /> and the same name on the case study's hero, the chosen cover grows into it. HoverImageLinks and Cursor bind through the shared runtime and are released before the swap.
Replaces: Project lists (Framer studio templates) · HoverImageLinks project index (Hover.dev) · Work index (Awwwards studio sites). 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.
The top of a case study: the project's title at display size, its facts in a grid (client, year, role, credits), the cover that opens from a rounded inset to the full width as the page scrolls (ScrollExpand), then a before-and-after (ImageCompare) or a gallery that opens full size (Lightbox).
The whole body of work as a grid of covers with filters by discipline.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown