Animated CSS Grid in Next.js: A Step-by-Step Tailwind and GSAP Tutorial

Matthew FrawleyMatthew FrawleyFebruary 4th, 2025

Project Setup and Dependencies

Once you have a Next.js project set up. You'll need these packages:

npm i tailwind-merge gsap @gsap/react

Defining the data

Before we dive into our JSX structure, let's create some data for our grid cards.

By using an array we can map over the elements rather than writing out the same markup over and over. This helps us adhere to the DRY principle of Don't Repeat Yourself and makes it much easier to change content and styling in the future.

You can download the SVG icons here

import Image, { type StaticImageData } from 'next/image'
import { type FC, type ReactNode, useRef } from 'react'
 
import nextIcon from '@/assets/icons/technologies/next.svg'
import reactIcon from '@/assets/icons/technologies/react.svg'
import tailwindIcon from '@/assets/icons/technologies/tailwind.svg'
import gsapIcon from '@/assets/icons/technologies/gsap.svg'
import typescriptIcon from '@/assets/icons/technologies/typescript.svg'
import webGLIcon from '@/assets/icons/technologies/webgl.svg'
 
type GridCard = {
  image: StaticImageData
  heading: string
  description: ReactNode
}
 
const DATA: GridCard[] = [
  {
    image: nextIcon,
    heading: 'Next.js',
    description:
      'A powerful React framework that supports hybrid static & server rendering, route pre-fetching, and more.',
  },
  {
    image: reactIcon,
    heading: 'React',
    description: 'A declarative, component-based JavaScript library for building modern user interfaces with ease.',
  },
  {
    image: tailwindIcon,
    heading: 'Tailwind CSS',
    description:
      'A utility-first CSS framework packed with classes that enable rapid and responsive design development.',
  },
  {
    image: gsapIcon,
    heading: 'GSAP',
    description:
      'A robust JavaScript library for high-performance animations that work seamlessly in every major browser.',
  },
  {
    image: typescriptIcon,
    heading: 'TypeScript',
    description: (
      // We can inline JSX in the description like this because it's of type ReactNode
      <>
        A <b>strongly typed</b> superset of JavaScript that enhances code quality and maintainability for large
        projects.
      </>
    ),
  },
  {
    image: webGLIcon,
    heading: 'WebGL',
    description:
      'A JavaScript API for rendering interactive 2D and 3D graphics in the browser.',
  },
] as const

Note:

  • We define a type to ensure each object in the array has the correct shape.
  • The image is of type StaticImageData, which is a Next.js-specific type for imported image assets. It contains both the source (url) and size data.
  • We use ReactNode for the description type to allow for inline JSX content - spot the bold phrase!
  • Finally, the array is marked with as const to make it read-only as we know this data won't and shouldn't change.

Building a basic grid

Now that we have our data ready to roll, it's time to write the JSX for our grid. We're creating a section that serves as a 3-column grid, and we'll map over our data to render a set of simple cards.

Adjust the styling, make it beautiful! 💅

AnimatedGrid.tsx
const AnimatedGrid: FC = () => {
  return (
    <section className="relative grid w-full grid-cols-3 gap-6 px-14 py-10 text-white">
      {DATA.map((item, index) => (
        <div key={index} className="space-y-3 rounded-xl bg-white/5 p-6 shadow-2xl">
          <Image src={item.image} alt={item.heading} className="h-20" />
          <h5 className="text-2xl font-bold">{item.heading}</h5>
          <p className="text-base text-white/80">{item.description}</p>
        </div>
      ))}
    </section>
  )
}
 
export default AnimatedGrid

What's happening here?

  • The <section> is styled as a grid with 3 columns using the grid grid-cols-3 classes.
  • We aren't stating how many rows the grid should have as it will automatically adjust based on the number of items.
  • Data mapping: We loop through our data and return a <div> card with an image, heading (h5), and description (p) for each item.

From Static to Dynamic: Making the Grid Responsive

Time to level up! Let's make a responsive layout that adapts to both the number of cards and the screen size.

We conditionally assign Tailwind grid classes depending on whether we have 3, 4, or a different number of items.

Then, with the help of twMerge, we combine our base styling with these dynamic classes and an optional className prop.

AnimatedGrid.tsx
// Other imports...
import { twMerge } from 'tailwind-merge'
 
type Props = {
  className?: string
}
 
