Collection 01 / Interactive
Project Showcase.
Tactile project cards with native expandable case studies. Your work, front and center.
Live playground
Selected work
A few things I’ve made.
FormaBrand identity · 2026
A new visual language for an architecture studio. Identity, website, and a system built to grow.
Discuss a similar projectMonoProduct design · 2026
Turning a complex creative workflow into a quiet, focused workspace. From first sketch to shipped product.
Discuss a similar projectTerrainDigital experience · 2025
An independent publication for the places in between. Editorial design with room to explore.
Discuss a similar projectimport { ProjectShowcase } from "@/components/gear5/project-showcase";
export default function Example() {
return <ProjectShowcase accent={"#d9fc87"} />;
}Make it yours.
npx shadcn@latest add https://gear5-ui.vercel.app/r/project-showcase.jsonCopies editable source into your project. Setup guide · React and Tailwind CSS 4 required.
Put it to work.
import { ProjectShowcase } from "@/components/gear5/project-showcase";
export default function Work() {
return <ProjectShowcase projects={[{
id: "studio", title: "Studio", category: "Brand & web design",
description: "The story behind the project.", href: "/work/studio",
symbol: "s.", color: "#d5e6d3",
}]} />;
}Good to know.
- Each card uses native details/summary. Click, tap, Space, or Enter to open a case study.
- Bring unique project ids and your own titles, descriptions, colors, symbols, and links.
- No image request or animation package is required. You can edit the source to add project artwork.
Source & props.
Installer-ready imports. For manual installation, copy this file into components/gear5/project-showcase.tsx and add the helpers below at their shown paths.
"use client";
import type { CSSProperties } from "react";
import { sanitizeHref } from "@/lib/gear5/sanitize";
export interface ShowcaseProject {
id: string;
title: string;
category: string;
description: string;
href: string;
symbol: string;
color: string;
}
export const showcaseProjects: ShowcaseProject[] = [
{
id: "forma",
title: "Forma",
category: "Brand identity · 2026",
description:
"A new visual language for an architecture studio. Identity, website, and a system built to grow.",
href: "#contact",
symbol: "f.",
color: "#d5e6d3",
},
{
id: "mono",
title: "Mono",
category: "Product design · 2026",
description:
"Turning a complex creative workflow into a quiet, focused workspace. From first sketch to shipped product.",
href: "#contact",
symbol: "m↗",
color: "#c9c4e4",
},
{
id: "terrain",
title: "Terrain",
category: "Digital experience · 2025",
description:
"An independent publication for the places in between. Editorial design with room to explore.",
href: "#contact",
symbol: "t*",
color: "#ead3b9",
},
];
export interface ProjectShowcaseProps {
projects?: ShowcaseProject[];
title?: string;
eyebrow?: string;
linkLabel?: string;
accent?: string;
className?: string;
}
/** Native details makes every project expandable with keyboard, touch, or pointer. */
export function ProjectShowcase({
projects = showcaseProjects,
title = "A few things I’ve made.",
eyebrow = "Selected work",
linkLabel = "Discuss a similar project",
accent = "#d9fc87",
className = "",
}: ProjectShowcaseProps) {
return (
<section
style={{ "--g5-accent": accent } as CSSProperties}
className={`rounded-3xl bg-[#111214] p-6 text-[#f4f4ef] sm:p-10 ${className}`}
>
<p className="font-mono text-[11px] tracking-[.12em] text-[#b4b6af] uppercase">
{eyebrow}
</p>
<h2 className="mt-3 text-3xl font-medium tracking-[-.05em] sm:text-4xl">
{title}
</h2>
<div className="mt-8 grid gap-4 md:grid-cols-3">
{projects.map((project, index) => (
<details
key={project.id}
className="group min-w-0 rounded-2xl border border-white/15 bg-[#181a1c] open:border-[var(--g5-accent)]"
>
<summary className="cursor-pointer list-none p-3 [&::-webkit-details-marker]:hidden">
<div
style={{ background: project.color }}
className="relative flex aspect-[4/3] items-center justify-center overflow-hidden rounded-xl text-[#17191b]"
>
<span
aria-hidden="true"
className="absolute inset-5 rounded-full border border-black/15"
/>
<span
aria-hidden="true"
className="font-serif text-7xl font-bold tracking-[-.1em] transition-transform duration-500 motion-safe:group-hover:scale-110 motion-safe:group-focus-within:scale-110 motion-reduce:transition-none"
>
{project.symbol}
</span>
<span
aria-hidden="true"
className="absolute start-4 top-4 font-mono text-[10px]"
>
0{index + 1} /
</span>
</div>
<span className="mt-4 flex items-center justify-between gap-3 px-1">
<span className="text-lg font-medium">{project.title}</span>
<span
aria-hidden="true"
className="flex size-7 items-center justify-center rounded-full border border-white/20 transition-transform group-open:rotate-45 motion-reduce:transition-none"
>
+
</span>
</span>
<span className="mt-1 mb-2 block px-1 text-xs text-[#b4b6af]">
{project.category}
</span>
</summary>
<div className="border-t border-white/15 p-4">
<p className="text-sm leading-6 text-[#b4b6af]">
{project.description}
</p>
<a
href={sanitizeHref(project.href)}
className="mt-4 inline-flex min-h-11 items-center gap-3 text-sm text-[var(--g5-accent)] underline-offset-4 hover:underline"
>
{linkLabel}
<span aria-hidden="true">↗</span>
</a>
</div>
</details>
))}
</div>
</section>
);
}
Helper: sanitize → lib/gear5/sanitize.ts
/**
* Security primitive: neutralise the one class of URL that turns "render a
* link" into "run arbitrary script" — `javascript:` and other executable
* schemes reaching an `href`/`src` from data the app did not author itself
* (a CMS field, a query param, a user's own profile link).
*/
const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:", "sms:"]);
/**
* Returns `url` unchanged if its scheme is safe to navigate to, or `"#"`
* otherwise. Relative URLs (no scheme) are always safe and pass through.
*/
export function sanitizeHref(url: string): string {
const trimmed = url.trim();
if (!trimmed) return "#";
// Browsers discard tabs and newlines when parsing a URL scheme.
const schemeProbe = trimmed.replace(/[\t\r\n]/g, "");
if (!/^[a-z][a-z0-9+.-]*:/i.test(schemeProbe)) return trimmed;
try {
const scheme = new URL(trimmed, "https://example.com").protocol;
return SAFE_SCHEMES.has(scheme) ? trimmed : "#";
} catch {
return "#";
}
}
VERIFIED: AXE FIXTURES / SERVER RENDER / SOURCE SCANS / 3300 B GZIP CEILING
Test your own content and screen-reader flows before shipping.