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 →
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;
  }
}
  • gsap

May 11, 2026

SECTIONS

Dual Push Cards

Two-up CTA card section with scroll-driven parallax on each card's background image. Cards scale down and drift vertically as you scroll past. Glassmorphic blur buttons at bottom-left. Stacks on mobile, side-by-side grid on desktop.

May 1, 2026

SECTIONS

Logo Wall Cycle

A responsive logo grid that cycles through brand logos with smooth GSAP-powered swap animations. Shows 8 logos on desktop and 6 on tablet, shuffling hidden logos into view on a timed loop. Pauses when out of viewport or tab is hidden.

Apr 27, 2026

SECTIONS

Case Overview Scroll

Full-viewport stacked case study slider. Each slide is sticky and its image receives scroll-driven translateY and scale transforms, creating a parallax depth effect as slides layer over each other. Inspired by DashDigital's case overview pattern.