Open source, MIT

scroll-carousel

A carousel that is a native horizontal scroller first. The layout is plain CSS with scroll snap, so the server markup is already the final layout. A small script adds arrows, dots, paging and an API; drag and autoplay are opt-in. No runtime dependencies.

pnpm add @nordwerk/scroll-carousel

Core 3.4 kB, drag +0.9 kB, autoplay +0.8 kB, stylesheet 1.4 kB, gzip and minified. React, Preact and Astro adapters included.

Every example below is live. Its readout shows the carousel's state; the change counter goes up by exactly one per settled move, however long the swipe. Switch JavaScript off in the bar to see what ships before any JavaScript runs. All products and names are made up.

Direction
Mouse drag
Rewind
JavaScript

Layout shift 0.000scrollend …

Hero with dots and autoplay

One slide per view. It moves on every 5 seconds and pauses while the pointer is over a slide or the hero is off-screen. Keyboard focus entering the hero, a swipe, drag, arrow or dot stops it; only the button next to the dots starts it again. With reduced motion it starts stopped. After the last slide the first fades in again.

Autumn roasts are in

Four single origins, roasted this week and shipped within two days.

Shop the roasts

Free shipping from €40

On every order to Germany, Austria and the Netherlands.

See delivery options

Hand grinders, 20% off

Every burr grinder in the range, until Sunday night.

Shop grinders

Pour-over class on Saturday

10:00 in our Hamburg store. Six places left, beans included.

Book a place

Gift cards from €15

Delivered by email within minutes, valid for three years.

Buy a gift card

index 0page 1 of 1isBeginning trueisEnd falsechange events 0

How it is built
<section class="sc hero" aria-roledescription="carousel" aria-label="Current offers">
  <button class="sc-play" data-sc-play>…</button>
  <div class="sc-dots" data-sc-dots></div>
  <div class="sc-track" data-sc-track tabindex="0" role="group" aria-label="Offer slides">
    <div role="group" aria-roledescription="slide" aria-label="1 of 5">…</div>
    …
  </div>
  <button class="sc-nav sc-prev" data-sc-prev aria-label="Previous offer">…</button>
  <button class="sc-nav sc-next" data-sc-next aria-label="Next offer">…</button>
  <p class="sc-status" data-sc-status aria-live="polite"></p>
</section>
.hero { --sc-gap: 0px; } /* one per view is the default */
import { attach } from '@nordwerk/scroll-carousel';
import '@nordwerk/scroll-carousel/carousel.css';
import { autoplay } from '@nordwerk/scroll-carousel/autoplay';

attach(hero, { rewind: true, plugins: [autoplay({ delay: 5000 })] });

Product row paged by group

Two, three or four products per view depending on the width of the row, set in container queries, and next or previous moves by the same number. Only page starts are snap points, so a swipe lands on a page too. Tab into the row to meet the skip link; arrow keys, Home and End work on the focused row. Adding a product at the front keeps the row where it is.

Bestsellers this week

View all 48
Skip the bestseller row

index 0page 1 of 1isBeginning trueisEnd falsechange events 0

How it is built
.bestsellers .sc-track { --sc-per-view: 2; --sc-group: 2; --sc-gap: 0.75rem; }
@container sc (min-width: 600px) { .bestsellers .sc-track { --sc-per-view: 3; --sc-group: 3; } }
@container sc (min-width: 900px) { .bestsellers .sc-track { --sc-per-view: 4; --sc-group: 4; } }
import { attach } from '@nordwerk/scroll-carousel';
import '@nordwerk/scroll-carousel/carousel.css';
import { drag } from '@nordwerk/scroll-carousel/drag';

const row = attach(bestsellers, { plugins: [drag()] });
row.slideTo(6);
bestsellers.addEventListener('sc:change', (event) => console.log(event.detail.page));
import { Carousel } from '@nordwerk/scroll-carousel/react';

