Percent Slide Carousel
The simplest slide carousel that is still correct. A flex track holds one full-width slide per child and translates by `-100% * index`; because a percentage translate resolves against the translated element's own width, and the track is exactly one viewport-box wide, one step is exactly one slide. Nothing is measured, so nothing goes stale on resize.
Percent Slide Carousel
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/percent-slide-carousel.jsonNo runtime dependencies.
Also installs use-reduced-motion, damp, 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 slide per child. Each is wrapped in a full-width, non-shrinking cell. |
| duration | number | = 600 | Slide transition duration in milliseconds. |
| index | number | — | Controlled index. Omit to let the component hold its own state. |
| defaultIndex | number | = 0 | Starting index when uncontrolled. |
| onIndexChange | (index: number) => void | — | Fires with the resolved index after clamping or wrapping. |
| loop | boolean | = false | Wrap past the ends. Off by default because the wrap animates backwards through every slide. |
| className | string | — | Merged onto the viewport box. |
| slideClassName | string | — | Merged onto every slide cell. |
| aria-label | string | = 'Carousel' | Label for the carousel group. |
How it works
The technique in order, not the code in order.
The viewport box is
overflow: hidden. Inside it a flex track ish-full w-full, and every slide isw-full shrink-0— full width of the *track*, not of the flex line, socountslides overflow the track bycount - 1widths instead of being compressed into it.The track carries
transform: translateX(-${100 * index}%). A percentage intranslateXis relative to the border box of the element being transformed, which is the track, whose width equals the viewport box, which equals one slide. So-100%is one slide exactly, with no numbers taken from layout.That is the whole reason to prefer this over a pixel translate: a pixel offset needs a measured slide width, which needs a resize observer, which needs a re-measure pass that can land a frame late and show a seam. Here a resize changes the meaning of
100%on the next paint for free.The transition is
transform 600ms cubic-bezier(0.22, 1, 0.36, 1)on the track only. Interrupting it mid-flight is handled by the compositor: the browser retargets from the current computed transform, so rapid clicks chain smoothly rather than restarting from the last committed index.Navigation clamps rather than wraps by default.
-100% * indexis a linear function of the index with no modulo in it, so a wrap from the last slide to the first would translate the whole track back across every intermediate slide — a visible rewind.loopis offered but honest about that.Each slide is a
role="group"witharia-roledescription="slide"and anaria-labelofn of N, so a screen reader announces position without the component owning any live region.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| duration | = 600 | Milliseconds for a full slide. Under ~350 ms a full-width traversal reads as a cut rather than a move and the eye loses which direction it went; over ~900 ms a rapid click-through feels blocked because each step is still finishing when the next arrives. |
| EASE | = cubic-bezier(0.22, 1, 0.36, 1) | An expo-out curve: ~80% of the distance is covered in the first third of the duration. On a full-viewport traversal that front-loading is what keeps a 600 ms move from feeling slow, while the long flat tail hides the exact moment the slide lands. |
| translate step | = 100% | Locked to the slide width by construction, not by choice. Any other value desynchronises the track from the slides and leaves a permanent sliver of the neighbour visible at rest. |
| shrink-0 on slides | = flex-shrink: 0 | Without it flexbox honours the container width and squeezes every slide to 1/count of the track. The translate still moves a full track-width, so one click jumps count slides — the single most common way this pattern breaks. |
| loop | = false | Default off. With a linear percentage translate there is no cheap wrap: going from the last index to 0 animates back through every slide. If you need a true loop, reach for the drum instead. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The track transition is set to none, so the slide changes on the next paint with no traversal. Navigation, focus order and the announced position are untouched — only the movement between slides is removed.
Nothing to clean up. No listeners, no timers, no animation frames, no imperative writes to the DOM; the only state is the current index and the browser owns the transition.
Click next and exactly one slide traverses the box in 600 ms with no neighbour visible at rest. Hold the next button down and the slides chain without restarting from the previous index. Resize the container mid-transition and the resting position stays flush with the box edges. At the last slide the next button is disabled unless loop is set. With reduced motion the slide changes instantly and the buttons still work.
Pitfalls
- Omitting
flex-shrink: 0on the slides. Flexbox compresses them to fit the track, the-100%step no longer equals one slide, and the carousel advances by the whole set on the first click. - Putting the transition on the slides instead of the track. Only the track moves; a transition on the children costs a style recalculation per slide per frame and animates nothing.
- Translating in pixels from a measured slide width. It works until the container resizes between the measure and the paint, at which point the track is offset by the difference and every slide sits a few pixels off-centre until the next navigation.
- Wrapping the index with a modulo and expecting a loop.
-100% * 0after-100% * (n - 1)animates the whole track backwards past every slide; users read it as the carousel malfunctioning, not as a wrap. - Marking off-screen slides
aria-hiddenwhile they still contain focusable children. Tab lands inside a hidden subtree, which is an accessibility violation and, in practice, scrolls the overflow container sideways and desynchronises it from the transform. - Forgetting
overflow: hiddenon the viewport box. The overflowing slides remain visible, and inside a page-level container they widen the document and add a horizontal scrollbar. - Killing the transition under reduced motion but also killing the index change. The slide must still change — reduced motion removes the traversal, not the navigation.
Notes
- This is the flat, non-3D member of the carousel trio.
wrapping-drum-carouselfakes depth with scale andautoAlphaover agsap.utils.wrapring and loops infinitely;perspective-coverflow-scrolleruses realperspective()/rotateY()/translateZ()driven by a native scroll container. This one has no depth, no wrap and no JavaScript in the motion path at all — reach for it when the content is the point and the carousel is not. - Because the motion is a single compositor-friendly transform on one element, it holds 60 fps on hardware where the other two do not. If you are shipping a hero on a low-end phone, this is the one that survives.
- Controlled and uncontrolled: pass
indexto drive it from outside, omit it and the component keeps its own state.onIndexChangefires in both cases.