Retour au catalogue

Gallery Filmstrip

Bande filmstrip horizontale draggable avec perforations de film en CSS. Aspect ratio fixe, barre de progression et drag elastique.

gallerycomplex Both Responsive a11y
playfuleditorialagencyportfolioeventcarousel
Theme

Créer une galerie filmstrip draggable en React

Une galerie filmstrip React utilise la prop drag de Framer Motion pour laisser l'utilisateur attraper et faire défiler une bande horizontale de cartes. On associe useMotionValue et useSpring pour adoucir le drag, puis on dérive une valeur de progression 0-1 avec useTransform pour animer une barre de progression sous la bande.

  • Stack : React 19 + Framer Motion 11 + Lucide React, ~137 lignes, zéro dépendance supplémentaire.
  • API Framer Motion clé : useMotionValue, useSpring, useTransform, drag + dragConstraints + dragElastic.
  • Les perforations de film sont rendu en CSS pur avec 60 divs rectangulaires, pas de SVG, pas d'images.
  • Chaque carte apparaît en fondu et se redimensionne à l'entrée dans le viewport avec un délai échelonné (0.06s par carte).
  • Balisage accessible ; le curseur grab signale l'interactivité. Pas de fallback drag au clavier inclus par défaut.

Gallery Filmstrip affiche une bande de cartes médias horizontale et draggable stylisée comme une vraie pellicule, bords, perforations et tout le reste. L'inertie du drag et une barre de progression en direct rendent la navigation physique plutôt que numérique. Elle s'intègre dans les portfolios, les sections de récap d'événements et partout où on veut que l'utilisateur s'attarde sur une séquence d'images.

Anatomie

La section comporte deux zones principales. En haut, un header positionne le titre à gauche et un hint 'glissez pour explorer' à droite, les deux s'animent depuis y:20 au premier scroll-into-view. En dessous, la filmstrip est dans un wrapper overflow:hidden avec un curseur grab. À l'intérieur, une bande de fond porte des rangées de perforations en haut et en bas (60 petits rectangles arrondis chacune, positionnés en absolu à 6px du bord) ainsi que la motion.div draggable qui contient la rangée de cartes. Sous la bande, une fine barre de progression de deux pixels s'étend de gauche à droite au fur et à mesure du drag.

Comment ça marche

La mécanique de drag repose sur trois hooks Framer Motion enchaînés. Un useMotionValue brut `x` stocke le décalage du drag. Il alimente un useSpring avec stiffness 300 et damping 40 pour produire `springX`, le spring absorbe les changements de direction brusques et évite les saccades. `springX` est ensuite passé directement à la motion.div comme style `x`. dragConstraints limite la bande entre 0 (début) et `-(total - 800)` (fin), où total vaut `items.length * (largeur + gap)`. dragElastic à 0.08 offre un léger rebond en bout de course. La barre de progression lit un useTransform qui mappe la même plage `springX` vers `[0, 1]`, puis pilote le `scaleX` d'un div rempli via transformOrigin:'left'.

Comment le coder en React

  1. Configurer les motion values

    Crée un useMotionValue brut pour la position x, passe-le dans un useSpring, puis dérive une valeur 0-1 de progression avec useTransform. Les paramètres du spring (stiffness 300, damping 40) donnent un ressenti ferme mais fluide. Calcule la largeur totale de la piste à partir du nombre d'éléments pour que les contraintes et la plage de progression restent synchronisées.

    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]);
  2. Construire le wrapper filmstrip

    Enveloppe la rangée draggable dans un div overflow:hidden et cursor:grab. Applique une couleur background-alt et des bordures haut/bas pour simuler le corps de la pellicule. Rends ensuite le composant Perforations de chaque côté, ce sont simplement 60 petits rectangles arrondis espacés avec gap:20, positionnés en absolu près de chaque bord.

    <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>
  3. Câbler la rangée draggable

    Passe springX comme style x de la motion.div, mets drag='x', et fournis dragConstraints en utilisant la largeur totale déjà calculée. Garde dragElastic à 0.08 pour un léger rebond en fin de course. Chaque carte à l'intérieur utilise whileInView avec un délai échelonné pour que les cartes apparaissent au fur et à mesure du drag.

    <motion.div
      drag="x"
      dragConstraints={{ left: -(total - 800), right: 0 }}
      dragElastic={0.08}
      style={{ x: springX, display: "flex", gap: `${CELL_GAP}px` }}
    >
  4. Ajouter la barre de progression

    Sous la filmstrip, rends un div conteneur de deux pixels avec overflow:hidden. À l'intérieur, place une motion.div dont le scaleX est piloté par la motion value progress et dont le transformOrigin est 'left'. Au fil du drag, la barre grandit de gauche à droite en temps réel.

    <div style={{ height: 2, background: "var(--color-border)", overflow: "hidden" }}>
      <motion.div
        style={{ scaleX: progress, height: "100%",
          background: "var(--color-accent)", transformOrigin: "left" }}
      />
    </div>

