Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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 marqueeNavigable carousel
Where it livescomponents/organisms/Carousel.tsxcomponents/molecules/TemplateGallery.tsx
PurposeAmbient dashboard-preview band on the home pageThe product page's screenshot viewer
User controlNone by design — decorativeArrow buttons, keyboard, thumbnails
MotionContinuous, driven by CSS animationDiscrete, one transform per navigation
StateNone — no JS at all beyond mountingindex, useState — a Client Component
Loop mechanismContent array duplicated, animated -100%Not looped — bounded set, wraps via modulo
Bundle costOne 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

MistakeSymptomFix
Reaching for a slider library for a passive logo bandUnused drag/swipe/autoplay code shipped for a strip nobody interacts withA CSS animation and a duplicated row
No prefers-reduced-motion guard on continuous motionVestibular-sensitive visitors get an animation they cannot stopmotion-reduce:animate-none (or the JS equivalent)
Pausing only on :hoverTouch and keyboard users have no way to stop itPair hover-pause with a reduced-motion guard, not instead of one
Conditionally rendering only the active slideOther slides never reach the HTML — invisible to crawlersMount every slide, translate the container instead
next % count for a wrapping indexNegative index going left from slide 0Double modulo: ((next % count) + count) % count
One component doing both jobsAmbient band picks up focus stops it doesn't need, or the gallery picks up autoplay it shouldn't haveSplit by intent — passive vs navigable — rather than parameterizing one component into two shapes
Assuming a library is required at allExtra KB and API surface for something CSS already doesCheck 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.

Keep reading

Tutorial9 min read

React Dropdown: The Two Defects a useState Toggle Ships With

A production dropdown, defect by defect: why one `open` boolean breaks the click, and why the 8px gap has to be padding rather than margin.

Read more
Tutorial9 min read

React + EmailJS: What Ships in Your Bundle vs. a Server Action

EmailJS ships its service ID, template ID and public key in your bundle by design. This storefront's contact form ships none of that — the two files that decide it.

Read more
Tutorial10 min read

React Focus Trap: Two Dialogs Claimed aria-modal, One Meant It

An audit of four overlay surfaces: the wishlist panel that let Tab walk out of a modal dialog, the off-screen drawer pointer-events-none never hid, and the iframe case.

Read more