Harlow & Pine
€64.00
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.
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.
index 0page 1 of 1isBeginning trueisEnd falsechange events 0
<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 })] });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.
Harlow & Pine
€64.00
Ostmark
Hand grinder with conical steel burrs and 40 grind settings
Was €109.00now €89.00
Fjordware
€18.50
Kiln Coffee Co.
Espresso roast, whole beans, 1 kg
€27.90
Ostmark
Paper filters, size 02, 100 pieces
€5.90
Harlow & Pine
€32.00
Tarekit
Brewing scale with timer, 0.1 g steps
Was €49.00now €39.00
Fjordware
€24.00
Tarekit
€45.00
Kiln Coffee Co.
€9.40
Harlow & Pine
€29.00
Fjordware
€22.00
index 0page 1 of 1isBeginning trueisEnd falsechange events 0
.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>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.
Kiln Coffee Co.
Espresso roast, whole beans, 1 kg
€27.90
Ostmark
Paper filters, size 02, 100 pieces
€5.90
Harlow & Pine
€32.00
Tarekit
Brewing scale with timer, 0.1 g steps
Was €49.00now €39.00
Fjordware
€24.00
Tarekit
€45.00
Kiln Coffee Co.
€9.40
Harlow & Pine
€29.00
index 0page 1 of 1isBeginning trueisEnd falsechange events 0
.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; } }Each guide comes to rest in the centre, the first and last included, with the neighbours showing on both sides. One dot per guide.
Dial in a hand grinder
Start coarse, taste, then go finer in small steps. 6 min read.
Why water matters more than beans
Hardness, temperature and what a filter jug changes. 8 min read.
Pour-over in five steps
Bloom, pour, wait, pour again, and when to stop. 4 min read.
Storing beans: what actually helps
Air, light and heat, ranked by how much they matter. 5 min read.
Milk texture without a steam wand
A frother, a jar and the right temperature. 7 min read.
Cold brew that keeps for a week
Ratio, grind and a clean bottle. 5 min read.
Descaling: how often, with what
Citric acid, vinegar or a descaler, and why it matters. 3 min read.
index 0page 1 of 1isBeginning trueisEnd falsechange events 0
.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; } }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
.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); }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.
Pick-up on Monday 21 September
index 0page 1 of 1isBeginning trueisEnd falsechange events 0
<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; } }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:
Harlow & Pine
€64.00
Ostmark
Hand grinder with conical steel burrs and 40 grind settings
Was €109.00now €89.00
.sc--center-fewHarlow & Pine
€64.00
Ostmark
Hand grinder with conical steel burrs and 40 grind settings
Was €109.00now €89.00
--sc-count: 2index 0page 1 of 1isBeginning trueisEnd falsechange events 0
<div class="sc sc--center-few">…</div>
<!-- or: <ul class="sc-track" style="--sc-count: 2"> -->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.
@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);
<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>
import { attach } from '@nordwerk/scroll-carousel';
import { drag } from '@nordwerk/scroll-carousel/drag';
for (const root of document.querySelectorAll('.sc')) attach(root, { plugins: [drag()] });
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.
npx shadcn@latest add studio-nordwerk/oss-ui/scroll-carousel
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
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>
MIT licence. Not included on purpose: vertical carousels, zoom, a draggable scrollbar, slide effects, virtual slides, synced thumbnails and a true infinite loop.