<Carousel as="ul" label="Bestsellers" gap={12}
  perView={{ 0: 2, 600: 3, 900: 4 }} group={{ 0: 2, 600: 3, 900: 4 }}>
  {products.map((product) => <ProductCard key={product.id} {...product} />)}
</Carousel>

Free mode on a phone

A row at phone width with part of the third product showing and 16 px offsets at both ends. No arrows and no dots are rendered. Best judged on a real phone; with a mouse, drag the row.

Picked for you

After a swipe, the row

index 0page 1 of 1isBeginning trueisEnd falsechange events 0

How it is built
.picks .sc-track {
  --sc-per-view: 2.3;
  --sc-gap: 0.75rem;
  --sc-offset-before: 1rem;
  --sc-offset-after: 1rem;
  --sc-snap: mandatory; /* or proximity, or none */
}
@media (max-width: 767px) { .picks { --sc-controls: none; } }

Centred slides

Each guide comes to rest in the centre, the first and last included, with the neighbours showing on both sides. One dot per guide.

index 0page 1 of 1isBeginning trueisEnd falsechange events 0

How it is built
.guides .sc-track { --sc-per-view: 1.25; --sc-align: center; --sc-centered: 1; }
@container sc (min-width: 700px) { .guides .sc-track { --sc-per-view: 2.6; } }

Slides as wide as their content

Category links sized by their text. Proximity snap; the arrows move by one view of whole chips, and the edge fades only where there is more to scroll.

index 0page 1 of 1isBeginning trueisEnd falsechange events 0

