← alle entries

project-grid

FC-0112Sectiesproject-gridblockv1.0.0

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.

zelfde component — andere tokens
Nova Studio

Nova Studio

Helder Finance

Helder Finance

Oker Interieur

Oker Interieur

Atelier Zuid

Atelier Zuid

Vier projecten in 5/7 en 7/5; de kaarten komen omhoog bij in beeld komen.

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; } }