SectionsIntermediateApril 14, 2026

Services Accordion

A services section with a large SplitText heading reveal, description, and expandable accordion rows. Each row fills with a black panel on hover, inverting text to white. Expanded state reveals a description and a responsive grid of sub-services.

View Full Demo →

What We Do

We bring together the right human expertise with the best use of technology to help define, design and accelerate delivery of more valuable, personal interactions between people and brands across their full ecosystem.

demo.jsx
import ServicesAccordion from "./index.jsx";
import { servicesAccordion } from "../demo-data.js";

export default function ServicesAccordionDemo() {
  return <ServicesAccordion {...servicesAccordion} />;
}
index.jsx
"use client";

import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import styles from "./styles.module.css";

gsap.registerPlugin(ScrollTrigger, SplitText);

function AccordionItem({ item }) {
  const [open, setOpen] = useState(false);
  const contentRef = useRef(null);

  return (
    <div
      className={`${styles.item} ${open ? styles.open : ""}`}
      aria-expanded={open}
    >
      {/* Black fill panel */}
      <div className={styles.fillPanel} />

      {/* Header row */}
      <div className={styles.itemHeader} onClick={() => setOpen((o) => !o)}>
        <div className={styles.itemTitle}>
          <p>{item.title}</p>
        </div>
        <div className={styles.itemToggle}>+</div>
      </div>

      {/* Expandable content */}
      <div
        className={styles.itemContent}
        ref={contentRef}
        style={{ maxHeight: open ? `${contentRef.current?.scrollHeight}px` : "0px" }}
      >
        <div className={styles.contentInner}>
          {item.description && (
            <p className={styles.contentDesc}>{item.description}</p>
          )}
          {item.services?.length > 0 && (
            <ul className={styles.serviceGrid}>
              {item.services.map((service, i) => (
                <li key={i} className={styles.serviceItem}>
                  <p className={styles.serviceTitle}>{service.title}</p>
                  <p className={styles.serviceDesc}>{service.description}</p>
                </li>
              ))}
            </ul>
          )}
        </div>
      </div>
    </div>
  );
}

export default function ServicesAccordion({
  heading = "What We Do",
  description = "",
  items = [],
}) {
  const sectionRef = useRef(null);
  const headingRef = useRef(null);
  const descRef = useRef(null);

  useEffect(() => {
    const ctx = gsap.context(() => {
      const h2 = headingRef.current;
      if (h2) {
        SplitText.create(h2, {
          type: "chars",
          charsClass: styles.charChild,
          mask: "chars",
        });

        const chars = h2.querySelectorAll(`.${styles.charChild}`);
        gsap.from(chars, {
          yPercent: 100,
          opacity: 0,
          duration: 0.8,
          stagger: 0.03,
          ease: "power4.out",
          scrollTrigger: {
            trigger: sectionRef.current,
            start: "top 75%",
            once: true,
          },
        });
      }

      if (descRef.current) {
        gsap.from(descRef.current, {
          y: 20,
          opacity: 0,
          duration: 0.8,
          ease: "power3.out",
          scrollTrigger: {
            trigger: sectionRef.current,
            start: "top 65%",
            once: true,
          },
        });
      }
    }, sectionRef);

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

  return (
    <section ref={sectionRef} className={styles.section}>
      <h2 ref={headingRef} className={styles.heading}>
        {heading}
      </h2>
      {description && (
        <div ref={descRef}>
          <p className={styles.description}>{description}</p>
        </div>
      )}
      <div className={styles.accordion}>
        {items.map((item, i) => (
          <AccordionItem key={i} item={item} />
        ))}
      </div>
    </section>
  );
}
styles.module.css
.section {
  width: 100%;
  min-height: 100dvh;
  padding: 0 2rem;
}

/* ---- Heading ---- */
.heading {
  font-size: clamp(3rem, 10vw, 9rem);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.03em;
  color: #000;
  margin-bottom: -0.2em;
  padding: 0 2rem;
  text-transform: uppercase;
}

.charChild {
  position: relative;
  display: inline-block;
}

/* ---- Description ---- */
.description {
  padding: 2rem;
  margin-bottom: 2rem;
  max-width: 70ch;
  font-size: clamp(14px, 1.2vw, 18px);
  line-height: 1.45;
  color: rgba(0, 0, 0, 0.8);
}

/* ---- Accordion ---- */
.accordion {
  width: 100%;
}

/* ---- Item ---- */
.item {
  position: relative;
  overflow: hidden;
  cursor: pointer;
  border-top: 1px solid rgba(0, 0, 0, 0.05);
  border-bottom: 1px solid rgba(0, 0, 0, 0.05);
  transition: all 0.7s ease-in-out;
}

/* ---- Black fill panel (hover) ---- */
.fillPanel {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%) scaleY(0);
  width: 170%;
  height: 150%;
  background: #000;
  transition: transform 0.7s ease-in-out;
  transform-origin: top;
  pointer-events: none;
  z-index: 0;
}

.item:hover .fillPanel,
.item.open .fillPanel {
  transform: translateX(-50%) scaleY(1);
}

/* ---- Header row ---- */
.itemHeader {
  position: relative;
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  height: 150px;
  z-index: 1;
}

.itemTitle {
  padding-left: 2rem;
  transition: transform 0.7s ease-in-out;
}

.itemTitle p {
  font-size: clamp(1.5rem, 3vw, 2.5rem);
  text-transform: uppercase;
  color: #000;
  transition: color 0.7s ease-in-out;
}

.item:hover .itemTitle,
.item.open .itemTitle {
  transform: translateX(2rem);
}

.item:hover .itemTitle p,
.item.open .itemTitle p {
  color: #fff;
}

.itemToggle {
  padding-right: 1rem;
  font-size: 2.25rem;
  color: #000;
  transition: transform 0.7s ease-in-out, color 0.7s ease-in-out;
  z-index: 1;
}

.item:hover .itemToggle,
.item.open .itemToggle {
  transform: scale(3);
  color: #fff;
}

/* ---- Expandable content ---- */
.itemContent {
  position: relative;
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.7s ease-in-out;
  z-index: 1;
}

.contentInner {
  padding: 0 4rem 2rem;
}

.contentDesc {
  max-width: 55ch;
  font-size: 1rem;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.7);
  margin-bottom: 2.5rem;
}

/* ---- Service grid ---- */
.serviceGrid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2.5rem 3rem;
  list-style: none;
}

.serviceTitle {
  font-size: 0.95rem;
  font-weight: 600;
  color: #fff;
  margin-bottom: 0.35rem;
}

.serviceDesc {
  font-size: 0.875rem;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.6);
}

/* ---- Responsive ---- */
@media (min-width: 768px) {
  .serviceGrid {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (min-width: 1024px) {
  .serviceGrid {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (max-width: 768px) {
  .section {
    padding: 0 1rem;
  }

  .heading {
    padding: 0 1rem;
  }

  .description {
    padding: 1rem;
  }

  .itemTitle {
    padding-left: 1rem;
  }

  .contentInner {
    padding: 0 1rem 2rem;
  }
}
  • 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.