Cómo crear una galería filmstrip draggable en React
Una galería filmstrip React usa la prop drag de Framer Motion para permitir que el usuario agarre y desplace una banda horizontal de cards. Combina useMotionValue con useSpring para suavizar el drag y luego deriva un valor de progreso de 0 a 1 con useTransform para animar una barra de progreso bajo la banda.
- Stack: React 19 + Framer Motion 11 + Lucide React, ~137 líneas, cero dependencias adicionales.
- API principal de Framer Motion: useMotionValue, useSpring, useTransform, drag + dragConstraints + dragElastic.
- Las perforaciones de la película se renderizan en CSS puro con 60 divs rectangulares, sin SVG, sin imágenes.
- Cada card aparece con fundido y se agranda al entrar en el viewport con retardos escalonados (0,06s por card).
- Marcado accesible; el cursor grab indica la interactividad. No incluye un fallback de drag por teclado de serie.
Gallery Filmstrip muestra una banda horizontal y draggable de cards de medios estilizada como un carrete de película real, con borde, perforaciones y todo lo demás. La inercia del drag y una barra de progreso en directo hacen que la navegación se sienta física en lugar de digital. Encaja en portfolios, secciones de resumen de eventos y cualquier contexto donde quieras que el usuario se detenga en una secuencia de imágenes.
Anatomía
La sección tiene dos zonas principales. Arriba, una fila de header coloca el título de la sección a la izquierda y una pista "slide to explore" a la derecha, ambas animan desde y:20 en el primer scroll-into-view. Debajo, la filmstrip se sitúa en un wrapper overflow:hidden restringido con un cursor:grab. Dentro, una banda de fondo lleva filas de perforaciones arriba y abajo (60 pequeños rectángulos redondeados cada una, posicionados en absoluto a 6px del borde) además del motion.div draggable que contiene la fila de cards. Bajo la banda, una fina barra de progreso de dos píxeles se extiende de izquierda a derecha a medida que avanza el drag.
Cómo funciona
La mecánica de drag se basa en tres hooks de Framer Motion encadenados. Un useMotionValue en bruto `x` almacena el desplazamiento del drag. Alimenta un useSpring con stiffness 300 y damping 40 para producir `springX`, el spring absorbe los cambios bruscos de dirección y evita que la banda se sienta entrecortada. Después `springX` se pasa directamente a la motion.div como su estilo `x`. dragConstraints limita la banda entre 0 (inicio) y `-(total - 800)` (final), donde total vale `items.length * (cellWidth + gap)`. dragElastic en 0.08 ofrece un pequeño rebote de overscroll en ambos extremos. La barra de progreso lee un useTransform que mapea el mismo rango de `springX` a `[0, 1]` y luego pilota el `scaleX` de un div relleno mediante transformOrigin:'left'.
Cómo crearlo en React
Configurar los motion values
Crea un useMotionValue en bruto para la posición x, pásalo a un useSpring y deriva un valor de progreso de 0 a 1 con useTransform. Los parámetros del spring (stiffness 300, damping 40) dan una sensación firme pero fluida. Calcula el ancho total de la pista a partir del número de elementos para que las restricciones y el rango de progreso se mantengan sincronizados.
const x = useMotionValue(0); const springX = useSpring(x, { stiffness: 300, damping: 40 }); const total = items.length * (CELL_W + CELL_GAP); const progress = useTransform(springX, [0, -(total - 800)], [0, 1]);Construir el wrapper de la filmstrip
Envuelve la fila draggable en un div con overflow:hidden y cursor:grab. Aplica un color background-alt y bordes arriba/abajo para simular el cuerpo del carrete. Luego renderiza el componente Perforations en ambos lados, son simplemente 60 pequeños rectángulos redondeados espaciados con gap:20, posicionados en absoluto cerca de cada borde.
<div style={{ position: "relative", overflow: "hidden", cursor: "grab", background: "var(--color-background-alt)", borderTop: "2px solid var(--color-border)", borderBottom: "2px solid var(--color-border)" }}> <Perforations side="top" /> <Perforations side="bottom" /> {/* draggable row */} </div>Conectar la fila draggable
Pasa springX como estilo x de la motion.div, pon drag='x' y proporciona dragConstraints usando el ancho total que ya calculaste. Mantén dragElastic en 0.08 para un sutil rebote final. Cada card del interior usa whileInView con un retardo escalonado para que las cards aparezcan a medida que el usuario las arrastra a la vista.
<motion.div drag="x" dragConstraints={{ left: -(total - 800), right: 0 }} dragElastic={0.08} style={{ x: springX, display: "flex", gap: `${CELL_GAP}px` }} >Añadir la barra de progreso
Bajo la filmstrip, renderiza un div contenedor fino de dos píxeles con overflow:hidden. Dentro, coloca una motion.div cuyo scaleX esté pilotado por la motion value progress y cuyo transformOrigin sea 'left'. A medida que el usuario arrastra, la barra crece de izquierda a derecha en tiempo real.
<div style={{ height: 2, background: "var(--color-border)", overflow: "hidden" }}> <motion.div style={{ scaleX: progress, height: "100%", background: "var(--color-accent)", transformOrigin: "left" }} /> </div>
Cuándo usarlo
Gallery Filmstrip funciona bien como sección a media página o al final de la página en sitios de portfolio, páginas de fotografía de eventos o escaparates de agencias, en cualquier lugar donde una secuencia de 6 a 12 imágenes se beneficie de una metáfora de scroll táctil. La estética de carrete encaja con marcas editoriales, fotográficas y de eventos. Evítala en grids de e-commerce donde los usuarios necesitan comparar muchos artículos lado a lado a la vez; un grid masonry clásico o paginado servirá mejor ahí. En móvil, el drag sigue funcionando por touch, pero las cards anchas (320px cada una) requieren pruebas cuidadosas en pantallas pequeñas.
Usado por
- Awwwards, Las galerías 'site of the year' usan layouts filmstrip draggables horizontales para mostrar trabajos premiados.
- Pentagram, Las páginas de case study de proyectos recorren ejemplos de trabajo en una amplia filmstrip horizontal con navegación por drag.
- Pitch, La navegación por plantillas usa una banda horizontal draggable para que los usuarios ojeen muchas slides sin salir de la página.
FAQ
¿Por qué usar useSpring encima de useMotionValue en lugar de solo drag?
El drag de Framer Motion escribe directamente en la motion value en cada frame, así que añadir un useSpring aguas abajo suaviza la entrada rápida sin pelearse con el evento de drag. El resultado es una sensación elástica y con peso: la banda se retrasa ligeramente en los flicks rápidos y se asienta de forma natural en lugar de detenerse en seco.
¿Cómo adapto el ancho de las cards a distintos tamaños de pantalla?
La constante CELL_W está fijada en 320px. En móvil, bájala a 240px o 260px mediante un hook responsive (useWindowSize o un listener de media query) y recalcula `total` en consecuencia. El límite izquierdo de dragConstraints y el rango de useTransform dependen ambos de total, así que mantenlos derivados en lugar de fijados a mano.
¿Puedo reemplazar las cards placeholder por imágenes reales?
Sí. Cada card es un div con aspectRatio:'3/2' y overflow:hidden. Cambia el icono placeholder por un Next.js Image (o un img normal) con object-fit:cover. La prop color de cada GalleryItem pasa a ser el fondo de fallback mientras carga la imagen.
¿Las perforaciones suponen un riesgo real de accesibilidad?
No. Los 60 divs de perforación son puramente decorativos y no llevan ni texto ni roles. Los lectores de pantalla los omiten por completo. Lo único que conviene añadir es una región live oculta visualmente que anuncie la posición actual (por ejemplo, 'card 4 de 10') cuando el drag se asienta, para que los usuarios de teclado o de switch sepan dónde están.