Retour au catalogue

Services Scroll Cards

Carousel de cards de services draggable horizontalement avec Framer Motion. Les cards hors viewport s'estompent en opacité et scale. Une barre de progression indique la position de défilement.

servicesmedium Both Responsive a11y
boldminimalcorporateagencysaasuniversalhorizontal-scrollcarousel
Theme

Cómo crear un carrusel horizontal de cards draggable en React

Un carrusel horizontal de cards draggable en React usa la API drag de Framer Motion con una motion value acotada. Cada card recibe la motion value x compartida y calcula su distancia respecto a los bordes del viewport para derivar opacity y scale mediante useTransform, creando un efecto de desvanecido en los bordes a medida que las cards salen de vista.

  • Stack: React 18 + Framer Motion 11 + Lucide React + Tailwind v4, ~270 líneas, sin dependencias adicionales.
  • APIs clave: useMotionValue, useTransform, drag, dragConstraints, dragElastic.
  • Barra de progreso sincronizada con la posición de scroll mediante una motion value scaleX derivada.
  • Totalmente responsive: las restricciones de drag se recalculan desde el ancho real del DOM al iniciar el drag.
  • Nativo táctil en móvil; no requiere puntero, funciona con un solo gesto táctil.

Services Scroll Cards es una sección con drag-to-scroll horizontal que presenta un conjunto de cards de servicios en una única fila desbordante. Las cards se desvanecen y encogen al llegar a los bordes del viewport, dando al usuario una pista visual de que hay más contenido fuera del área visible. Una barra de progreso minimalista bajo la pista refleja la posición de scroll en tiempo real.

Anatomía

La sección se divide en tres áreas: un bloque de header centrado (eyebrow, título H2, párrafo de subtítulo); una pista de drag a todo el ancho en overflow hidden que contiene una fila flex de cards; y una fila de indicador de progreso bajo la pista. Cada card es una columna de ancho fijo (300px) con una zona de icono en ratio 4:3 en la parte superior, un badge de tag opcional, un título en negrita y un párrafo de descripción en tono atenuado. Toda la fila flex está envuelta en un único motion.div que recibe el prop drag.

Cómo funciona

Una única motion value x compartida lo controla todo. El motion.div padre se configura con drag="x", dragConstraints apuntando al ref del contenedor y dragElastic={0.08} para un ligero rubber-band en los bordes. Cada ServiceCard recibe dragX (el mismo valor x) y su propio index. Dentro de cada card, dos llamadas useTransform leen el valor x actual de forma síncrona: calculan la posición proyectada de la card en pantalla (cardOffset + val + center) y miden su distancia a ambos bordes del viewport. Cuando esa distancia baja de -80px, la opacity disminuye de 1 a 0 en un rango de 120px, y el scale se encoge de 1 a un mínimo de 0.88 en un rango de 600px. El scaleX de progreso se deriva de la misma forma: un callback useTransform mapea x al rango 0..1 según la restricción maxLeft calculada.

