Website Sections
PortfolioGrid
Project tiles with filter chips. Tiles glide to their new places when the filter changes; on hover the caption rises and a pill follows the pointer.
Download
- React
- Tailwind CSS v4
- motion
components/ui/portfolio-grid.tsx
"use client";
import { useId, useRef, useState, type CSSProperties, type ElementType, type PointerEvent } from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
export type PortfolioGridItem = {
title: string;
/** Used for the filter chips. */
tag: string;
src: string;
alt: string;
href?: string;
/** Small line after the tag, such as the year. */
note?: string;
/** Takes two columns on a wide screen. */
wide?: boolean;
};
type PortfolioGridProps = {
items: PortfolioGridItem[];
title?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Word for the chip that shows everything. */
allLabel?: string;
/** Word on the pill that follows the pointer over a link. */
viewLabel?: string;
/** Colour of the active chip and the pill. */
color?: string;
/** Text colour on that colour. */
ink?: string;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
const SPRING = { stiffness: 320, damping: 30 };
/* PortfolioGrid: project tiles with filter chips. Tiles glide to their new places when the filter changes; on hover the picture grows, the caption rises and a small pill follows the pointer. */
export function PortfolioGrid({
items,
title,
eyebrow,
subtitle,
allLabel = "All",
viewLabel = "View",
color = "#a78bfa",
ink = "#0b0a14",
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: PortfolioGridProps) {
const reduce = useReducedMotion();
const id = useId();
const grid = useRef<HTMLUListElement>(null);
const [tag, setTag] = useState(allLabel);
const [pill, setPill] = useState(false);
const x = useMotionValue(0);
const y = useMotionValue(0);
const pillX = useSpring(x, SPRING);
const pillY = useSpring(y, SPRING);
const tags = [allLabel, ...new Set(items.map((item) => item.tag))];
const shown = items.filter((item) => tag === allLabel || item.tag === tag);
const move = (e: PointerEvent) => {
const box = grid.current?.getBoundingClientRect();
if (e.pointerType !== "mouse" || !box) return;
const over = Boolean((e.target as Element).closest("a"));
x.set(e.clientX - box.left);
y.set(e.clientY - box.top);
if (over && !pill) {
pillX.jump(e.clientX - box.left);
pillY.jump(e.clientY - box.top);
}
setPill(over);
};
return (
<section aria-labelledby={title ? id : undefined} className={`@container ${className}`} style={{ "--pg-color": color, "--pg-ink": ink } as CSSProperties}>
{title && (
<header className="mx-auto mb-6 max-w-2xl text-center">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.5rem,6cqw,2.5rem)] leading-tight font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-pretty opacity-70">{subtitle}</p>}
</header>
)}
<div role="group" aria-label="Filter" className="mx-auto mb-6 flex max-w-6xl flex-wrap justify-center gap-2">
{tags.map((name) => {
const on = name === tag;
return (
<button
key={name}
type="button"
aria-pressed={on}
onClick={() => setTag(name)}
className={`rounded-full border px-3.5 py-1.5 text-sm transition-colors ${on ? "border-[var(--pg-color)] bg-[var(--pg-color)] text-[var(--pg-ink)]" : "border-current/15 hover:border-current/40"}`}
>
{name}
</button>
);
})}
</div>
<p role="status" className="sr-only">
{shown.length} shown
</p>
<ul
ref={grid}
onPointerMove={reduce ? undefined : move}
onPointerLeave={() => setPill(false)}
className="relative mx-auto grid max-w-6xl grid-flow-dense grid-cols-1 gap-3 overflow-hidden @lg:grid-cols-2 @3xl:grid-cols-3 @3xl:gap-4"
>
<AnimatePresence mode="popLayout" initial={false}>
{shown.map((item) => {
const body = (
<>
<img
src={item.src}
alt={item.alt}
className="absolute inset-0 size-full object-cover transition-transform duration-700 ease-out group-hover/tile:scale-105 motion-reduce:transition-none motion-reduce:group-hover/tile:scale-100"
/>
<span className="absolute inset-x-0 bottom-0 translate-y-1 bg-gradient-to-t from-black/80 to-transparent p-4 pt-12 text-white opacity-0 transition-[opacity,translate] duration-300 group-focus-within/tile:translate-y-0 group-focus-within/tile:opacity-100 group-hover/tile:translate-y-0 group-hover/tile:opacity-100 pointer-coarse:translate-y-0 pointer-coarse:opacity-100 motion-reduce:translate-y-0">
<span className="block font-semibold">{item.title}</span>
<span className="block text-sm opacity-80">
{item.tag}
{item.note && ` · ${item.note}`}
</span>
</span>
</>
);
return (
<motion.li
key={item.title}
layout={!reduce}
initial={{ opacity: 0, scale: reduce ? 1 : 0.94 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.94 }}
transition={{ duration: reduce ? 0 : 0.45, ease: [0.16, 1, 0.3, 1] }}
className={`group/tile relative aspect-[4/3] place-self-stretch overflow-hidden rounded-2xl ${item.wide ? "@3xl:col-span-2 @3xl:aspect-[8/3]" : ""}`}
>
{item.href ? (
<a href={item.href} className="absolute inset-0 block outline-offset-[-3px]">
{body}
</a>
) : (
body
)}
</motion.li>
);
})}
</AnimatePresence>
<motion.span
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-10 -mt-5 -ml-8 grid h-10 w-16 place-items-center rounded-full bg-[var(--pg-color)] text-sm font-medium text-[var(--pg-ink)]"
style={{ x: pillX, y: pillY }}
initial={false}
animate={{ opacity: pill ? 1 : 0, scale: pill ? 1 : 0.6 }}
transition={{ duration: 0.2 }}
>
{viewLabel}
</motion.span>
</ul>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/portfolio-grid.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/portfolio-grid-demo.tsx
import { PortfolioGrid } from "@/components/ui/portfolio-grid"; const ITEMS = [ { title: "Tideline", tag: "Brand", note: "2025", src: "/demo/tide.jpg", alt: "Sea and hills in soft blue light", href: "#tideline", wide: true }, { title: "Ember Co", tag: "Web", note: "2025", src: "/demo/ember.jpg", alt: "Red hills under a burning sky", href: "#ember" }, { title: "Moss Field", tag: "Print", note: "2024", src: "/demo/moss.jpg", alt: "Green hills in soft light", href: "#moss" }, { title: "First Light", tag: "Web", note: "2024", src: "/demo/dawn.jpg", alt: "Teal hills at dawn", href: "#dawn" }, { title: "Dusk Notes", tag: "Brand", note: "2023", src: "/demo/dusk.jpg", alt: "Hills at dusk under a pale sun", href: "#dusk" }, ]; export default function PortfolioGridExample() { return ( <div className="h-full overflow-y-auto"> <PortfolioGrid title="Selected work" items={ITEMS} headingAs="h3" className="min-h-full bg-[#0b0a14] p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | PortfolioGridItem[] | — | Each has title, tag, src and alt, plus optional href, note and wide. |
| title | string | — | Heading above the chips. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| allLabel | string | "All" | Word for the chip that shows everything. |
| viewLabel | string | "View" | Word on the pill that follows the pointer over a link. |
| color | string | "#a78bfa" | Colour of the active chip and the pill. |
| ink | string | "#0b0a14" | Text colour on that colour. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |