React Carousel: Two Patterns, Zero Dependencies, One Bug We Fixed
A passive CSS marquee and a navigable translated-track gallery, both dependency-free — plus the missing prefers-reduced-motion guard this audit found and fixed.
A carousel is two different problems wearing one name: a passive strip of images that loops for atmosphere, and a navigable set of slides a user is meant to page through on purpose. Reach for a library — Embla, Swiper, Keen Slider — when you need drag physics, autoplay with a pause control, or virtualization. Neither of ours needs any of that, and this codebase ships both patterns with zero carousel dependencies in package.json.
Here they are, what each is for, and a real accessibility gap the audit for this post found and fixed along the way.
Two carousels, two different jobs
| Passive marquee | Navigable carousel | |
|---|---|---|
| Where it lives | components/organisms/Carousel.tsx | components/molecules/TemplateGallery.tsx |
| Purpose | Ambient dashboard-preview band on the home page | The product page's screenshot viewer |
| User control | None by design — decorative | Arrow buttons, keyboard, thumbnails |
| Motion | Continuous, driven by CSS animation | Discrete, one transform per navigation |
| State | None — no JS at all beyond mounting | index, useState — a Client Component |
| Loop mechanism | Content array duplicated, animated -100% | Not looped — bounded set, wraps via modulo |
| Bundle cost | One keyframe in globals.css | ~120 lines of component code, no dependency either way |
| Right for | "Always moving, never the point" | "The user is choosing what to look at" |
Neither is the library-backed component most "react carousel" guides assume you're building toward. Both are a deliberate bet that the two shapes this site actually needs don't require one.
The passive one: pure CSS, no state
The home page's dashboard-preview band never stops, never waits for input, and has nothing to be accessible to beyond "let me ignore it" — so it carries no component state at all:
// src/components/organisms/Carousel.tsx
export default function Carousel() {
const row = [...carouselShots, ...carouselShots]; // duplicated for a seamless loop
return (
<div className="group relative flex w-full overflow-hidden py-8">
<MarqueeRow shots={row} />
<MarqueeRow shots={row} ariaHidden />
</div>
);
}
// src/components/molecules/MarqueeRow.tsx
<div
aria-hidden={ariaHidden || undefined}
className="flex shrink-0 animate-[marquee_40s_linear_infinite] items-center gap-6 pr-6
group-hover:[animation-play-state:paused] motion-reduce:animate-none"
>
{shots.map((src, i) => <img key={i} src={src} alt={ariaHidden ? "" : "ASoc dashboard preview"} />)}
</div>
/* src/app/globals.css */
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
The trick that makes it loop without a JavaScript reset: duplicate the content, animate one full row's width, and mount a second identical row behind it. When the first row has translated exactly -100%, the second row is sitting exactly where the first one started, so the loop point is invisible — no jump, no listener watching for the animation to end. group-hover:[animation-play-state:paused] stops it on hover so a curious visitor can actually read a logo. And the second <MarqueeRow> is aria-hidden — it exists only to fill the gap while the first row scrolls out, so a screen reader should never announce it as a duplicate list.
The duplication has a real cost worth naming rather than hiding: eight images become sixteen <img> tags in the DOM, and the second copy is pure repetition. That's the trade a marquee makes on purpose — every non-JS implementation of an infinite scroll needs some second copy of the content to paper over the seam, whether that's a duplicated row (this approach), a requestAnimationFrame loop that resets position invisibly, or a library doing the same duplication internally with more code. Marking the second row loading="lazy" and aria-hidden keeps the visible cost to layout only, not to the accessibility tree or (much) to network priority.
The gap this audit found
What the row didn't have, until writing this post: any response to the OS-level "reduce motion" setting. A 40-second looping animation that starts on page load and never stops is exactly the shape WCAG 2.2.2 (Pause, Stop, Hide) is written for — auto-updating content, running longer than five seconds, presented alongside other content someone might be trying to read. The existing group-hover pause helps a mouse user; it does nothing for someone who has set prefers-reduced-motion: reduce at the OS level and never touches this element with a pointer at all. TemplateGallery, two files over, already had the parallel guard on its slide transition (motion-reduce:transition-none) — the marquee was the one place it had been missed. The fix is one utility class:
- className="flex shrink-0 animate-[marquee_40s_linear_infinite] items-center gap-6 pr-6 group-hover:[animation-play-state:paused]"
+ className="flex shrink-0 animate-[marquee_40s_linear_infinite] items-center gap-6 pr-6 group-hover:[animation-play-state:paused] motion-reduce:animate-none"
motion-reduce:animate-none is Tailwind's @media (prefers-reduced-motion: reduce) variant applied to the animation itself, so the row simply renders at rest for anyone whose OS says so — no JavaScript media-query check required.
The navigable one: mount everything, translate a track
TemplateGallery is a genuinely different component, because a product screenshot carousel has a job the marquee doesn't: every slide has to be reachable, on purpose, by keyboard or click, and every slide has to exist in the HTML for a crawler even though only one is visible.
// src/components/molecules/TemplateGallery.tsx — the core structure
const go = useCallback(
(next: number) => setIndex(((next % count) + count) % count), // wraps both directions
[count],
);
<div
className="flex transition-transform duration-500 ease-out motion-reduce:transition-none"
style={{ transform: `translateX(-${index * 100}%)` }}
>
{images.map((src, i) => <div className="w-full shrink-0" key={src}>{/* slide */}</div>)}
</div>
Every slide stays mounted; only the track's transform changes. That single decision is why the gallery needs no network request between slides, why a crawler sees every screenshot in the raw HTML, and why motion-reduce:transition-none is enough there — there's no continuous animation to stop, only a transition to skip. The full accessibility treatment — aria-roledescription, inert on off-screen slides, thumbnails versus dots, the arrow-key scoping — is deep enough to be its own post; the relevant point here is just that it exists because the content is different, not because a marquee wasn't good enough.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
| Reaching for a slider library for a passive logo band | Unused drag/swipe/autoplay code shipped for a strip nobody interacts with | A CSS animation and a duplicated row |
No prefers-reduced-motion guard on continuous motion | Vestibular-sensitive visitors get an animation they cannot stop | motion-reduce:animate-none (or the JS equivalent) |
Pausing only on :hover | Touch and keyboard users have no way to stop it | Pair hover-pause with a reduced-motion guard, not instead of one |
| Conditionally rendering only the active slide | Other slides never reach the HTML — invisible to crawlers | Mount every slide, translate the container instead |
next % count for a wrapping index | Negative index going left from slide 0 | Double modulo: ((next % count) + count) % count |
| One component doing both jobs | Ambient band picks up focus stops it doesn't need, or the gallery picks up autoplay it shouldn't have | Split by intent — passive vs navigable — rather than parameterizing one component into two shapes |
| Assuming a library is required at all | Extra KB and API surface for something CSS already does | Check whether the carousel needs drag, autoplay-with-pause, or virtualization before reaching for one |
Frequently asked questions
When should I actually reach for Embla, Swiper, or Keen Slider? When you need touch-drag physics, autoplay with a compliant pause control, or virtualizing a carousel with far more slides than fit in memory comfortably. A four-to-eight-slide product gallery or a decorative logo band needs none of those — see the product gallery post for the fuller case against adding one there.
Is a CSS-only marquee accessible?
It can be, with two things most examples skip: a prefers-reduced-motion guard (this post's fix) and treating the duplicated content as aria-hidden so assistive tech doesn't announce the same list twice. Without either, "no JavaScript" isn't the same as "no accessibility work."
Why does the gallery use React state and the marquee doesn't? Because the gallery has a concept of "current slide" a user changes on purpose, and the marquee doesn't — nothing about the marquee's position is meaningful to track. State exists to answer "where am I," and the marquee has no such question.
Can the two patterns share a component? Not usefully. A parameterized "carousel" component trying to be both ambient and navigable ends up with props for autoplay, props for controls, props for looping — the complexity a dedicated library would have anyway, without the library's testing. Two small, honest components beat one configurable one here.
Templates with the component library this pattern lives in
ASoc Pulse is a commerce-ops admin dashboard whose UI kit includes the charts and widget shells a marquee-style preview strip slots into. ASoc Estate is a real-estate admin with a property grid and detail views — the same mount-and-translate pattern TemplateGallery uses applies directly to a listing's photo set. ASoc Crest ships a full component library including tables and charts pages, the natural home for either carousel shape in a larger admin build.
Browse the full set of React admin templates, Next.js admin templates, or Tailwind admin templates. For the deeper accessibility pass on the navigable pattern — ARIA roles, inert, keyboard scoping — see building a product image gallery in Next.js.
