Retour au catalogue

Customer Stories Scroll

3 cards horizontales case study avec reveal diagonal clipPath au scroll, border accent animée au hover, stats count-up et citation courte. Style Linear / Apple.

customer-storiesmedium Both Responsive a11y
elegantboldeditorialsaasuniversalagencystacked
Theme

How to build a scroll-reveal customer stories section in React

A React customer stories section with scroll reveal uses Framer Motion's useInView to trigger a clip-path animation on each card as it enters the viewport, while a count-up effect on the key stat is driven by useMotionValue and animate. Each card also animates a left accent border and a background tint on hover using Framer Motion variants.

  • Stack: React 18 + Framer Motion 11 + CSS custom properties, ~250 lines split across two files, zero icon library.
  • Scroll detection: useInView with once:true and a -80px margin so the animation fires just before the card is fully visible.
  • Count-up: Framer Motion's animate() drives useMotionValue from 0 to the numeric target over 1.6s with an easeOut curve; supports integers and one-decimal floats.
  • Accessible: cards use semantic blockquote + author attribution; the clip-path animation respects prefers-reduced-motion if handled at the theme level.
  • Responsive: the three-column card grid collapses gracefully on narrow screens via CSS grid.

Customer Stories Scroll is a stacked section of horizontal case-study cards, each revealing itself diagonally as it enters the viewport. The pattern combines a clip-path sweep, a count-up key metric, and a hover state with an animated left-border accent, a combination closer to Linear or Apple case study pages than a generic testimonials grid.

Anatomy

The parent component renders a header (optional badge, h2, subtitle) animated with a single fade-up on mount, followed by a vertical flex column of StoryCard components. Each StoryCard is a three-column CSS grid: the left column holds the company logo text and an industry pill; the center column shows the animated stat; the right column contains the blockquote, author attribution and an optional case study link. A hover-driven background tint and a scaleY left accent border overlay the card without shifting its layout.

How it works

Each StoryCard clips itself from right to left with `clipPath: 'inset(0 100% 0 0)'` at rest, then animates to `inset(0 0% 0 0)` when useInView fires. Cards stagger by 120ms per index. The stat count-up lives in a separate AnimatedStat component: useInView triggers a Framer Motion animate() call that increments a useMotionValue from 0 to the target number; useTransform rounds it to an integer or one decimal. Hover effects use Framer Motion variants on the parent motion.div, the accent border drives scaleY from its top origin, the tint fades in independently.

How to build it in React

  1. Set up the scroll reveal on each card

    Attach a ref to each card's wrapper and pass it to useInView with once:true. Use the inView boolean to toggle between the initial clipped state and the fully revealed state. Stagger the delay by multiplying the card index by 0.12.

    const ref = useRef<HTMLDivElement>(null);
    const inView = useInView(ref, { once: true, margin: "-80px" });
    
    <motion.div
      ref={ref}
      initial={{ clipPath: "inset(0 100% 0 0)", opacity: 0.6 }}
      animate={inView ? { clipPath: "inset(0 0% 0 0)", opacity: 1 } : {}}
      transition={{
        clipPath: { duration: 0.75, delay: index * 0.12, ease: [0.22, 1, 0.36, 1] },
        opacity: { duration: 0.3, delay: index * 0.12 },
      }}
    />
  2. Build the count-up stat

    Create a dedicated AnimatedStat component. Use useMotionValue starting at 0, then call animate() inside a useEffect when inView becomes true. useTransform handles the rounding, Math.round for integers, toFixed(1) for decimals. Store the animation controller so you can stop it on cleanup.

    const count = useMotionValue(0);
    const rounded = useTransform(count, (v) =>
      isFloat ? v.toFixed(1) : Math.round(v).toString()
    );
    
    useEffect(() => {
      if (!inView) return;
      const ctrl = animate(count, numericValue, { duration: 1.6, ease: "easeOut" });
      return () => ctrl.stop();
    }, [inView]);
  3. Add the hover accent border

    Wrap the card contents in a motion.div with initial='rest' and whileHover='hover'. Define variants for the left-border overlay: scaleY goes from 0 to 1 with transformOrigin set to 'top'. A second variant controls the background tint opacity independently with its own transition duration.

    <motion.div initial="rest" whileHover="hover">
      {/* Accent border */}
      <motion.div
        variants={{ rest: { scaleY: 0 }, hover: { scaleY: 1 } }}
        transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
        style={{ position: "absolute", left: 0, top: 0, bottom: 0,
          width: 3, background: "var(--color-accent)", transformOrigin: "top" }}
      />
    </motion.div>
  4. Wire up the data shape

    Each story needs an id, a logo text, an industry label, a numeric stat string (integer or one-decimal float), an optional suffix like '%' or 'M€', a stat label, a quote, author, role, and an optional href. Keep the stat as a string to let AnimatedStat detect whether to apply toFixed(1).

    interface Story {
      id: string;
      logo: string;
      industry: string;
      stat: string;        // "340", "2.4", "98"
      statSuffix?: string; // "%", "M€"
      statLabel: string;
      quote: string;
      author: string;
      role: string;
      href?: string;
    }

