Project Grid
A portfolio section that earns its space: filter by discipline, scan the work, and open the full case study.
INDEPENDENT DESIGN PARTNERS · EST. 2020
Selected partnerships
4 projects
Filter by discipline. Open a case study. ↗
'use client'
import { useId, useState, type ReactNode } from 'react'
import './project-grid.css'
export interface ProjectGridItem {
id: string
title: string
category: string
href: string
description?: string
year?: string
/** Decorative artwork. Put information needed to understand the project in its text fields. */
media: ReactNode
}
export interface ProjectGridProps {
items: ProjectGridItem[]
title?: string
label?: string
className?: string
onFilterChange?: (category: string) => void
}
function decorativeMedia(element: HTMLDivElement | null) {
element?.setAttribute('inert', '')
}
export function ProjectGrid({
items,
title = 'Selected work',
label = 'Project categories',
className = '',
onFilterChange,
}: ProjectGridProps) {
const headingId = useId()
const [filter, setFilter] = useState<string | null>(null)
const categories = [...new Set(items.map((item) => item.category))]
const activeFilter = filter !== null && categories.includes(filter) ? filter : null
const visibleItems = activeFilter === null ? items : items.filter((item) => item.category === activeFilter)
function chooseCategory(category: string | null) {
setFilter(category)
onFilterChange?.(category ?? 'All')
}
return (
<section className={`ac-project-grid ${className}`.trim()} aria-labelledby={headingId}>
<header className="ac-project-grid-heading">
<h2 id={headingId}>{title}</h2>
<p className="ac-project-grid-count" role="status" aria-live="polite" aria-atomic="true">
{visibleItems.length} {visibleItems.length === 1 ? 'project' : 'projects'}
{activeFilter !== null && <span className="ac-project-grid-sr-only"> in {activeFilter}</span>}
</p>
</header>
{items.length > 0 && (
<div className="ac-project-grid-filters" role="group" aria-label={label}>
<button type="button" aria-pressed={activeFilter === null} onClick={() => chooseCategory(null)}>
All <span aria-hidden="true">{items.length.toString().padStart(2, '0')}</span>
</button>
{categories.map((category) => (
<button key={category} type="button" aria-pressed={activeFilter === category} onClick={() => chooseCategory(category)}>
{category} <span aria-hidden="true">{items.filter((item) => item.category === category).length.toString().padStart(2, '0')}</span>
</button>
))}
</div>
)}
{visibleItems.length > 0 ? (
<ul className="ac-project-grid-list">
{visibleItems.map((item) => (
<li key={`${activeFilter ?? '*'}-${item.id}`}>
<article className="ac-project-grid-card">
<a href={item.href}>
<div ref={decorativeMedia} className="ac-project-grid-media" aria-hidden="true">{item.media}</div>
<div className="ac-project-grid-details">
<div className="ac-project-grid-meta"><span>{item.category}</span>{item.year && <span>{item.year}</span>}</div>
<div className="ac-project-grid-title"><h3>{item.title}</h3><span className="ac-project-grid-arrow" aria-hidden="true">↗</span></div>
{item.description && <p>{item.description}</p>}
</div>
</a>
</article>
</li>
))}
</ul>
) : <p className="ac-project-grid-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-grid.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 { ProjectGrid } from '@/components/anticraft/ProjectGrid';
export function Example() {
return (
<ProjectGrid
title="Selected partnerships"
items={[
{
id: 'kin', title: 'KIN', category: 'Web', href: '/work/kin', year: '2026',
description: 'Everyday objects. A better shopping experience.',
media: <div style={{ display: 'grid', placeItems: 'center', background: '#b9c396', color: '#2e3b28', fontSize: 64, fontWeight: 600 }}>KIN</div>,
},
{
id: 'forma', title: 'FORMA', category: 'Brand', href: '/work/forma', year: '2026',
description: 'A new identity for the way we work.',
media: <div style={{ display: 'grid', placeItems: 'center', background: '#c85332', color: '#f9e7c6', fontSize: 48, fontFamily: 'Georgia, serif' }}>Forma.</div>,
},
{
id: 'folio', title: 'FOLIO', category: 'Product', href: '/work/folio', year: '2025',
description: 'Making personal finance feel personal.',
media: <div style={{ display: 'grid', placeItems: 'center', background: '#c9b9dc', color: '#44315b', fontSize: 48 }}>folio↗</div>,
},
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ProjectGridItem[] | Required | Project cards. Every item needs a unique id, title, category, href and decorative ReactNode media; description and year are optional. Replace the example /work/… URLs with your actual case-study routes. An empty list shows a short empty state. |
title | string | 'Selected work' | The section heading. It also supplies the section’s accessible name. |
label | string | 'Project categories' | An accessible name for the category button group. |
onFilterChange | (category: string) => void | undefined | Receives the selected category, or All for the full collection. Use a client component when passing a callback. Filtering keeps keyboard focus on the button and announces the number of matching projects. |
className | string | '' | Additional classes. Customize --ac-project-ink, --ac-project-muted, --ac-project-line, --ac-project-accent, --ac-project-radius and --ac-project-selected-ink. Two columns become one when the component itself is 420px wide or narrower. |
item.media | ReactNode | Required | Decorative artwork or an image with alt="". It fills a 4:3 surface, or 5:3 in the compact layout. Media is hidden from assistive technology and made inert; keep it free of links, buttons and other controls. Put essential information in title and description. |
Motion & accessibility
Category buttons filter the visible linked cards immediately. Matching cards enter with a short 9px rise; pointer hover adds a subtle artwork zoom and turns the arrow. Touch users get every project through ordinary links without hover. Reduced motion removes the entrance, zoom transition and arrow transition. The demo opens small local case studies; installed cards follow your supplied href URLs.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.
Inspiration
Instrument’s recent-work categories and linked case cards informed this practical portfolio structure. Anticraft’s artwork, filtering implementation, responsive layout and motion are original.
Visit Instrument — WorkThe component source and demo artwork are original to Anticraft UI. The linked site is the design or content reference.