const AnimatedGrid: FC<Props> = ({ className }) => {
  // If there are exactly 3 or 4 items we tailor the columns so the cards are aligned and divided as evenly as possible
  const gridColsClass =
    CARD_DATA.length === 3
      ? 'md:grid-cols-3 grid-cols-1'
      : CARD_DATA.length === 4
        ? 'lg:grid-cols-4 md:grid-cols-2 grid-cols-1'
        : 'lg:grid-cols-3 md:grid-cols-2 grid-cols-1'
 
  return (
    <section
      ref={container}
      // Merge the base classes, grid column classes and optional className prop
      className={twMerge(
        'card relative grid w-full grid-cols-1 gap-6 px-14 py-10 text-white',
        gridColsClass,
        className,
      )}>
      {DATA.map((item, index) => (
        <div key={index} className="space-y-3 rounded-xl bg-white/5 p-6 shadow-2xl">
          <Image src={item.image} alt={item.heading} className="h-20" />
          <h5 className="text-2xl font-bold">{item.heading}</h5>
          <p className="text-base text-white/80">{item.description}</p>
        </div>
      ))}
    </section>
  )
}
 
export default AnimatedGrid

Summary of changes

  • The className props is spread into the section - allowing for additional styling to be passed in from the parent
  • twMerge is useful when there are, or could be, be class conflicts. For example the className prop could contain a grid-cols-5 class which we'd want to take precedence over the default grid classes.

Animating Cards with GSAP

In this section we'll bring it to life with some smooth animations using GSAP!

We're going to be using the useGSAP hook and ScrollTrigger plugin, so we need to import and register them outside of the component.

Now we can add a ref to our grid container and use that as the trigger element for the scroll based animations.

We'll hide the cards with an opacity-0 class and then animate them in using GSAP

The GSAP animations are scoped to the container, ensuring that only elements within the grid are targeted.

AnimatedGrid.tsx
'use client'
import { useGSAP } from '@gsap/react'
import gsap from 'gsap'
import ScrollTrigger from 'gsap/dist/ScrollTrigger'
// Imports and data...
 
gsap.registerPlugin(useGSAP, ScrollTrigger)
 
const AnimatedGrid: FC<Props> = ({ className }) => {
  const container = useRef<HTMLDivElement>(null)
 
  useGSAP(
    () => {
      if (!container.current) return
      // Our animation code is inside the useGSAP hook
      // define Grid based stagger parameters
      const staggerParams: gsap.StaggerVars = {
        each: 0.12,
        from: 'center',
        grid: 'auto',
        ease: 'power2.inOut',
      }
 
      // create the animation timeline with a scroll trigger
      gsap
        .timeline({
          scrollTrigger: {
            trigger: container.current,
            start: 'top 75%', // start when the top of the <section> is at 75% of the viewport (25% from the bottom)
            toggleActions: 'play none none reverse', // play animation when in view, reverse when out
          },
        })
        .fromTo(
          '.card',
          { opacity: 0, scale: 0.8 },
          {
            opacity: 1,
            scale: 1,
            duration: 0.6,
            ease: 'power2.out',
            stagger: staggerParams,
          },
        )
        .fromTo(
          'img',
          { opacity: 0, scale: 0.6 },
          {
            opacity: 1,
            scale: 1,
            duration: 0.5,
            ease: 'power2.out',
            stagger: staggerParams,
          },
          0.5,
        )
        .fromTo(
          'h5',
          { opacity: 0, y: 16 },
          {
            opacity: 1,
            y: 0,
            duration: 0.5,
            ease: 'power2.out',
          },
          '-=0.3',
        )
        .fromTo(
          'p',
          { opacity: 0, y: 12 },
          {
            opacity: 1,
            y: 0,
            duration: 0.4,
            ease: 'power2.out',
          },
          '-=0.3',
        )
    },
    { scope: container, dependencies: [] },
  )
 
  const gridColsClass =
    DATA.length === 3
      ? 'md:grid-cols-3 grid-cols-1'
      : DATA.length === 4
        ? 'lg:grid-cols-4 md:grid-cols-2 grid-cols-1'
        : 'lg:grid-cols-3 md:grid-cols-2 grid-cols-1'
 
  return (
    <section
      ref={container}
      className={twMerge('relative grid w-full grid-cols-1 gap-6 px-14 py-10 text-white', gridColsClass, className)}>
      {DATA.map((item, index) => (
        // The 'card' class is used as a selector in the animation
        // 'opacity-0' ensures the card is invisible until animated in
        <div key={index} className="card space-y-3 rounded-xl bg-white/5 p-6 opacity-0 shadow-2xl">
          <Image src={item.image} alt={item.heading} className="h-20" />
          <h5 className="text-sm font-semibold uppercase tracking-wide">{item.heading}</h5>
          <p className="text-base text-white/60">{item.description}</p>
        </div>
      ))}
    </section>
  )
}

