Auto-Advance Filmstrip
Panels are a fixed number of container-width units wide and the whole track is one `translateX`. The active panel sits centred, its neighbours are dimmed and scaled down, and the strip steps every few seconds until you touch it. All motion is a CSS transition on a value React already recomputes when the index changes — there is no tween and no animation loop.
Auto-Advance Filmstrip
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/auto-advance-filmstrip.jsonNo runtime dependencies.
Also installs use-reduced-motion, use-visibility, wrap from this registry.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
4 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | One panel per child. |
| panelWidth | number | = 46 | Panel width in container-width units (cqw). |
| interval | number | = 3000 | Milliseconds a panel is held before the strip advances. |
| duration | number | = 500 | Slide transition duration in milliseconds. |
| autoPlay | boolean | = true | Whether the strip advances on its own. |
| inactiveOpacity | number | = 0.35 | Opacity applied to every panel that is not active. |
| inactiveScale | number | = 0.8 | Scale applied to every panel that is not active. |
| className | string | — | Merged onto the root, which is also the query container. |
| panelClassName | string | — | Merged onto each panel cell. |
| aria-label | string | = 'Filmstrip' | Label for the carousel group. |
How it works
The technique in order, not the code in order.
Every panel is
Wcontainer-width units wide (width: 46cqw) and the track is translated to(100 - W) / 2 - W * indexof the same unit. The first term centres one panel —(100 - 46) / 2 = 27— and the second steps exactly one panel per index. It is one expression with no measurement and no resize handler, because both terms are already relative to the container.cqwrather thanvw: the strip is sized against its own container, so it stays centred inside a card, a modal or a split layout instead of only when it happens to be full-bleed. The root setscontainer-type: inline-sizeto establish that container.The transition is
transform 500ms cubic-bezier(0.22, 1, 0.36, 1)on the track. For a component where React already re-renders on index change, a CSS transition is fewer moving parts than a tween and it interpolates on the compositor.Inactive panels get
opacity: 0.35**and**scale(0.8), transitioning on the same curve and duration as the track so the three properties land together.Auto-advance is
setTimeoutre-armed on every index change, notsetInterval. Manual navigation therefore restarts the dwell rather than inheriting whatever was left of the current tick.The timer is gated on four conditions —
autoPlay, not reduced-motion,document.visibilityState === "visible", and not currently hovered or focused within — so it never advances behind the user's back or in a background tab.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| panelWidth | = 46 | Panel width in cqw. Under 50 means two neighbours are partly visible, which is what makes it a filmstrip rather than a slideshow. At 46 the centred panel leaves 27 units of peek on each side — enough to read the neighbour as a real panel, not a sliver. |
| interval | = 3000 | Milliseconds per panel. Below ~2500 a caption cannot be read before it leaves; above ~5000 the strip looks broken rather than paced on a page where nothing else is moving. |
| duration | = 500 | Slide transition. Roughly a sixth of the dwell, which keeps the strip reading as a series of held frames with a transition between them rather than as continuous motion. |
| EASE | = cubic-bezier(0.22, 1, 0.36, 1) | Expo-out. It covers about 80% of the distance in the first 30% of the duration, so the strip appears to arrive early and settle — the same reason it is the default curve for interface movement that should feel decided rather than mechanical. |
| inactiveOpacity / inactiveScale | = 0.35 / 0.8 | Both, together. 0.35 alone reads as a loading state; 0.8 alone reads as a layout bug. Applied together they read as depth, which is the only interpretation a viewer will land on. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Auto-advance never starts, so nothing moves unless the user presses a control. When they do, transition is set to none on both the track and the panels, so the strip cuts to the new panel in one frame instead of sliding. The dot pager and arrows behave identically; only the interpolation is gone.
The pending setTimeout is cleared by the effect's own teardown, which also runs on every index change and whenever the strip pauses. useVisibility and useReducedMotion unsubscribe their listeners. There is nothing else to release: all motion is CSS on nodes React owns.
The strip steps one panel roughly every three seconds with the neighbours visible, dimmed and smaller on both sides. Hovering anywhere over it stops the advance and moving away resumes it with a full dwell. Switch tabs for ten seconds and come back: it is on the panel it was on, not ten further along. Resize the container from 320 px to full width and the active panel stays exactly centred.
Pitfalls
- Using a percentage in the track's
translateX. Percentages ontransformresolve against the *element's own* width, and the track is as wide as all its panels — sotranslateX(-46%)moves it by 46% of the whole strip, not of one panel. The arithmetic only works in a unit that refers to the container:cqwhere,vwif the strip is genuinely full-bleed. - Using
setIntervalfor the auto-advance. Tap "next" a hundred milliseconds before the tick fires and the strip advances twice; the second jump has no cause the user can see. AsetTimeoutkeyed on the index gives every panel a full dwell. - Leaving the timer running while the tab is hidden. The user comes back to a strip that has silently walked eleven panels forward, and every intervening transition ran on a throttled timer.
- Not pausing on hover or focus. A pointer resting on a panel and a keyboard user tabbing into a control both mean "I am reading this"; advancing underneath either one is the single most common complaint about auto-advancing carousels.
- Only setting
containerTypeon an ancestor that has no definite inline size.cqwthen resolves against the viewport-sized fallback and the arithmetic silently reverts to thevwbehaviour it was meant to replace. - Fading inactive panels to
opacity: 0. They are still in the layout and still hit-testable, so a click intended for the active panel can land on an invisible neighbour that happens to be under the cursor.
Notes
- Cheapest of the three carousels here and the right default when the set is short and each panel needs a moment on screen. Unlike
wrapping-drum-carouselit does not wrap continuously — it jumps from the last index back to the first, and that jump is a full-width slide, which is visible. Keep the set small enough that the loop point is rare. - Every panel stays mounted and in the layout at all times. For twenty panels that is twenty subtrees painting; virtualise or switch technique past roughly a dozen.