Cómo crearlo en React

  1. Crea la motion value compartida y la pista de drag

    Declara un único useMotionValue(0) para x en el componente padre. Asocia un ref al div wrapper en overflow hidden, luego pasa ambos al motion.div con drag="x", dragConstraints={trackRef} y style={{ x }}. Fija dragElastic={0.08} para que la fila resista ligeramente el overscroll.

    const x = useMotionValue(0);
    const trackRef = useRef<HTMLDivElement>(null);
    
    <div ref={trackRef} className="w-full overflow-hidden">
      <motion.div drag="x" dragConstraints={trackRef} dragElastic={0.08} style={{ x, display: "flex", gap: 24 }}>
        {cards.map((card, i) => <ServiceCard key={card.id} dragX={x} index={i} totalCards={cards.length} />)}
      </motion.div>
    </div>
  2. Deriva la opacity y el scale de cada card desde la posición de drag

    Dentro de cada card, calcula las posiciones de pantalla izquierda y derecha proyectadas a partir de su index, CARD_WIDTH y CARD_GAP. Luego llama useTransform sobre dragX para mapear esa distancia al borde a opacity y scale. Las cards totalmente dentro del viewport se mantienen en opacity 1 y scale 1; las que cruzan un borde se desvanecen y encogen proporcionalmente.

    const opacity = useTransform(dragX, (val) => {
      const pos = index * (CARD_WIDTH + CARD_GAP) + val + center;
      const distFromEdge = Math.min(pos, viewWidth - pos - CARD_WIDTH);
      return distFromEdge > -80 ? 1 : Math.max(0, 1 + distFromEdge / 120);
    });
    
    const scale = useTransform(dragX, (val) => {
      const pos = index * (CARD_WIDTH + CARD_GAP) + val + center;
      const distFromEdge = Math.min(pos, viewWidth - pos - CARD_WIDTH);
      return distFromEdge > -80 ? 1 : Math.max(0.88, 1 + distFromEdge / 600);
    });
  3. Añade la barra de progreso

    Bajo la pista de drag, renderiza una línea horizontal de 1px y un div hijo con origin-left. Calcula scaleX mediante un callback useTransform que mapea el valor x actual al rango 0..1: divide el desplazamiento x negativo entre maxLeft (la distancia de drag máxima). Aplícalo al style del div hijo.

    const progressScaleX = useTransform(x, () => {
      const maxLeft = -(totalWidth - trackRef.current!.offsetWidth);
      if (maxLeft >= 0) return 1;
      return Math.max(0, Math.min(1, 1 - x.get() / maxLeft));
    });
    
    <motion.div className="h-full origin-left" style={{ scaleX: progressScaleX, backgroundColor: "var(--color-accent)" }} />
  4. Acota x al iniciar el drag para layouts responsive

    La restricción maxLeft depende del ancho realmente renderizado del contenedor, que cambia al redimensionar. Léela dentro de onDragStart desde el DOM real vía trackRef.current.offsetWidth. Si el valor x actual ya está fuera del rango válido, lo que puede ocurrir tras un redimensionamiento del viewport, acótalo de vuelta antes de que Framer Motion aplique sus propias restricciones.

    onDragStart={() => {
      const viewW = trackRef.current!.offsetWidth;
      const maxLeft = -(totalWidth - viewW);
      const cur = x.get();
      if (cur > 0) x.set(0);
      if (cur < maxLeft) x.set(maxLeft);
    }}

Cuándo usarlo

Recurre a este patrón cuando tengas cinco o más servicios, features u ofertas que no quepan de forma legible en una grid en los breakpoints habituales. Funciona bien en sitios de agencias, páginas de features SaaS y landings de producto donde cada servicio es suficientemente distinto como para merecer su propia card. Evítalo cuando los elementos son muy comparables y el usuario necesita revisarlos todos en paralelo, una grid estática sirve mejor para ese caso. En pantallas muy grandes donde caben todas las cards, la interacción de drag se vuelve inerte; en ese caso, considera ocultar la barra de progreso para no confundir la UI.

Usado por

  • Stripe, Filas de features de producto en drag-to-scroll horizontal en sus páginas de Payments y productos.
  • Shopify, Filas de cards desplazables para destacar features en su página de marketing de features.
  • Notion, Carruseles horizontales de features draggable en varias landings de producto y de casos de uso.
  • Webflow, Secciones de cards arrastrables al tacto que presentan las capacidades del producto en una fila desplazable.

FAQ

¿Funciona en pantallas táctiles y móvil?

Sí. La API drag de Framer Motion gestiona de forma nativa tanto los eventos de ratón como los táctiles, por lo que el mismo gesto de drag funciona en iOS y Android sin código adicional.

¿Por qué usar useTransform en lugar de CSS overflow:scroll?

CSS overflow:scroll no puede controlar la opacity y el scale por card según la posición sin JavaScript. useTransform lee la motion value de drag de forma síncrona en cada frame, permitiendo que cada card reaccione a su posición exacta en pantalla sin ningún recálculo de layout.

¿Cómo actualizo las restricciones de drag tras redimensionar la ventana?

El enfoque más limpio es recalcular maxLeft dentro de onDragStart leyendo trackRef.current.offsetWidth en ese momento, en lugar de guardarlo en state. También puedes añadir un ResizeObserver sobre el ref de la pista para volver a acotar x.set() dentro del rango cada vez que cambie el ancho del contenedor.

¿Puedo añadir un comportamiento de snap sobre las cards?

Framer Motion no tiene puntos de snap integrados para el drag, pero puedes implementarlo con onDragEnd: calcula el index de card más cercano a partir del valor x final y luego anima x hasta el offset de esa card con animate(x, targetOffset, { type: 'spring' }).

"use client";

import React, { useRef } from "react";
import { motion, useMotionValue, useTransform } from "framer-motion";
import * as LucideIcons from "lucide-react";

interface ServiceCard {
  id: string;
  icon: string;
  title: string;
  description: string;
  tag?: string;
}

interface ServicesScrollCardsProps {
  eyebrow?: string;
  title?: string;
  subtitle?: string;
  services: ServiceCard[];
}

const CARD_WIDTH = 300;

Code complet réservé à Pro

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

Passer en Pro, 9,99€/mois

Reseñas

Carrusel de cards draggable en React con Framer Motion,