Project Preview List
An editorial project index with real case-study links, artwork on focus, and useful thumbnails on small screens.
A FEW THINGS WE HAVE PUT INTO THE WORLD
Good work. Good people.
04 projectsExplore the work. Open a case study. ↗
'use client'
import { useId, useState, type ReactNode } from 'react'
import './project-preview-list.css'
export interface ProjectPreviewListItem {
id: string
title: string
category: string
href: string
year?: string
/** Decorative artwork. The title and category provide the accessible description. */
media: ReactNode
}
export interface ProjectPreviewListProps {
items: ProjectPreviewListItem[]
title?: string
className?: string
}
function decorativeMedia(element: HTMLDivElement | null) {
element?.setAttribute('inert', '')
}
export function ProjectPreviewList({ items, title = 'Project index', className = '' }: ProjectPreviewListProps) {
const headingId = useId()
const [previewId, setPreviewId] = useState<string | null>(null)
const preview = items.find((item) => item.id === previewId) ?? items[0]
return (
<section className={`ac-project-preview-list ${className}`.trim()} aria-labelledby={headingId}>
<header className="ac-project-preview-heading"><h2 id={headingId}>{title}</h2><span>{items.length.toString().padStart(2, '0')} projects</span></header>
{items.length > 0 ? (
<div className="ac-project-preview-layout">
<ol className="ac-project-preview-rows">
{items.map((item, index) => (
<li key={item.id}>
<a
className="ac-project-preview-link"
href={item.href}
data-previewing={preview?.id === item.id ? 'true' : undefined}
onPointerEnter={(event) => { if (event.pointerType !== 'touch') setPreviewId(item.id) }}
onFocus={() => setPreviewId(item.id)}
>
<span className="ac-project-preview-number" aria-hidden="true">{(index + 1).toString().padStart(2, '0')}</span>
<div ref={decorativeMedia} className="ac-project-preview-thumbnail" aria-hidden="true">{item.media}</div>
<div className="ac-project-preview-copy"><h3>{item.title}</h3><span>{item.category}{item.year && <> · {item.year}</>}</span></div>
<span className="ac-project-preview-arrow" aria-hidden="true">↗</span>
</a>
</li>
))}
</ol>
<div ref={decorativeMedia} className="ac-project-preview-art" aria-hidden="true">
<div key={preview.id} className="ac-project-preview-image">{preview.media}</div>
<div className="ac-project-preview-caption"><span>{preview.title}</span><span>{preview.category}</span></div>
</div>
</div>
) : <p className="ac-project-preview-empty">Projects are on the way. Check back soon.</p>}
</section>
)
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/project-preview-list.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { ProjectPreviewList } from '@/components/anticraft/ProjectPreviewList';
export function Example() {
return (
<ProjectPreviewList
title="Good work. Good people."
items={[
{
id: 'kin', title: 'KIN', category: 'Ecommerce website', year: '2026', href: '/work/kin',
media: <div style={{ display: 'grid', placeItems: 'center', background: '#b9c396', color: '#2e3b28', fontSize: 64, fontWeight: 600 }}>KIN</div>,
},
{
id: 'forma', title: 'FORMA', category: 'Brand identity', year: '2026', href: '/work/forma',
media: <div style={{ display: 'grid', placeItems: 'center', background: '#c85332', color: '#f9e7c6', fontSize: 48, fontFamily: 'Georgia, serif' }}>Forma.</div>,
},
{
id: 'folio', title: 'FOLIO', category: 'Product design', year: '2025', href: '/work/folio',
media: <div style={{ display: 'grid', placeItems: 'center', background: '#c9b9dc', color: '#44315b', fontSize: 48 }}>folio↗</div>,
},
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ProjectPreviewListItem[] | Required | Projects with a unique id, title, category, href and decorative ReactNode media, plus an optional year. Replace the sample /work/… paths with real case-study routes. Empty collections show a short empty state. |
title | string | 'Project index' | The section heading and accessible name. |
className | string | '' | Additional classes. Customize --ac-preview-ink, --ac-preview-muted, --ac-preview-line, --ac-preview-accent, --ac-preview-radius and --ac-preview-caption-background. Below a component width of 500px the side preview becomes a thumbnail beside every row. |
item.media | ReactNode | Required | Decorative artwork or an image with alt="". It fills a 4:5 desktop preview and square compact thumbnails. Both versions are hidden from assistive technology and made inert. Avoid nested controls and place essential information in the text fields. |
item.href | string | Required | A real destination followed by the native anchor, preserving modified clicks and open-in-new-tab behavior. Hover and keyboard focus only change the decorative artwork. |
Motion & accessibility
The first project’s artwork is visible initially. Hover or keyboard focus changes the side preview with a brief opacity and scale entrance; the last preview stays visible when the pointer leaves. The compact layout shows every thumbnail directly, so no content depends on hover. Normal Tab and Enter navigate links. Reduced motion removes artwork and arrow transitions. Demo links open local case studies.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.
Inspiration
Emotion’s numbered project index and visible project categories informed this editorial list structure. Anticraft’s preview behavior, artwork, responsive thumbnails and React implementation are original.
Visit Emotion AgencyThe component source and demo artwork are original to Anticraft UI. The linked site is the design or content reference.