Skip to content

Collection 01 / Interactive

Project Gallery.

An image-forward project grid with filters and a keyboard-ready detail preview.

Live playground

Accent

A few things I’ve made

Selected work, up close.

Identity

Forma

A quiet visual language for architecture with big ideas.

import { ProjectGallery } from "@/components/gear5/project-gallery";

export default function Example() {
  return <ProjectGallery accent={"#d9fc87"} />;
}

Make it yours.

npx shadcn@latest add https://gear5-ui.vercel.app/r/project-gallery.json

Copies editable source into your project. Setup guide · React and Tailwind CSS 4 required.

Put it to work.

import { ProjectGallery, type GalleryProject } from "@/components/gear5/project-gallery";
const projects: GalleryProject[] = [
  { id: "studio", category: "Identity", title: "Studio",
    description: "A new identity for an independent practice.",
    artworkAlt: "Abstract graphic for the Studio identity.",
    href: "/work/studio",
    artwork: <div className="flex aspect-[5/4] items-center justify-center bg-stone-200 text-7xl">S.</div> },
];
export default function Work() {
  return <ProjectGallery projects={projects} title="Selected work." />;
}

Good to know.

  • Use artwork for your locally hosted, optimized project image or SVG. The caller controls image loading and alt text.
  • Filters and project choices are buttons; arrow keys, Home, and End move through the visible projects.
  • The example artwork and portfolio names are fictional. Replace them with images you can publish.

Source & props.

Installer-ready imports. For manual installation, copy this file into components/gear5/project-gallery.tsx and add the helpers below at their shown paths.

"use client";

import { useId, useState, type CSSProperties, type ReactNode } from "react";
import { sanitizeHref } from "@/lib/gear5/sanitize";

export interface GalleryProject {
  id: string;
  title: string;
  category: string;
  description: string;
  /** Add a locally hosted image, optimized with your framework, or a small SVG preview. */
  artwork?: ReactNode;
  artworkAlt?: string;
  href?: string;
  linkLabel?: string;
}

const exampleProjects: GalleryProject[] = [
  {
    id: "forma",
    title: "Forma",
    category: "Identity",
    description: "A quiet visual language for architecture with big ideas.",
    artworkAlt: "Abstract sage-green identity card showing a serif f.",
    artwork: (
      <div className="flex aspect-[5/4] items-center justify-center overflow-hidden bg-[#c8d5c1] text-[#273028]">
        <span className="font-serif text-[clamp(5rem,18vw,10rem)] tracking-[-.12em]">
          f.
        </span>
      </div>
    ),
    linkLabel: "Read the Forma case study",
  },
  {
    id: "mono",
    title: "Mono",
    category: "Product",
    description: "A calmer way to keep a small team moving forward.",
    artworkAlt: "Lavender product tile marked m with an upward arrow.",
    artwork: (
      <div className="flex aspect-[5/4] items-center justify-center overflow-hidden bg-[#d2ccef] text-[#302b48]">
        <span className="font-mono text-[clamp(4rem,15vw,8rem)] tracking-[-.16em]">
          m↗
        </span>
      </div>
    ),
    linkLabel: "Read the Mono case study",
  },
  {
    id: "terrain",
    title: "Terrain",
    category: "Digital",
    description: "A digital home that makes room for the natural world.",
    artworkAlt: "Warm sand-colored creative title card marked t.",
    artwork: (
      <div className="flex aspect-[5/4] items-center justify-center overflow-hidden bg-[#e8cdb5] text-[#49362b]">
        <span className="font-serif text-[clamp(4rem,15vw,8rem)] italic">
          t*
        </span>
      </div>
    ),
    linkLabel: "Read the Terrain case study",
  },
];

export interface ProjectGalleryProps {
  projects?: GalleryProject[];
  title?: string;
  eyebrow?: string;
  allLabel?: string;
  emptyLabel?: string;
  accent?: string;
  className?: string;
}

