SectionsIntermediateMay 4, 2026
Portfolio Grid
A responsive portfolio showcase section with a header tagline, blinking cursor counters, a 2-up/4-col project card grid with hover-zoom images and data-label metadata, plus a full-width CTA button. Scroll-triggered fade and move-up animations via GSAP.
View Full Demo →Preview
Portfolio
We help brands grow and tell their stories to the world.
_12
Source
demo.jsx
import PortfolioGrid from "./index.jsx";
import { portfolioGrid } from "../demo-data.js";
export default function PortfolioGridDemo() {
return <PortfolioGrid {...portfolioGrid} />;
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import styles from "./styles.module.css";
gsap.registerPlugin(ScrollTrigger);
function BlinkingCursor({ value }) {
const cursorRef = useRef(null);
useEffect(() => {
const ctx = gsap.context(() => {
gsap.to(cursorRef.current, {
opacity: 0,
duration: 0.5,
repeat: -1,
yoyo: true,
ease: "steps(1)",
});
});
return () => ctx.revert();
}, []);
return (
<span className={styles.counter}>
<span ref={cursorRef} className={styles.cursor}>_</span>
<span>{value}</span>
</span>
);
}
function CaseCard({ item }) {
const { title, services, year, image, href = "#" } = item;
return (
<a className={styles.card} href={href}>
<div className={styles.cardMedia}>
<div className={styles.cardImageWrap}>
<img
src={image}
alt={title}
className={styles.cardImage}
loading="lazy"
/>
</div>
</div>
<div className={styles.cardInfo}>
<h2 className={styles.cardTitle} data-label="Project">
<span>{title}</span>
</h2>
<p className={styles.cardServices} data-label="Services">
<span>{services}</span>
</p>
<p className={styles.cardYear} data-label="Year">
<span>({year})</span>
</p>
</div>
</a>
);
}
export default function PortfolioGrid({
label = "Portfolio",
tagline = "We help brands grow and tell their stories to the world.",
projectCount = 12,
totalWorks = 37,
ctaLabel = "Discover all works",
ctaHref = "#",
items = [],
}) {
const sectionRef = useRef(null);
const headerRef = useRef(null);
const cardsRef = useRef([]);
const footerRef = useRef(null);
useEffect(() => {
const ctx = gsap.context(() => {
// Header fade in
gsap.from(headerRef.current, {
opacity: 0,
duration: 0.8,
ease: "power2.out",
scrollTrigger: {
trigger: headerRef.current,
start: "top 85%",
once: true,
},
});
// Cards move up stagger
const cards = cardsRef.current.filter(Boolean);
gsap.set(cards, { y: 60, opacity: 0 });
ScrollTrigger.batch(cards, {
start: "top 90%",
once: true,
onEnter: (batch) => {
gsap.to(batch, {
y: 0,
opacity: 1,
duration: 0.9,
ease: "power3.out",
stagger: 0.08,
});
},
});
// Footer fade in
gsap.from(footerRef.current, {
opacity: 0,
duration: 0.8,
ease: "power2.out",
scrollTrigger: {
trigger: footerRef.current,
start: "top 90%",
once: true,
},
});
}, sectionRef);
return () => ctx.revert();
}, []);
return (
<section ref={sectionRef} className={styles.section}>
{/* Header */}
<div ref={headerRef} className={styles.header}>
<div className={styles.labelWrap}>
<span className={styles.dot} />
{label}
</div>
<h3 className={styles.tagline}>{tagline}</h3>
<div className={styles.headerCounter}>
<BlinkingCursor value={projectCount} />
</div>
</div>
{/* Project Grid */}
<div className={styles.grid}>
{items.map((item, i) => (
<div
key={i}
className={styles.cardWrap}
ref={(el) => (cardsRef.current[i] = el)}
>
<CaseCard item={item} />
</div>
))}
</div>
{/* Footer CTA */}
<div ref={footerRef} className={styles.footer}>
<div className={styles.footerInner}>
<div className={styles.footerLabel}>
<span>Works</span>
<BlinkingCursor value={totalWorks} />
</div>
<a className={styles.cta} href={ctaHref}>
<span className={styles.ctaText}>{ctaLabel}</span>
<span className={styles.ctaArrow}>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 7h12M8 2l5 5-5 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</a>
</div>
</div>
</section>
);
}
styles.module.css
/* ── Section ── */
.section {
width: 100%;
background: #fff;
border-top: 1px solid rgba(0, 0, 0, 0.2);
padding: 1.5rem 1.6rem 14rem;
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto,
sans-serif;
color: #000;
line-height: 1.4;
letter-spacing: -0.02em;
}
/* ── Header ── */
.header {
position: relative;
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
margin-bottom: 4rem;
}
.labelWrap {
display: flex;
align-items: baseline;
font-size: 1rem;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
display: inline-block;
flex-shrink: 0;
margin-right: 10px;
}
.tagline {
font-size: clamp(1.5rem, 3vw, 2.25rem);
font-weight: 400;
line-height: 1.2;
margin: 0;
max-width: 35.5rem;
}
.headerCounter {
font-family: "SF Mono", "Fira Code", Menlo, monospace;
font-size: clamp(1.5rem, 3vw, 2.25rem);
line-height: 1;
}
/* ── Counter / Blink ── */
.counter {
font-family: "SF Mono", "Fira Code", Menlo, monospace;
direction: ltr;
}
.cursor {
display: inline-block;
}
/* ── Grid ── */
.grid {
display: flex;
flex-direction: column;
gap: 2.5rem;
margin-bottom: 4rem;
}
.cardWrap {
display: block;
}
/* ── Card ── */
.card {
display: block;
text-decoration: none;
color: inherit;
}
.cardMedia {
margin-bottom: 1rem;
}
.cardImageWrap {
border-radius: 4px;
overflow: hidden;
position: relative;
aspect-ratio: 695 / 480;
}
.cardImage {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
background: #f5f5f5;
transition: transform 0.7s ease;
}
.card:hover .cardImage {
transform: scale(1.1);
}
/* ── Card Info ── */
.cardInfo {
line-height: 1.3;
}
.cardTitle {
font-size: 0.875rem;
font-weight: 400;
margin: 0;
}
.cardTitle span {
background-image: linear-gradient(currentColor, currentColor);
background-position: 0 100%;
background-repeat: no-repeat;
background-size: 0% 1px;
transition: background-size 0.4s ease;
}
.card:hover .cardTitle span {
background-size: 100% 1px;
}
.cardServices {
font-size: 0.875rem;
margin: 0;
color: #555;
}
.cardYear {
font-size: 0.875rem;
font-family: "SF Mono", "Fira Code", Menlo, monospace;
margin: 0;
color: #555;
}
/* ── Data labels (before pseudo) ── */
.cardTitle::before,
.cardServices::before,
.cardYear::before {
content: attr(data-label);
display: none;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #999;
margin-bottom: 0.25rem;
}
/* ── Footer CTA ── */
.footer {
display: block;
}
.footerInner {
width: 100%;
}
.footerLabel {
display: flex;
justify-content: space-between;
align-items: flex-end;
font-size: clamp(1.5rem, 3vw, 2.25rem);
font-weight: 400;
margin-bottom: 0.75rem;
}
.cta {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
padding: 1rem 2rem;
border: 1px solid #000;
border-radius: 9999px;
background: #000;
color: #fff;
text-decoration: none;
font-size: 0.875rem;
letter-spacing: 0.02em;
transition: background 0.3s ease, color 0.3s ease;
cursor: pointer;
}
.cta:hover {
background: transparent;
color: #000;
}
.ctaText {
display: inline-block;
}
.ctaArrow {
display: flex;
align-items: center;
}
/* ── Tablet (≥ 768px) ── */
@media (min-width: 768px) {
.section {
padding: 1.5rem 2rem 14rem;
}
.header {
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 6rem;
}
.headerCounter {
position: absolute;
top: 0;
right: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem 1rem;
row-gap: 3.5rem;
margin-bottom: 4rem;
}
.cardTitle {
font-size: 1rem;
}
.cardTitle::before,
.cardServices::before,
.cardYear::before {
display: block;
}
.footer {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.footerInner {
grid-column: 2;
}
}
/* ── Desktop (≥ 1024px) ── */
@media (min-width: 1024px) {
.section {
padding: 1.5rem 2.5rem 16rem;
}
.header {
margin-bottom: 6rem;
}
.grid {
grid-template-columns: repeat(4, 1fr);
margin-bottom: 10rem;
}
.cardWrap {
grid-column: span 2;
}
.dot {
width: 12px;
height: 12px;
}
}
/* ── Wide (≥ 1280px) ── */
@media (min-width: 1280px) {
.section {
padding: 1.5rem 3rem 22rem;
max-width: 1600px;
margin: 0 auto;
}
}
/* ── Mobile (≤ 767px) ── */
@media (max-width: 767px) {
.headerCounter {
display: none;
}
}
Dependencies
gsap




