SectionsIntermediateApril 13, 2026

Blog Grid Reveal

A typographic blog grid with inline images embedded in large text links. Each row slides up into view on scroll via GSAP ScrollTrigger. Rows alternate alignment and images react on hover with rotation and opacity.

View Full Demo →
demo.jsx
import BlogGridReveal from "./index.jsx";
import { blogGridReveal } from "../demo-data.js";
import styles from "./demo.module.css";

export default function BlogGridRevealDemo() {
  return (
    <div className={styles.page}>
      <div className={styles.spacer} />
      <BlogGridReveal {...blogGridReveal} />
      <div className={styles.spacer} />
    </div>
  );
}
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 BlogItem({ item, reversed }) {
  const { title, title1, title2, image, size, color, href = "#", imagePosition = "before" } = item;

  const content = title1 && title2 ? (
    <span>
      {title1}{" "}
      <img src={image} alt="" className={styles.inlineImage} />
      {" "}{title2}
    </span>
  ) : imagePosition === "after" ? (
    <>
      {title}{" "}
      <img src={image} alt={title} className={styles.inlineImage} />
    </>
  ) : (
    <>
      <img src={image} alt={title} className={styles.inlineImage} />
      {" "}{title}
    </>
  );

  return (
    <div className={`${styles.row} ${reversed ? styles.reverse : ""}`}>
      <div className={styles.revealWrapper}>
        <div className={styles.revealInner}>
          <a href={href} className={styles.link} style={{ fontSize: size, color }}>
            {content}
          </a>
        </div>
      </div>
    </div>
  );
}

export default function BlogGridReveal({
  label = "Essential",
  items = [],
  readMoreLabel = "Read All",
  readMoreHref = "#",
}) {
  const sectionRef = useRef(null);

  useEffect(() => {
    const ctx = gsap.context(() => {
      const els = gsap.utils.toArray(`.${styles.revealInner}`);

      gsap.set(els, { yPercent: 100 });

      ScrollTrigger.create({
        trigger: sectionRef.current,
        start: "top 60%",
        once: true,
        onEnter: () => {
          gsap.to(els, {
            yPercent: 0,
            duration: 1.25,
            ease: "power4.out",
            stagger: 0.04,
          });
        },
      });
    }, sectionRef);

    return () => ctx.revert();
  }, []);

  return (
    <section ref={sectionRef} className={styles.wrapper}>
      <div className={styles.column}>
        <div className={styles.revealWrapper}>
          <div className={`${styles.revealInner} ${styles.label}`}>
            {label}
          </div>
        </div>

        {items.map((item, index) => (
          <BlogItem key={index} item={item} reversed={index % 2 !== 0} />
        ))}

        <div className={styles.revealWrapper}>
          <div className={styles.revealInner}>
            <a href={readMoreHref} className={styles.readMore}>
              <span>{readMoreLabel}</span>
            </a>
          </div>
        </div>
      </div>
    </section>
  );
}
demo.module.css
.page {
  background: #f1ebe7;
}

.spacer {
  height: 50vh;
}
styles.module.css
.wrapper {
  width: 100%;
  min-height: 70vh;
  background-color: #f1ebe7;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4rem 0;
  box-sizing: border-box;
}

.column {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
}

/* ---- Reveal clip ---- */
.revealWrapper {
  overflow: hidden;
  display: block;
}

.revealInner {
  will-change: transform;
}

/* ---- Label ---- */
.label {
  font-size: 14px;
  color: #000;
  text-transform: uppercase;
  text-align: center;
  padding: 2rem 0;
  width: 100%;
}

/* ---- Rows ---- */
.row {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 1rem;
  margin: 0.25rem 0;
}

.reverse {
  flex-direction: row-reverse;
}

/* ---- Link ---- */
.link {
  color: #000;
  text-transform: uppercase;
  text-decoration: none;
  letter-spacing: -0.06em;
  transition: color 0.4s ease;
  display: inline-flex;
  align-items: center;
}

.link:hover {
  color: #000;
}

/* ---- Inline image ---- */
.inlineImage {
  height: 1.25em;
  width: auto;
  vertical-align: middle;
  margin: 0 0.4em;
  opacity: 0.5;
  transition: transform 0.3s ease, opacity 0.3s ease;
  display: inline-block;
  object-fit: contain;
}

.link:hover .inlineImage {
  opacity: 1;
  transform: rotate(7deg) scale(1.05);
}

/* ---- Read more ---- */
.readMore {
  font-size: 14px;
  color: #000;
  text-transform: uppercase;
  text-align: center;
  text-decoration: none;
  margin-top: 1.25rem;
  cursor: pointer;
  display: inline-block;
}

.readMore span {
  display: inline-block;
  position: relative;
}

.readMore span::after {
  content: "";
  display: block;
  margin: 0.25rem auto 0;
  width: 100%;
  height: 1px;
  background: #000;
  transition: width 0.3s ease;
}

.readMore:hover span::after {
  width: 60%;
}

/* ---- Mobile ---- */
@media (max-width: 768px) {
  .wrapper {
    min-height: auto;
    padding: 3rem 1rem;
  }

  .link {
    letter-spacing: -0.04em;
  }
}
  • 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 4, 2026

SECTIONS

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.

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.