/** A filtered, image-forward portfolio grid with a keyboard-friendly detail preview. */
export function ProjectGallery({
  projects = exampleProjects,
  title = "Selected work, up close.",
  eyebrow = "A few things I’ve made",
  allLabel = "Everything",
  emptyLabel = "Add projects to see them here.",
  accent = "#d9fc87",
  className = "",
}: ProjectGalleryProps) {
  const id = useId();
  const [category, setCategory] = useState(allLabel);
  const [selectedId, setSelectedId] = useState(projects[0]?.id ?? "");
  const categories = [
    allLabel,
    ...new Set(projects.map((project) => project.category)),
  ];
  const visible =
    category === allLabel
      ? projects
      : projects.filter((project) => project.category === category);
  const selected =
    visible.find((project) => project.id === selectedId) ?? visible[0];

  function moveSelection(index: number) {
    const project = visible[index];
    if (project) setSelectedId(project.id);
  }

  return (
    <section
      style={{ "--g5-accent": accent } as CSSProperties}
      aria-labelledby={`${id}-title`}
      className={`rounded-3xl bg-[#111214] p-6 text-[#f4f4ef] sm:p-10 ${className}`}
    >
      <div className="flex flex-wrap items-end justify-between gap-5">
        <div>
          <p className="font-mono text-[11px] tracking-[.12em] text-[#b4b6af] uppercase">
            {eyebrow}
          </p>
          <h2
            id={`${id}-title`}
            className="mt-3 text-3xl font-medium tracking-[-.05em] sm:text-4xl"
          >
            {title}
          </h2>
        </div>
        {categories.length > 2 && (
          <div aria-label="Filter projects" className="flex flex-wrap gap-2">
            {categories.map((option) => (
              <button
                key={option}
                type="button"
                aria-pressed={category === option}
                onClick={() => setCategory(option)}
                className={`min-h-10 rounded-full border px-4 py-2 text-xs transition-colors motion-reduce:transition-none ${
                  category === option
                    ? "border-transparent bg-[var(--g5-accent)] font-medium text-[#111214]"
                    : "border-white/15 text-[#c2c4bd] hover:bg-white/5"
                }`}
              >
                {option === allLabel ? allLabel : option}
              </button>
            ))}
          </div>
        )}
      </div>

      {visible.length === 0 ? (
        <p className="mt-8 rounded-2xl border border-white/10 px-5 py-12 text-center text-sm text-[#b4b6af]">
          {emptyLabel}
        </p>
      ) : (
        <>
          <div
            role="group"
            aria-label="Projects"
            className="mt-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"
          >
            {visible.map((project, index) => (
              <button
                key={project.id}
                id={`${id}-project-${project.id}`}
                type="button"
                aria-pressed={selected?.id === project.id}
                aria-controls={`${id}-detail`}
                onClick={() => moveSelection(index)}
                onKeyDown={(event) => {
                  let next: number;
                  if (event.key === "Home") next = 0;
                  else if (event.key === "End") next = visible.length - 1;
                  else if (
                    event.key === "ArrowRight" ||
                    event.key === "ArrowDown"
                  )
                    next = (index + 1) % visible.length;
                  else if (event.key === "ArrowLeft" || event.key === "ArrowUp")
                    next = (index - 1 + visible.length) % visible.length;
                  else return;
                  event.preventDefault();
                  moveSelection(next);
                  document
                    .getElementById(`${id}-project-${visible[next].id}`)
                    ?.focus();
                }}
                className={`group min-w-0 overflow-hidden rounded-2xl border text-start transition-[border-color,opacity] motion-reduce:transition-none ${
                  selected?.id === project.id
                    ? "border-[var(--g5-accent)]"
                    : "border-white/10 hover:border-white/35"
                }`}
              >
                <div
                  aria-hidden={!project.artworkAlt}
                  role={project.artworkAlt ? "img" : undefined}
                  aria-label={project.artworkAlt}
                  className="overflow-hidden [&>div]:transition-transform [&>div]:duration-500 motion-safe:group-hover:[&>div]:scale-[1.03] motion-reduce:[&>div]:transition-none"
                >
                  {project.artwork ?? (
                    <div className="flex aspect-[5/4] items-center justify-center bg-[#26292b] font-serif text-7xl text-white/70">
                      {project.title.slice(0, 1)}
                    </div>
                  )}
                </div>
                <span className="flex items-center justify-between gap-3 px-4 py-4">
                  <span className="min-w-0">
                    <span className="block truncate text-sm font-medium">
                      {project.title}
                    </span>
                    <span className="mt-1 block font-mono text-[10px] text-[#b4b6af]">
                      {project.category}
                    </span>
                  </span>
                  <span
                    aria-hidden="true"
                    className="text-sm text-[var(--g5-accent)]"
                  >
                    {selected?.id === project.id ? "↗" : "+"}
                  </span>
                </span>
              </button>
            ))}
          </div>

          {selected && (
            <article
              id={`${id}-detail`}
              aria-live="polite"
              aria-atomic="true"
              className="mt-4 grid overflow-hidden rounded-2xl border border-white/10 bg-[#181a1c] sm:grid-cols-[.8fr_1fr]"
            >
              <div
                aria-hidden={!selected.artworkAlt}
                role={selected.artworkAlt ? "img" : undefined}
                aria-label={selected.artworkAlt}
                className="min-h-48 overflow-hidden sm:min-h-64"
              >
                {selected.artwork ?? (
                  <div className="flex h-full min-h-48 items-center justify-center bg-[#26292b] font-serif text-8xl text-white/70 sm:min-h-64">
                    {selected.title.slice(0, 1)}
                  </div>
                )}
              </div>
              <div className="flex flex-col justify-center p-6 sm:p-8">
                <p className="font-mono text-[10px] text-[var(--g5-accent)] uppercase">
                  {selected.category}
                </p>
                <h3 className="mt-3 text-2xl font-medium tracking-[-.04em]">
                  {selected.title}
                </h3>
                <p className="mt-3 max-w-md text-sm leading-7 text-[#b4b6af]">
                  {selected.description}
                </p>
                {selected.href && (
                  <a
                    href={sanitizeHref(selected.href)}
                    className="mt-6 inline-flex min-h-11 w-fit items-center gap-4 rounded-full border border-white/20 px-5 py-3 text-sm transition-colors hover:bg-white/5 motion-reduce:transition-none"
                  >
                    {selected.linkLabel ?? "View project"}
                    <span
                      aria-hidden="true"
                      className="text-[var(--g5-accent)]"
                    >
                      ↗
                    </span>
                  </a>
                )}
              </div>
            </article>
          )}
        </>
      )}
    </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.