When to use it

Place this section in the middle or late funnel, after features or pricing, where concrete results reinforce the decision. It works best with 3 to 5 stories; fewer feels thin, more breaks the rhythm of the reveal. Skip it on pure marketing pages that already have a testimonials grid above the fold: two social-proof sections back to back dilute both. On mobile, the three-column card grid becomes a single-column stack, which works fine but loses the quick comparison between logo, stat and quote.

Used by

  • Linear, Uses horizontally laid-out customer story cards with a prominent metric, short quote and author attribution on its marketing site.
  • Vercel, Customer pages combine a key performance stat, a one-sentence quote and a company logo in a scannable card format.
  • Stripe, Stacked case-study entries with industry tags, numeric outcomes and blockquote excerpts, the same information hierarchy this component uses.
  • Notion, Customer story cards pair a headline stat with a short testimonial and a role/company tag, revealed progressively as the user scrolls.

FAQ

Why clip-path instead of a slide-in translateX animation?

clip-path reveal keeps the card in its final position in the layout from the start, so there is no layout shift and surrounding elements are never pushed around. A translateX slide-in requires overflow:hidden on a wrapper or the card visually bleeds outside its grid cell.

Can I use a real number (not a string) for the stat?

AnimatedStat reads the stat prop as a string and calls parseFloat on it, so both '340' and '2.4' work. The isFloat check looks for a dot character in the original string, which is why the prop must stay a string rather than a JavaScript number, 2.4 and 2 would both lose the dot.

How do I adjust the stagger delay between cards?

The delay is `index * 0.12` seconds, so the second card starts 120ms after the first and the third 240ms after. Change the multiplier to 0.08 for a tighter cascade or 0.2 for a more pronounced stagger. With more than 5 cards, keep the multiplier low or the last cards will wait too long.

What happens on prefers-reduced-motion?

The component does not currently check prefers-reduced-motion internally. To respect it, wrap the animate prop in a conditional: read the media query with a custom hook and skip to the final state immediately if reduced motion is requested. The count-up can also be skipped by animating with duration:0.

"use client";

import { motion, useInView, useMotionValue, useTransform, animate } from "framer-motion";
import { useRef, useEffect } from "react";

export interface Story {
  id: string;
  logo: string;
  industry: string;
  stat: string;
  statSuffix?: string;
  statLabel: string;
  quote: string;
  author: string;
  role: string;
  href?: string;
}

function AnimatedStat({ value, suffix = "" }: { value: string; suffix?: string }) {
  const ref = useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once: true, margin: "-40px" });
  const count = useMotionValue(0);

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Customer Stories Section with Scroll Reveal, Tutorial