Cómo crear una sección de casos de clientes con reveal al hacer scroll en React
Una sección de casos de clientes en React con reveal al hacer scroll usa useInView de Framer Motion para disparar una animación clip-path en cada tarjeta cuando entra en el viewport, mientras un efecto count-up sobre la stat clave se controla con useMotionValue y animate. Cada tarjeta también anima un borde de acento izquierdo y un tinte de fondo al hover mediante variants de Framer Motion.
- Stack: React 18 + Framer Motion 11 + CSS custom properties, ~250 líneas repartidas en dos archivos, cero librería de iconos.
- Detección de scroll: useInView con once:true y un margen de -80px, la animación se dispara justo antes de que la tarjeta sea totalmente visible.
- Count-up: animate() de Framer Motion lleva useMotionValue de 0 hasta el valor objetivo en 1,6s con una curva easeOut; soporta enteros y decimales con un dígito.
- Accesible: las tarjetas usan blockquote semántico + atribución de autor; la animación clip-path respeta prefers-reduced-motion si se gestiona a nivel del tema.
- Responsive: la cuadrícula de tres columnas por tarjeta se reduce con naturalidad en pantallas estrechas mediante CSS grid.
Customer Stories Scroll es una sección apilada de grandes tarjetas de casos de éxito horizontales, cada una revelándose en diagonal al entrar en el viewport. El patrón combina un barrido clip-path, una métrica clave en count-up y un estado hover con un borde de acento izquierdo animado, mucho más cercano a las páginas de casos de éxito de Linear o Apple que a una cuadrícula de testimonios genérica.
Anatomía
El componente padre renderiza un header (badge opcional, h2, subtítulo) animado con un simple fade-up al montar, seguido de una columna flex vertical de componentes StoryCard. Cada StoryCard es una cuadrícula CSS de tres columnas: la columna izquierda contiene el texto del logo de la empresa y una pill de industria; la columna central muestra la stat animada; la columna derecha contiene el blockquote, la atribución de autor y un enlace opcional al caso de éxito. Un tinte de fondo activado al hover y un borde de acento izquierdo en scaleY se superponen a la tarjeta sin desplazar su layout.
Cómo funciona
Cada StoryCard se recorta de derecha a izquierda con `clipPath: 'inset(0 100% 0 0)'` en reposo, y luego anima hacia `inset(0 0% 0 0)` cuando useInView se dispara. Las tarjetas se escalonan 120ms por índice. El count-up de la stat vive en un componente AnimatedStat separado: useInView dispara una llamada animate() de Framer Motion que incrementa un useMotionValue de 0 hasta el número objetivo; useTransform lo redondea a un entero o a un decimal. Los efectos hover usan variants de Framer Motion sobre la motion.div padre, el borde de acento controla scaleY desde su origen superior, el tinte aparece de forma independiente.
Cómo crearlo en React
Configurar el reveal al hacer scroll en cada tarjeta
Asigna un ref al wrapper de cada tarjeta y pásalo a useInView con once:true. Usa el booleano inView para alternar entre el estado recortado inicial y el estado totalmente revelado. Escalona el retardo multiplicando el índice de la tarjeta por 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 }, }} />Construir la stat count-up
Crea un componente AnimatedStat dedicado. Usa useMotionValue empezando en 0, y luego llama a animate() dentro de un useEffect cuando inView pase a true. useTransform gestiona el redondeo, Math.round para los enteros, toFixed(1) para los decimales. Guarda el controlador de animación para poder detenerlo en el 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]);Añadir el borde de acento al hover
Envuelve el contenido de la tarjeta en una motion.div con initial='rest' y whileHover='hover'. Define variants para el overlay de borde izquierdo: scaleY pasa de 0 a 1 con transformOrigin fijado en 'top'. Un segundo variant controla la opacidad del tinte de fondo de forma independiente con su propia duración de transición.
<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>Conectar la estructura de datos
Cada story necesita un id, un texto de logo, una etiqueta de industria, una cadena stat numérica (entero o decimal con un dígito), un sufijo opcional como '%' o 'M€', una etiqueta de stat, una cita, autor, rol y un href opcional. Mantén la stat como string para que AnimatedStat detecte si aplica 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; }
Cuándo usarlo
Coloca esta sección en la mitad o el final del funnel, después de las features o el pricing, donde los resultados concretos refuerzan la decisión. Funciona mejor con 3 a 5 stories; por debajo se ve pobre, por encima se rompe el ritmo del reveal. Evítala en páginas de marketing que ya tienen una cuadrícula de testimonios por encima de la línea de flotación: dos secciones de prueba social seguidas diluyen ambas. En móvil, la cuadrícula de tres columnas por tarjeta se convierte en una sola columna, lo que funciona bien pero pierde la comparación rápida entre logo, stat y cita.
Usado por
- Linear, Usa tarjetas de casos de clientes en disposición horizontal con una métrica destacada, una cita breve y atribución de autor en su sitio de marketing.
- Vercel, Las páginas de clientes combinan una stat de rendimiento clave, una cita de una frase y un logo de empresa en un formato de tarjeta fácil de escanear.
- Stripe, Entradas de casos de éxito apiladas con tags de industria, resultados numéricos y extractos en blockquote, la misma jerarquía de información que usa este componente.
- Notion, Las tarjetas de casos de clientes combinan una stat destacada con un breve testimonio y un tag de rol/empresa, revelados progresivamente a medida que el usuario hace scroll.
FAQ
¿Por qué clip-path en lugar de una animación translateX de slide-in?
El reveal con clip-path mantiene la tarjeta en su posición final en el layout desde el principio, así que no hay desplazamiento de la maquetación y los elementos vecinos nunca se mueven. Un slide-in con translateX requiere overflow:hidden en un wrapper o la tarjeta se desborda visualmente fuera de su celda de la cuadrícula.
¿Puedo usar un número real (no una string) para la stat?
AnimatedStat lee la prop stat como una string y le aplica parseFloat, así que tanto '340' como '2.4' funcionan. La comprobación isFloat busca un punto en la string original, por eso la prop debe seguir siendo una string en lugar de un número JavaScript, 2.4 y 2 perderían ambos el punto.
¿Cómo ajusto el retardo escalonado entre las tarjetas?
El retardo es `index * 0.12` segundos, así que la segunda tarjeta empieza 120ms después de la primera y la tercera 240ms después. Cambia el multiplicador a 0,08 para una cascada más ajustada o a 0,2 para un escalonado más pronunciado. Con más de 5 tarjetas, mantén el multiplicador bajo o las últimas tarjetas esperarán demasiado.
¿Qué ocurre con prefers-reduced-motion?
El componente no comprueba prefers-reduced-motion internamente por ahora. Para respetarlo, condiciona la prop animate: lee la media query con un hook custom y salta directamente al estado final si se solicita movimiento reducido. El count-up también se puede omitir animando con duration:0.