Skip to content

Components / Sections

Project Index

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 demo

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

tone
density

Measured

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

Baseline · widely available

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.

Install Pro

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:

terminal
npx shadcn@latest add @moonarc-pro/project-index
src/pages/index.astro
---
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.

Props

PropTypeDefaultDescription
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 }[]noneThe 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.
headlinestringnoneThe section's heading. Slot "headline" wins.
eyebrowstringnoneA short line above the headline, in mono. Slot "eyebrow" wins.
copystringnoneA paragraph under the headline. Slot "copy" wins.
level1 | 22Heading level of the headline: 2 under a page's h1, 1 when the index opens its page.
labelstring'Projects'Accessible name of the list.
cursorLabelstring'View'The pointer's label over a line.
cursorbooleantrueThe 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]noneColumn 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 }noneA link after the list, such as every project. Slot "actions" wins.
sizenumber380Width of the floating cover in px.
ratiostring'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.

Theming and overrides

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.

Classes

.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

Slots

slot="eyebrow"slot="headline"slot="copy"slot="actions"

Reduced motion

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.

With ClientRouter

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

Why it is built this way

Replaces: Project lists (Framer studio templates) · HoverImageLinks project index (Hover.dev) · Work index (Awwwards studio sites). 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

01 / 04 · Sample case study

Harbour Lines

A mark, a typeface and a sign system for an imaginary ferry line, read from a moving deck.

Client
A sample ferry line
Year
2026
Role
Identity, wayfinding, motion
Credits
Sample names
A sample cover: a blue field with a pale circle

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

Chrome 115 · not Firefox · Safari 26scrollclick

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