Key points

  • The 'use client' directive marks the component as client-side. This is essential since we're using browser-specific functions such as useRef...
  • We add a React ref to our <section> container.
  • We use the ref as the useGSAP scope so we can target it's child elements using lazy selectors without affecting elements outside the component.
  • We make a safety check to ensure container.current exists before creating our animations.

Mobile friendly animations with GSAP 'matchMedia'

By leveraging GSAP's matchMedia utility, we can run different animations based on a media query.

We'll leave the desktop animation timeline as it was, but only run it if the screen width is 640px or more.

Narrow screens (less than 640px width) would benefit from a different type of transition. There is a single column, and you only see a couple of cards at any one time. The grid stagger is ineffective in this scenario because by the time the user scrolls down to the latter cards, they will have already animated in.

Instead, let's make it so that the cards animate individually as they enter the viewport. We can achieve this by creating a GSAP Tween with a scroll trigger unique to each card.

AnimatedGrid.tsx useGSAP hook
  useGSAP(
    () => {
      if (!container.current) return
 
      // Initialize matchMedia for responsive animations
      const matchMedia = gsap.matchMedia()
 
      // Smaller screens
      matchMedia.add('screen and (max-width: 639px)', () => {
        // Create an array of card elements create a GSAP animation for each
        const cards = gsap.utils.toArray('.card') as HTMLDivElement[]
 
        cards.forEach((card) => {
          gsap.fromTo(
            card,
            {
              opacity: 0,
              scale: 0.9,
              y: 24,
            },
            {
              opacity: 1,
              scale: 1,
              y: 0,
              duration: 0.5,
              ease: 'power2.in',
              scrollTrigger: {
                trigger: card,
                start: 'top 90%',
                toggleActions: 'play none none reverse',
              },
            },
          )
        })
        // Reset Y temporarily when the scroll trigger refreshes, 
        // because Y is initially set to 24 we don't want that to effect the scroll trigger calculations
        ScrollTrigger.addEventListener('refreshInit', () => {
          gsap.set('.card', { y: 0 })
        })
      })
 
      // Larger screens
      matchMedia.add('screen and (min-width: 640px)', () => {
        // Grid animation code from earlier
      })
    },
    { scope: container, dependencies: [] },
  )

Making it reusable: Passing the card data as Props

Let's finish things off by allowing our Component to receive data via props instead of hardcoding it.

If we export the GridCardProps type we can use it wherever we're creating card data.

After adding the 'cards' prop to the component, we can replace the hardcoded DATA array with the incoming value.

AnimatedGrid.tsx
// ...
 
export type GridCardProps = {
  image: StaticImageData
  heading: string
  description: ReactNode
}
 
type Props = {
  cards: GridCardProps[]
  className?: string
}
 
const AnimatedGrid: FC<Props> = ({ className, cards = [] }) => {
  // ...
  // Replace hardcoded 'DATA' with 'cards'
}

How does this help?

  • Dynamic Data Injection: The component now receives its card data via a prop, making it easy to pass dynamic content from a server-side page.
  • Type Safety: By exporting the type we can ensure each card adheres to the correct shape.

Example usage:

page.tsx
import AnimatedGrid, { type GridCardProps } from '@/components/examples/animatedCSSGrid/AnimatedCSSGrid'
 
const CARDS: GridCardProps[] = [
  {
    asset: idSVG,
    header: 'Serialised ID management',
    subheader: 'Give every item its unique ID to deliver trackable and personalised experiences',
  },
  // ...
];
 
export default function ExamplePage() {
  // fetch cards data? 
  return (
    <main className="w-full font-sans">
      <section className="h-lvh">
      </section>
      <AnimatedCSSGrid cards={CARDS} />
    </main>
  )
}

Signing Out


Thanks for reading, Matt ✌️