Quand l'utiliser

Gallery Filmstrip convient comme section en milieu ou fin de page sur des sites de portfolio, des pages de photographie d'événements ou des vitrines d'agences, partout où une séquence de 6 à 12 images bénéficie d'une métaphore de défilement tactile. L'esthétique pellicule se prête aux marques éditoriales, photographiques et événementielles. À éviter sur les grilles e-commerce où les utilisateurs doivent comparer de nombreux articles côte à côte ; une grille maçonnée classique ou paginée sera plus adaptée. Sur mobile, le drag fonctionne par touch, mais les cartes larges (320px chacune) nécessitent des tests soigneux sur petits écrans.

Utilisé par

  • Awwwards, Les galeries 'site of the year' utilisent des layouts filmstrip draggables horizontaux pour mettre en avant les réalisations primées.
  • Pentagram, Les pages de case study projets font défiler les exemples de travaux dans un large filmstrip horizontal avec navigation au drag.
  • Pitch, La navigation dans les templates utilise une bande horizontale draggable pour parcourir de nombreuses slides sans quitter la page.

FAQ

Pourquoi utiliser useSpring par-dessus useMotionValue plutôt que simplement drag ?

Le drag de Framer Motion écrit directement dans la motion value à chaque frame, donc ajouter un useSpring en aval adoucit les entrées rapides sans contrecarrer l'événement drag. Résultat : un ressenti élastique et pesant, la bande décroche légèrement sur les flicks rapides et s'immobilise naturellement au lieu de s'arrêter brusquement.

Comment adapter la largeur des cartes à différentes tailles d'écran ?

La constante CELL_W est fixée à 320px. Sur mobile, baissez-la à 240px ou 260px via un hook responsive (useWindowSize ou un écouteur de media query) et recalculez `total` en conséquence. La borne gauche de dragConstraints et la plage de useTransform dépendent toutes deux de total, gardez-les donc dérivées plutôt que codées en dur.

Peut-on remplacer les cartes placeholder par de vraies images ?

Oui. Chaque carte est un div avec aspectRatio:'3/2' et overflow:hidden. Remplacez l'icône placeholder par un composant Next.js Image (ou un img classique) avec object-fit:cover. La prop color de chaque GalleryItem devient le fond de fallback pendant le chargement de l'image.

Les perforations posent-elles un vrai risque d'accessibilité ?

Non. Les 60 divs de perforation sont purement décoratives et ne portent ni texte ni rôles. Les lecteurs d'écran les ignorent entièrement. Ce qu'il faut ajouter, c'est une région live visuellement cachée qui annonce la position courante (ex. 'carte 4 sur 10') quand le drag s'immobilise, pour que les utilisateurs au clavier ou aux switchs sachent où ils en sont.

"use client";

import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { Image } from "lucide-react";
import { useRef } from "react";

interface GalleryItem {
  label: string;
  color: string;
}

interface GalleryFilmstripProps {
  title?: string;
  subtitle?: string;
  items?: GalleryItem[];
}

const EASE = [0.16, 1, 0.3, 1] as const;
const CELL_W = 320;
const CELL_GAP = 16;

function Perforations({ side }: { side: "top" | "bottom" }) {

Code complet réservé à Pro

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

Passer en Pro, 9,99€/mois

Avis

Galerie filmstrip draggable en React, Tutoriel Framer Motion