Raster van projectbeelden met wisselende breedtes (5/7, 7/5 op twaalf kolommen), elk met de titel als donkere pil en een ronde pijlknop over het beeld; de kaarten komen omhoog zodra ze in beeld komen. Voor werk, cases en portfolio.
blocks/projects/project-grid.tsx
/**
* ProjectGrid — een raster van projectbeelden met wisselende breedtes
* (5/7, 7/5 op een 12-koloms raster), elk met de titel als donkere pil en
* een ronde pijlknop over het beeld; de kaarten komen omhoog zodra ze in
* beeld komen (scroll: trigger). Voor werk, cases en portfolio.
*
* Geoogst uit 21st.dev Condition Grid (uilayout): motion/react → framer op
* de smooth-token, lucide MoveUpRight → inline pad, next/image → img,
* bg-black/text-white → --foreground/--background, vier vaste projecten →
* `projects` van het project, klik → href per project (of onSelect).
*/
"use client";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
export interface GridProject {
id: string;
image: string;
title: string;
description?: string;
href?: string;
}
export interface ProjectGridProps extends Omit<React.HTMLAttributes<HTMLElement>, "children" | "onSelect"> {
projects: readonly GridProject[];
/** Eén keer verschijnen (standaard) of elke keer dat een kaart in beeld komt. */
once?: boolean;
onSelect?: (project: GridProject) => void;
linkComponent?: React.ElementType;
}
/** Kolommen op het 12-raster: eerste rij 5/7, laatste rij 7/5, daartussen 6/6. */
function span(i: number, n: number): number {
if (i === 0) return 5;
if (i === 1) return 7;
if (i === n - 2) return 7;
if (i === n - 1) return 5;
return 6;
}
export function ProjectGrid({ projects, once = true, onSelect, linkComponent: Link = "a", className, ...rest }: ProjectGridProps) {
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth", { mass: "content" });
return (
<section {...rest} className={["fp-projects", className].filter(Boolean).join(" ")}>
{projects.map((p, i) => (
<motion.article
key={p.id}
className="fp-projects__kaart"
style={{ ["--fp-projects-span" as string]: span(i, projects.length) } as React.CSSProperties}
initial={reduced ? false : { y: 50, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once, amount: 0.2 }}
transition={veer}
>
{/* eslint-disable-next-line @next/next/no-img-element -- beelden van het project */}
<img className="fp-projects__beeld" src={p.image} alt={p.description ?? p.title} loading="lazy" />
<div className="fp-projects__balk">
<h3 className="fp-projects__titel">{p.title}</h3>
{p.href ? (
<Link href={p.href} className="fp-projects__pijl" aria-label={p.title} onClick={() => onSelect?.(p)}>
<Pijl />
</Link>
) : (
<span className="fp-projects__pijl" aria-hidden="true"><Pijl /></span>
)}
</div>
</motion.article>
))}
</section>
);
}
function Pijl() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" width="1.1em" height="1.1em" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17L17 7M8 7h9v9" />
</svg>
);
}
blocks/projects/projects.css
/** ProjectGrid — importeren in de app-root (registry-site: app/globals.css). */
.fp-projects { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 1rem; width: 100%; box-sizing: border-box; padding: 0 1.25rem 0.5rem; overflow: hidden; color: var(--foreground); }
.fp-projects__kaart { position: relative; grid-column: span 12; margin: 0; }
@media (min-width: 40rem) { .fp-projects__kaart { grid-column: span var(--fp-projects-span, 6); } }
.fp-projects__beeld { display: block; width: 100%; height: 100%; aspect-ratio: 2 / 1; object-fit: cover; border-radius: calc(var(--radius) * 1.5); }
.fp-projects__balk { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 1rem; }
.fp-projects__titel { margin: 0; padding: 0.5rem 1rem; border-radius: calc(var(--radius) * 1.5); font: inherit; font-size: clamp(0.875rem, 1.6vw, 1.25rem); font-weight: 500; background: var(--foreground); color: var(--background); }
.fp-projects__pijl {
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 3rem; height: 3rem; border-radius: 50%;
background: var(--foreground); color: var(--background); text-decoration: none;
transition: transform var(--motion-instant-duration) var(--motion-instant);
}
a.fp-projects__pijl:hover { transform: translate(2px, -2px); }
a.fp-projects__pijl:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
@media (max-width: 40rem) { .fp-projects__pijl { width: 2.5rem; height: 2.5rem; } }