Projects & work
Projects Gallery
Every project as a grid of cards or a compact list, with a Grid / List switch.
Layout and motion from the Nitip template — see it in context"use client";
import * as React from "react";
import Link from "next/link";
import { motion } from "motion/react";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { UnderlineLink } from "@/components/ui/underline-link";
import { ZoomCard } from "@/components/ui/zoom-card";
import { cn } from "@/lib/utils";
/*
* Projects Gallery — every project, viewed as a grid of cards or a compact list, with a Grid / List switch.
* Source: marcframe/nitip → components/sections/ProjectGallerySection.tsx (+ design-system ProjectGallery,
* ProjectCard, ProjectListItem, Switch).
*
* Layout kept from Framer: the switch sits at the right above the projects. Grid: one column below 768px, two from
* 768px (gap 32 × 48). List: rows with a divider on top — year and title (side by side from 768px), a 1.3625:1 photo
* capped at 100px (230px from 768px), and from 1024px a "View details" link.
* Motion kept: the new view fades in over 0.3s (Nitip's refinement of Framer's instant swap), the switch's fill
* slides (segmented-control), and photos zoom to 105% with Framer's 400ms tween (zoom-card) while their card or row
* is hovered — zoom-card follows the nearest `group` ancestor. Changed: list rows zoom at every width, not only
* from 1024px.
* The section heading is for screen readers (the gallery follows the page title); project titles are h3.
*/
export type GalleryProject = {
title: string;
year: string;
href: string;
image: { src: string; alt: string };
services?: { label: string; href?: string }[];
};
export type ProjectsGalleryProps = {
/** Section heading, visually hidden. */
title?: string;
projects?: GalleryProject[];
defaultView?: "grid" | "list";
labels?: { grid: string; list: string; view: string; details: string };
className?: string;
};
const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=1200&q=80`;
const defaults = {
title: "All projects",
labels: { grid: "Grid", list: "List", view: "Project view", details: "View details" },
projects: [
{
title: "Harvest Kitchen",
year: "2025",
href: "/projects/harvest-kitchen",
image: { src: photo("photo-1504674900247-0877df9cc836"), alt: "Plates of grilled meat and salads on a table, seen from above" },
services: [{ label: "Social media", href: "/services/social-media" }, { label: "Content", href: "/services/content" }],
},
{
title: "Pulse Fitness",
year: "2025",
href: "/projects/pulse-fitness",
image: { src: photo("photo-1534438327276-14e5300c3a48"), alt: "Rack of dumbbells in a bright gym" },
services: [{ label: "Paid advertising", href: "/services/paid-advertising" }, { label: "SEO", href: "/services/seo" }],
},
{
title: "Atlas Outfitters",
year: "2024",
href: "/projects/atlas-outfitters",
image: { src: photo("photo-1441986300917-64674bd600d8"), alt: "Clothes on rails and shelves inside a shop" },
services: [{ label: "Brand strategy", href: "/services/brand-strategy" }],
},
{
title: "Tempo Wearables",
year: "2024",
href: "/projects/tempo-wearables",
image: { src: photo("photo-1523275335684-37898b6baf30"), alt: "White smartwatch lying on a white surface" },
services: [{ label: "Content", href: "/services/content" }],
},
{
title: "Echo Audio",
year: "2023",
href: "/projects/echo-audio",
image: { src: photo("photo-1505740420928-5e560c06d30e"), alt: "Black headphones on a yellow background" },
services: [{ label: "Paid advertising", href: "/services/paid-advertising" }],
},
{
title: "Verde Plant Co.",
year: "2023",
href: "/projects/verde-plant-co",
image: { src: photo("photo-1485955900006-10f4d324d411"), alt: "Small succulent in a pale blue pot on a white background" },
services: [{ label: "SEO", href: "/services/seo" }, { label: "Content", href: "/services/content" }],
},
] as GalleryProject[],
};
export function ProjectsGallery({
title = defaults.title,
projects = defaults.projects,
defaultView = "grid",
labels = defaults.labels,
className,
}: ProjectsGalleryProps) {
const [view, setView] = React.useState(defaultView);
/* Fade only after a switch, so the server-rendered first view shows at once. */
const [switched, setSwitched] = React.useState(false);
return (
<section className={cn("py-(--section-y)", className)}>
<h2 className="sr-only">{title}</h2>
<div className="mx-auto flex max-w-(--container) flex-col gap-6 px-(--gutter)">
<SegmentedControl
label={labels.view}
size="sm"
className="self-end"
value={view}
onValueChange={(next) => {
setView(next);
setSwitched(true);
}}
options={[
{ value: "grid", label: labels.grid },
{ value: "list", label: labels.list },
]}
/>
<motion.div
key={view}
initial={switched ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{view === "grid" ? (
<div className="grid gap-12 md:grid-cols-2 md:gap-x-8">
{projects.map((project) => (
<article key={project.href} className="group flex flex-col gap-4">
<Link href={project.href} tabIndex={-1} aria-hidden className="block">
<ZoomCard className="aspect-[545/400]">
<img src={project.image.src} alt={project.image.alt} className="object-cover" />
</ZoomCard>
</Link>
<div className="flex flex-col gap-2">
<div className="flex h-5 items-start justify-between gap-5 overflow-hidden text-sm whitespace-nowrap text-muted-foreground">
<p className="flex min-w-0 flex-wrap gap-x-1">
{project.services?.map((service, i) => {
const label = `${service.label}${i < (project.services?.length ?? 0) - 1 ? "," : ""}`;
return service.href ? (
<Link
key={service.label}
href={service.href}
className="transition-colors hover:text-foreground hover:underline hover:underline-offset-4"
>
{label}
</Link>
) : (
<span key={service.label}>{label}</span>
);
})}
</p>
<p>{project.year}</p>
</div>
<h3 className="font-heading text-h3 font-semibold tracking-tight">
<Link href={project.href} className="hover:underline hover:underline-offset-4">
{project.title}
</Link>
</h3>
</div>
</article>
))}
</div>
) : (
<div className="border-b border-border">
{projects.map((project) => (
<article
key={project.href}
className="group flex items-start gap-5 border-t border-border py-5 md:gap-8 lg:gap-12"
>
<div className="flex min-w-0 flex-1 flex-col gap-2.5 md:flex-row md:items-start md:gap-8 lg:gap-12">
<p className="shrink-0 text-sm text-muted-foreground">{project.year}</p>
<h3 className="min-w-0 flex-1 font-heading text-h3 font-semibold tracking-tight">
<Link href={project.href} className="hover:underline hover:underline-offset-4">
{project.title}
</Link>
</h3>
</div>
<Link
href={project.href}
tabIndex={-1}
aria-hidden
className="block min-w-0 flex-1 max-md:max-w-25 md:max-w-57.5"
>
<ZoomCard className="aspect-[1.3625]">
<img src={project.image.src} alt={project.image.alt} className="object-cover" />
</ZoomCard>
</Link>
<UnderlineLink href={project.href} className="hidden lg:inline-flex">
{labels.details}
<span className="sr-only">: {project.title}</span>
</UnderlineLink>
</article>
))}
</div>
)}
</motion.div>
</div>
</section>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/projects-gallery.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/segmented-control.json https://framebrick.co/r/underline-link.json https://framebrick.co/r/zoom-card.json2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Install the dependencies.
npm install motion4. Copy the block into
components/projects-gallery.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { motion } from "motion/react"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { UnderlineLink } from "@/components/ui/underline-link"; import { ZoomCard } from "@/components/ui/zoom-card"; import { cn } from "@/lib/utils"; /* * Projects Gallery — every project, viewed as a grid of cards or a compact list, with a Grid / List switch. * Source: marcframe/nitip → components/sections/ProjectGallerySection.tsx (+ design-system ProjectGallery, * ProjectCard, ProjectListItem, Switch). * * Layout kept from Framer: the switch sits at the right above the projects. Grid: one column below 768px, two from * 768px (gap 32 × 48). List: rows with a divider on top — year and title (side by side from 768px), a 1.3625:1 photo * capped at 100px (230px from 768px), and from 1024px a "View details" link. * Motion kept: the new view fades in over 0.3s (Nitip's refinement of Framer's instant swap), the switch's fill * slides (segmented-control), and photos zoom to 105% with Framer's 400ms tween (zoom-card) while their card or row * is hovered — zoom-card follows the nearest `group` ancestor. Changed: list rows zoom at every width, not only * from 1024px. * The section heading is for screen readers (the gallery follows the page title); project titles are h3. */ export type GalleryProject = { title: string; year: string; href: string; image: { src: string; alt: string }; services?: { label: string; href?: string }[]; }; export type ProjectsGalleryProps = { /** Section heading, visually hidden. */ title?: string; projects?: GalleryProject[]; defaultView?: "grid" | "list"; labels?: { grid: string; list: string; view: string; details: string }; className?: string; }; const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=1200&q=80`; const defaults = { title: "All projects", labels: { grid: "Grid", list: "List", view: "Project view", details: "View details" }, projects: [ { title: "Harvest Kitchen", year: "2025", href: "/projects/harvest-kitchen", image: { src: photo("photo-1504674900247-0877df9cc836"), alt: "Plates of grilled meat and salads on a table, seen from above" }, services: [{ label: "Social media", href: "/services/social-media" }, { label: "Content", href: "/services/content" }], }, { title: "Pulse Fitness", year: "2025", href: "/projects/pulse-fitness", image: { src: photo("photo-1534438327276-14e5300c3a48"), alt: "Rack of dumbbells in a bright gym" }, services: [{ label: "Paid advertising", href: "/services/paid-advertising" }, { label: "SEO", href: "/services/seo" }], }, { title: "Atlas Outfitters", year: "2024", href: "/projects/atlas-outfitters", image: { src: photo("photo-1441986300917-64674bd600d8"), alt: "Clothes on rails and shelves inside a shop" }, services: [{ label: "Brand strategy", href: "/services/brand-strategy" }], }, { title: "Tempo Wearables", year: "2024", href: "/projects/tempo-wearables", image: { src: photo("photo-1523275335684-37898b6baf30"), alt: "White smartwatch lying on a white surface" }, services: [{ label: "Content", href: "/services/content" }], }, { title: "Echo Audio", year: "2023", href: "/projects/echo-audio", image: { src: photo("photo-1505740420928-5e560c06d30e"), alt: "Black headphones on a yellow background" }, services: [{ label: "Paid advertising", href: "/services/paid-advertising" }], }, { title: "Verde Plant Co.", year: "2023", href: "/projects/verde-plant-co", image: { src: photo("photo-1485955900006-10f4d324d411"), alt: "Small succulent in a pale blue pot on a white background" }, services: [{ label: "SEO", href: "/services/seo" }, { label: "Content", href: "/services/content" }], }, ] as GalleryProject[], }; export function ProjectsGallery({ title = defaults.title, projects = defaults.projects, defaultView = "grid", labels = defaults.labels, className, }: ProjectsGalleryProps) { const [view, setView] = React.useState(defaultView); /* Fade only after a switch, so the server-rendered first view shows at once. */ const [switched, setSwitched] = React.useState(false); return ( <section className={cn("py-(--section-y)", className)}> <h2 className="sr-only">{title}</h2> <div className="mx-auto flex max-w-(--container) flex-col gap-6 px-(--gutter)"> <SegmentedControl label={labels.view} size="sm" className="self-end" value={view} onValueChange={(next) => { setView(next); setSwitched(true); }} options={[ { value: "grid", label: labels.grid }, { value: "list", label: labels.list }, ]} /> <motion.div key={view} initial={switched ? { opacity: 0 } : false} animate={{ opacity: 1 }} transition={{ duration: 0.3, ease: "easeOut" }} > {view === "grid" ? ( <div className="grid gap-12 md:grid-cols-2 md:gap-x-8"> {projects.map((project) => ( <article key={project.href} className="group flex flex-col gap-4"> <Link href={project.href} tabIndex={-1} aria-hidden className="block"> <ZoomCard className="aspect-[545/400]"> <img src={project.image.src} alt={project.image.alt} className="object-cover" /> </ZoomCard> </Link> <div className="flex flex-col gap-2"> <div className="flex h-5 items-start justify-between gap-5 overflow-hidden text-sm whitespace-nowrap text-muted-foreground"> <p className="flex min-w-0 flex-wrap gap-x-1"> {project.services?.map((service, i) => { const label = `${service.label}${i < (project.services?.length ?? 0) - 1 ? "," : ""}`; return service.href ? ( <Link key={service.label} href={service.href} className="transition-colors hover:text-foreground hover:underline hover:underline-offset-4" > {label} </Link> ) : ( <span key={service.label}>{label}</span> ); })} </p> <p>{project.year}</p> </div> <h3 className="font-heading text-h3 font-semibold tracking-tight"> <Link href={project.href} className="hover:underline hover:underline-offset-4"> {project.title} </Link> </h3> </div> </article> ))} </div> ) : ( <div className="border-b border-border"> {projects.map((project) => ( <article key={project.href} className="group flex items-start gap-5 border-t border-border py-5 md:gap-8 lg:gap-12" > <div className="flex min-w-0 flex-1 flex-col gap-2.5 md:flex-row md:items-start md:gap-8 lg:gap-12"> <p className="shrink-0 text-sm text-muted-foreground">{project.year}</p> <h3 className="min-w-0 flex-1 font-heading text-h3 font-semibold tracking-tight"> <Link href={project.href} className="hover:underline hover:underline-offset-4"> {project.title} </Link> </h3> </div> <Link href={project.href} tabIndex={-1} aria-hidden className="block min-w-0 flex-1 max-md:max-w-25 md:max-w-57.5" > <ZoomCard className="aspect-[1.3625]"> <img src={project.image.src} alt={project.image.alt} className="object-cover" /> </ZoomCard> </Link> <UnderlineLink href={project.href} className="hidden lg:inline-flex"> {labels.details} <span className="sr-only">: {project.title}</span> </UnderlineLink> </article> ))} </div> )} </motion.div> </div> </section> ); }
Usage
import { ProjectsGallery } from "@/components/projects-gallery";
<ProjectsGallery
defaultView="grid"
projects={[
{ title: "Harvest Kitchen", year: "2025", href: "/projects/harvest-kitchen", image: { src: "/work/harvest.jpg", alt: "…" } },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | — | Section heading for screen readers (visually hidden) — put hero-title above it. |
| projects | { title, year, href, image, services? }[] | 6 projects | Shown in both views; titles are h3 links. |
| defaultView | "grid" | "list" | "grid" | View on first render. |
| labels | { grid, list, view, details } | — | Switch options, its accessible name, and the list's link text. |
| className | string | — | Classes for the <section>. |
- The switch's fill slides (segmented-control; arrow keys work); the new view fades in over 0.3s.
- List rows: year and title (side by side from 768px), a 1.3625:1 photo capped at 100px (230px from 768px), and from 1024px a View details link.
- Photos zoom to 105% while their card or row is hovered (zoom-card) — list rows at every width, not only from 1024px as on Framer.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS + Motion block called ProjectsGallery (Next.js). - <section className="py-(--section-y)"> with a visually hidden h2; container mx-auto max-w-(--container) px-(--gutter), column gap-6. - A Grid / List segmented control (radio group, sliding fill) aligned right. Switching fades the new view in (opacity 0 → 1, 0.3s ease-out), but not on first render. - Grid: 1 column, 2 from 768px (gap-x-8 gap-y-12); cards as in ProjectsGrid (545:400 photo that zooms to 1.05 over 400ms cubic-bezier(0.12,0.23,0.5,1), a one-line muted meta row of service links and year, an h3 title link). - List: rows with border-t (and a border-b under the last), py-5, items-start: year (text-sm muted) and title (h3) stacked, side by side from 768px; a 1.3625:1 photo (rounded-lg, max 100px, 230px from 768px) that zooms on hover; from 1024px a "View details" text link whose underline grows on hover. - Only shadcn variables and block tokens; plain <img> with alt text.











