SectionsSimpleApril 21, 2026

Testimonial Grid

A 3-column testimonial card grid with star ratings, italic serif quotes, and attribution lines. Warm cream background with white cards. Shopify CMS-ready with commented integration code for metaobjects and review apps.

View Full Demo →

What Customers Say

Don't take our word for it.

I have really dry skin on the back of my arms and haven't found anything that locks in hydration like this. After four uses, I'm already seeing a difference.

Alura M. · Vanilla Body Butter

Started using this in my third trimester and my stretch marks are noticeably softer. I put it on my daughter's eczema patches too — the only thing that doesn't make her fuss.

Priya T. · Unscented Body Butter

Bought the mini set as baby shower favors. Every single person asked me where to get more. Now I just buy them in bulk.

Danielle R. · Mini Set of Three

demo.jsx
import TestimonialGrid from "./index.jsx";
import { testimonialGrid } from "../demo-data.js";

export default function TestimonialGridDemo() {
  return <TestimonialGrid {...testimonialGrid} />;
}
index.jsx
"use client";

import styles from "./styles.module.css";

/*
  ── Shopify CMS Integration ──
  To connect to Shopify metaobjects or a reviews app:

  // Option A: Shopify Metaobjects (Admin API)
  // Create a metaobject definition "testimonial" with fields:
  //   - quote (multi_line_text_field)
  //   - name (single_line_text_field)
  //   - product (single_line_text_field)
  //   - rating (number_integer, min: 1, max: 5)
  //
  // Query via Storefront API:
  // const TESTIMONIALS_QUERY = `
  //   query {
  //     metaobjects(type: "testimonial", first: 6) {
  //       nodes {
  //         fields {
  //           key
  //           value
  //         }
  //       }
  //     }
  //   }
  // `;
  //
  // Then map the response:
  // const items = data.metaobjects.nodes.map(node => ({
  //   quote: node.fields.find(f => f.key === "quote")?.value,
  //   name: node.fields.find(f => f.key === "name")?.value,
  //   product: node.fields.find(f => f.key === "product")?.value,
  //   rating: parseInt(node.fields.find(f => f.key === "rating")?.value) || 5,
  // }));

  // Option B: Judge.me / Stamped / Loox
  // Fetch from their public API endpoint:
  // const res = await fetch(`https://judge.me/api/v1/reviews?shop_domain=${shop}&api_token=${token}`);
  // Map response to same { quote, name, product, rating } shape.
*/

function Stars({ count = 5 }) {
  return (
    <div className={styles.stars}>
      {Array.from({ length: count }).map((_, i) => (
        <span key={i} className={styles.star}>★</span>
      ))}
    </div>
  );
}

export default function TestimonialGrid({
  label = "What Customers Say",
  heading,
  headingEmphasis,
  items = [],
}) {
  // Build heading with optional italic emphasis word
  const renderHeading = () => {
    if (!headingEmphasis || !heading) return heading;
    const parts = heading.split(headingEmphasis);
    if (parts.length < 2) return heading;
    return (
      <>
        {parts[0]}<em>{headingEmphasis}</em>{parts[1]}
      </>
    );
  };

  return (
    <section className={styles.section}>
      {label && <p className={styles.label}>{label}</p>}
      {heading && <h2 className={styles.heading}>{renderHeading()}</h2>}

      <div className={styles.grid}>
        {items.map((item, i) => (
          <div key={i} className={styles.card}>
            <Stars count={item.rating || 5} />
            <blockquote className={styles.quote}>
              &ldquo;{item.quote}&rdquo;
            </blockquote>
            <p className={styles.attribution}>
              {item.name}
              {item.product && (
                <>
                  <span className={styles.dot}> · </span>
                  {item.product}
                </>
              )}
            </p>
          </div>
        ))}
      </div>
    </section>
  );
}
styles.module.css
.section {
  width: 100%;
  background: #f1ebe7;
  padding: 6rem 2rem 8rem;
  text-align: center;
}

/* ---- Label ---- */
.label {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: #555;
  margin-bottom: 1.5rem;
}

/* ---- Heading ---- */
.heading {
  font-size: clamp(1.75rem, 3.5vw, 3rem);
  font-weight: 400;
  line-height: 1.2;
  color: #1a1a1a;
  margin-bottom: 3.5rem;
  font-family: Georgia, "Times New Roman", serif;
}

.heading em {
  font-style: italic;
}

/* ---- Grid ---- */
.grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.5rem;
  max-width: 1200px;
  margin: 0 auto;
}

/* ---- Card ---- */
.card {
  background: #fff;
  border-radius: 0.5rem;
  padding: 2.5rem 2rem 2rem;
  text-align: left;
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
}

/* ---- Stars ---- */
.stars {
  display: flex;
  gap: 0.15rem;
}

.star {
  color: #a8875b;
  font-size: 0.9rem;
}

/* ---- Quote ---- */
.quote {
  font-family: Georgia, "Times New Roman", serif;
  font-style: italic;
  font-size: clamp(1rem, 1.3vw, 1.2rem);
  line-height: 1.55;
  color: #1a1a1a;
  flex: 1;
}

/* ---- Attribution ---- */
.attribution {
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: #555;
  margin-top: auto;
}

.dot {
  color: #999;
}

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

/* ---- Mobile ---- */
@media (max-width: 767px) {
  .section {
    padding: 4rem 1rem 5rem;
  }

  .card {
    padding: 2rem 1.5rem 1.5rem;
  }
}

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.