How it is built
.chips .sc-track { --sc-slide-size: auto; --sc-gap: 0.5rem; --sc-snap: proximity; --sc-group: page; }
/* The script marks the ends, so styles can react: */
.chips[data-sc-overflow]:not([data-sc-end]) .sc-track { mask-image: linear-gradient(to right, #000 85%, transparent); }

Date strip that opens on today

Opens on today, the 21st of 41 days. The script loads as a module, after the first paint, yet nothing jumps: a small inline snippet sets the start position while the page is parsed. Pages are counted from today, so on a wide row each step is a week starting on Monday. Adding a week before the first day keeps today where it is.

Choose a pick-up day

Pick-up on Monday 21 September

index 0page 1 of 1isBeginning trueisEnd falsechange events 0

How it is built
<div class="sc dates">
  <ul class="sc-track" data-sc-track tabindex="0" aria-label="Pick-up days">
    <li>…</li>
    <li data-sc-initial>…</li>
  </ul>
</div>
<script>/* PRE_POSITION from '@nordwerk/scroll-carousel/markup', inline */</script>
.dates .sc-track { --sc-per-view: 7; --sc-group: page; --sc-gap: 0.375rem; }
@container sc (max-width: 400px) { .dates .sc-track { --sc-per-view: 5; } }

Too few slides to scroll

Two products in a row sized for three. No arrows, no dots, no dragging, and the row is no tab stop. Two ways to lay them out:

Recently viewed

Centred, slide size kept: .sc--center-few
Per view clamped to the slide count: --sc-count: 2

index 0page 1 of 1isBeginning trueisEnd falsechange events 0

How it is built
<div class="sc sc--center-few">…</div>
<!-- or: <ul class="sc-track" style="--sc-count: 2"> -->

Tailwind CSS, copy and paste

The product row with Tailwind CSS v4: layout values as arbitrary properties, container-query variants for the breakpoints, and your own arrow buttons styled from the carousel's state, group-data-[sc-overflow]/row:grid and aria-disabled:opacity-30. Import the stylesheet into the components layer, so utilities can override it. The preview is this exact code, compiled by Tailwind.

CSS
@import "tailwindcss";
/* In the components layer, so utilities on the markup can override the package's defaults. */
@import "@nordwerk/scroll-carousel/carousel.css" layer(components);
HTML
<div class="sc group/row relative [--sc-gap:1rem] [--sc-dot:var(--color-neutral-900)]">
  <ul data-sc-track tabindex="0" aria-label="New arrivals"
    class="sc-track [--sc-group:page] [--sc-per-view:1.3] @md:[--sc-per-view:2] @3xl:[--sc-per-view:3] @5xl:[--sc-per-view:4] pb-1 focus-visible:outline-2 focus-visible:outline-neutral-900">
    <li>
      <a href="#" class="block h-full rounded-2xl bg-white p-3 ring-1 ring-neutral-200 transition hover:ring-neutral-400">
        <div class="aspect-square rounded-xl bg-stone-200"></div>
        <p class="mt-3 text-sm text-neutral-500">Linnea</p>
        <p class="font-medium text-balance text-neutral-900">Linen overshirt</p>
        <p class="mt-1 font-semibold text-neutral-900">€89.00</p>
      </a>
    </li>
    <li>
      <a href="#" class="block h-full rounded-2xl bg-white p-3 ring-1 ring-neutral-200 transition hover:ring-neutral-400">
        <div class="aspect-square rounded-xl bg-sky-100"></div>
        <p class="mt-3 text-sm text-neutral-500">Arken</p>
        <p class="font-medium text-balance text-neutral-900">Merino crew neck, undyed</p>
        <p class="mt-1 font-semibold text-neutral-900">€119.00</p>
      </a>
    </li>
    <li>
      <a href="#" class="block h-full rounded-2xl bg-white p-3 ring-1 ring-neutral-200 transition hover:ring-neutral-400">
        <div class="aspect-square rounded-xl bg-rose-100"></div>
        <p class="mt-3 text-sm text-neutral-500">Linnea</p>
        <p class="font-medium text-balance text-neutral-900">Wide trousers in washed cotton</p>
        <p class="mt-1 font-semibold text-neutral-900">€79.00</p>
      </a>
    </li>
    <li>
      <a href="#" class="block h-full rounded-2xl bg-white p-3 ring-1 ring-neutral-200 transition hover:ring-neutral-400">
        <div class="aspect-square rounded-xl bg-amber-100"></div>
        <p class="mt-3 text-sm text-neutral-500">Holm & Co.</p>
        <p class="font-medium text-balance text-neutral-900">Canvas tote, 20 l</p>
        <p class="mt-1 font-semibold text-neutral-900">€45.00</p>
      </a>
    </li>
    <li>
      <a href="#" class="block h-full rounded-2xl bg-white p-3 ring-1 ring-neutral-200 transition hover:ring-neutral-400">
        <div class="aspect-square rounded-xl bg-emerald-100"></div>
        <p class="mt-3 text-sm text-neutral-500">Arken</p>
        <p class="font-medium text-balance text-neutral-900">Waxed field jacket</p>
        <p class="mt-1 font-semibold text-neutral-900">€229.00</p>
      </a>
    </li>
    <li>
      <a href="#" class="block h-full rounded-2xl bg-white p-3 ring-1 ring-neutral-200 transition hover:ring-neutral-400">
        <div class="aspect-square rounded-xl bg-violet-100"></div>
        <p class="mt-3 text-sm text-neutral-500">Holm & Co.</p>
        <p class="font-medium text-balance text-neutral-900">Leather card holder</p>
        <p class="mt-1 font-semibold text-neutral-900">€35.00</p>
      </a>
    </li>
    <li>
      <a href="#" class="block h-full rounded-2xl bg-white p-3 ring-1 ring-neutral-200 transition hover:ring-neutral-400">
        <div class="aspect-square rounded-xl bg-orange-100"></div>
        <p class="mt-3 text-sm text-neutral-500">Linnea</p>
        <p class="font-medium text-balance text-neutral-900">Organic cotton tee, set of two</p>
        <p class="mt-1 font-semibold text-neutral-900">€49.00</p>
      </a>
    </li>
    <li>
      <a href="#" class="block h-full rounded-2xl bg-white p-3 ring-1 ring-neutral-200 transition hover:ring-neutral-400">
        <div class="aspect-square rounded-xl bg-teal-100"></div>
        <p class="mt-3 text-sm text-neutral-500">Arken</p>
        <p class="font-medium text-balance text-neutral-900">Wool beanie</p>
        <p class="mt-1 font-semibold text-neutral-900">€29.00</p>
      </a>
    </li>
  </ul>
  <button type="button" data-sc-prev aria-label="Previous products"
    class="absolute start-3 top-[38%] z-10 hidden size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-neutral-900 shadow-lg ring-1 ring-neutral-200 transition group-data-[sc-overflow]/row:grid hover:bg-neutral-900 hover:text-white aria-disabled:pointer-events-none aria-disabled:opacity-30">
    <svg viewBox="0 0 24 24" class="size-5 rtl:-scale-x-100" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
  </button>
  <button type="button" data-sc-next aria-label="Next products"
    class="absolute end-3 top-[38%] z-10 hidden size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-neutral-900 shadow-lg ring-1 ring-neutral-200 transition group-data-[sc-overflow]/row:grid hover:bg-neutral-900 hover:text-white aria-disabled:pointer-events-none aria-disabled:opacity-30">
    <svg viewBox="0 0 24 24" class="size-5 rtl:-scale-x-100" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>
  </button>
  <div data-sc-dots class="sc-dots mt-4"></div>
  <p data-sc-status aria-live="polite" class="sr-only"></p>
</div>
JavaScript
import { attach } from '@nordwerk/scroll-carousel';
import { drag } from '@nordwerk/scroll-carousel/drag';

for (const root of document.querySelectorAll('.sc')) attach(root, { plugins: [drag()] });

shadcn/ui: a component and five blocks

The component has the structure of shadcn's Carousel, on native scrolling: ScrollCarousel, ScrollCarouselContent, ScrollCarouselItem, ScrollCarouselPrevious and ScrollCarouselNext, plus ScrollCarouselDots, ScrollCarouselPlay and useScrollCarousel(). It uses your theme and shadcn's Button, and installs straight from the GitHub repository. Below are the five blocks as shadcn add installs them, with shadcn's default theme; the Swiper comparison shows the same five as wireframes.

Add the component
npx shadcn@latest add studio-nordwerk/oss-ui/scroll-carousel
Or a block, which brings the component along
npx shadcn@latest add studio-nordwerk/oss-ui/product-row
npx shadcn@latest add studio-nordwerk/oss-ui/brand-teasers
npx shadcn@latest add studio-nordwerk/oss-ui/hero-autoplay
npx shadcn@latest add studio-nordwerk/oss-ui/image-gallery
npx shadcn@latest add studio-nordwerk/oss-ui/logo-belt
Use it
import {
  ScrollCarousel,
  ScrollCarouselContent,
  ScrollCarouselItem,
  ScrollCarouselPrevious,
  ScrollCarouselNext,
  ScrollCarouselDots,
} from "@/components/ui/scroll-carousel"

<ScrollCarousel aria-label="Bestsellers">
  <ScrollCarouselContent className="[--sc-group:page] [--sc-per-view:1.4] @md:[--sc-per-view:3] @4xl:[--sc-per-view:4]">
    {products.map((product) => (
      <ScrollCarouselItem key={product.id}>
        <ProductCard {...product} />
      </ScrollCarouselItem>
    ))}
  </ScrollCarouselContent>
  <ScrollCarouselPrevious />
  <ScrollCarouselNext />
  <ScrollCarouselDots />
</ScrollCarousel>

Coming from Swiper?

Five storefront carousels as wireframes, each next to the Swiper options it usually takes, and every option and API call side by side.

Compare with Swiper

scroll-carousel 0.1.5

MIT licence. Not included on purpose: vertical carousels, zoom, a draggable scrollbar, slide effects, virtual slides, synced thumbnails and a true infinite loop.