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

How to build a draggable filmstrip gallery in React

A React filmstrip gallery uses Framer Motion's drag prop to let users grab and scroll a horizontal strip of cards. Pair useMotionValue with useSpring to smooth out the drag, then derive a 0-to-1 progress value with useTransform to drive a progress bar beneath the strip.

  • Stack: React 19 + Framer Motion 11 + Lucide React, ~137 lines, zero extra dependencies.
  • Core Framer Motion API: useMotionValue, useSpring, useTransform, drag + dragConstraints + dragElastic.
  • Film perforations rendered as pure CSS with 60 rectangular divs, no SVG, no images.
  • Each card fades in and scales up on scroll-into-view with staggered delays (0.06s per card).
  • Accessible markup; the grab cursor signals interactivity. No keyboard drag fallback included out of the box.

Gallery Filmstrip renders a horizontal draggable strip of media cards styled to look like a physical film reel, border, perforations and all. Drag inertia and a live progress bar make navigation feel physical rather than digital. It fits portfolios, event recap sections and any context where you want users to linger on a sequence of images.

Anatomy

The section has two main regions. Up top, a header row holds the section title on the left and a "slide to explore" hint on the right, both animate in from y:20 on first scroll-into-view. Below that, the filmstrip sits in a constrained overflow:hidden wrapper with a cursor:grab feel. Inside, a background strip carries top and bottom Perforation rows (60 small rounded rectangles each, absolutely positioned 6px from the edge) plus the draggable motion.div that holds the card row. Under the strip, a thin two-pixel progress bar stretches from left to right as the drag advances.

How it works

The drag mechanic centers on three Framer Motion hooks chained together. A raw useMotionValue `x` stores the drag offset. It feeds into a useSpring with stiffness 300 and damping 40 to produce `springX`, the spring buffers sudden direction changes and keeps the strip from feeling jerky. `springX` is then passed directly to the motion.div as its `x` style. dragConstraints clamps the strip between 0 (start) and `-(total - 800)` (end), where total is `items.length * (cellWidth + gap)`. dragElastic at 0.08 provides a small overscroll bounce at both ends. The progress bar reads a useTransform that maps the same `springX` range to `[0, 1]`, then drives the `scaleX` of a filled div via transformOrigin:'left'.

How to build it in React

  1. Set up the motion values

    Create a raw useMotionValue for the x position, feed it into a useSpring, and derive a 0-1 progress value with useTransform. The spring parameters (stiffness 300, damping 40) give a firm but smooth feel. Compute the total track width from your item count so the constraints and progress range stay in sync.

    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. Build the filmstrip wrapper

    Wrap the draggable row in a div with overflow:hidden and cursor:grab. Apply a background-alt color and top/bottom borders to simulate the film reel body. Then render the Perforations component on both sides, it's just 60 small rounded rectangles spaced with gap:20, positioned absolutely near each edge.

    <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. Wire up the draggable row

    Pass springX as the x style of the motion.div, set drag='x', and give it dragConstraints using the total width you already computed. Keep dragElastic at 0.08 for a subtle end bounce. Each card inside uses whileInView with a staggered delay so cards pop in as the user drags into view.

    <motion.div
      drag="x"
      dragConstraints={{ left: -(total - 800), right: 0 }}
      dragElastic={0.08}
      style={{ x: springX, display: "flex", gap: `${CELL_GAP}px` }}
    >
  4. Add the progress bar

    Below the filmstrip, render a thin two-pixel container div with overflow:hidden. Inside it, put a motion.div whose scaleX is driven by the progress motion value and whose transformOrigin is 'left'. As the user drags, the bar grows from left to right in real time.

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

When to use it

Gallery Filmstrip works well as a mid-page or late-page section on portfolio sites, event photography pages, or agency showcases, anywhere a sequence of 6 to 12 images benefits from a tactile scroll metaphor. The film reel aesthetic lends itself to editorial, photography and event brands. Avoid it on e-commerce grids where users need to compare many items side-by-side at once; a traditional masonry or paginated grid will serve better there. On mobile, the drag still works via touch, but the wide cards (320px each) require careful testing on small screens.

Used by

  • Awwwards, Site-of-the-year galleries use horizontal draggable filmstrip layouts to showcase award-winning work.
  • Pentagram, Project case study pages scroll through work samples in a wide horizontal filmstrip with drag navigation.
  • Pitch, Template browsing uses a horizontal draggable strip so users can skim many slides without leaving the page.

FAQ

Why use useSpring on top of useMotionValue instead of just drag?

Framer Motion's drag writes directly to the motion value on every frame, so adding a useSpring downstream smooths out the rapid input without fighting the drag event. The result is an elastic, weighted feel: the strip lags slightly on fast flicks and settles naturally instead of stopping hard.

How do I adapt the card width for different screen sizes?

The CELL_W constant is set to 320px. On mobile, drop it to 240px or 260px via a responsive hook (useWindowSize or a media query listener) and recompute `total` accordingly. The dragConstraints left bound and the useTransform range both depend on total, so keep them derived rather than hardcoded.

Can I replace the placeholder cards with real images?

Yes. Each card is a div with aspectRatio:'3/2' and overflow:hidden. Swap the placeholder icon for a Next.js Image (or a plain img) with object-fit:cover. The color prop on each GalleryItem becomes the background fallback while the image loads.

Do the perforations add real accessibility risk?

No. The 60 perforation divs are purely decorative and carry no text or roles. Screen readers skip them entirely. The one thing to add is a visually-hidden live region that announces the current position (e.g. 'card 4 of 10') when the drag settles, so keyboard or switch users know where they are.

"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

Reviews

React Draggable Filmstrip Gallery, Framer Motion Tutorial