6.0 KiB
Carousel — design
Status: approved design (2026-06-30). Package name provisional (
carousel) — rename before publish.
A tiny, framework-agnostic carousel. Native scroll does the heavy lifting; CSS does the motion; JavaScript is a thin, opt-in fallback. Size is the priority.
Goals
- Smallest realistic footprint. Native platform features before code.
- Responsive and touch-first via native scroll — no hand-rolled drag/momentum.
- Modern CSS (scroll-snap, scroll-driven animations, native scroll markers/buttons).
- Controls as opt-in modules the developer wires to their own markup.
- Evergreen browsers with graceful degradation. No legacy polyfills.
Non-goals (deferred — add when asked)
- Infinite/looping mode (needs slide cloning; fights scroll-snap).
- Fade/swap (transform-driven) variant.
- Modules generating their own DOM or shipping CSS.
Browser target
Evergreen + graceful degrade. Baseline as of mid-2026:
| Feature | Chrome | Safari | Firefox | Use |
|---|---|---|---|---|
scroll-snap, scrollbar hiding |
✅ years | ✅ years | ✅ years | Core path — universal |
scroll-snap-stop: always |
✅ | ✅ | ✅ | One-swipe-one-slide |
CSS scroll-driven animations (animation-timeline: view()) |
✅ | ⚠️ partial | ✅ | Opt-in per-slide effects; degrades to plain snap |
::scroll-marker / ::scroll-button |
✅ 135+ | ✅ 18.2+ | ⚠️ partial | CSS-first controls; JS dots() is the FF/legacy fallback |
scrollsnapchange event |
✅ | ✅ | ❌ (mid-2026) | Deferred index-tracking upgrade; IntersectionObserver used today |
scroll-state(snapped:) container queries |
✅ | ❌ | ❌ | Skipped — not Baseline |
Architecture
Three independently tree-shakeable layers:
- Core —
createCarousel(track, opts?)wraps one scroll container, returns an instance. Zero deps. - Sugar modules (opt-in imports) —
dots(),autoplay(). Wire the developer's existing markup to the instance. Create no DOM, ship no CSS. - CSS — a stylesheet the developer imports/copies: snap track + optional scroll-driven effect keyframes. The motion lives here, not in JS.
CSS-first controls. Native ::scroll-marker (dots) and ::scroll-button (arrows)
are the default in Chrome 135+ / Safari 18.2+ — zero JS. The dots() JS module is a
progressive fallback that engages only where native markers are unsupported
(Firefox, older browsers). Arrows need no module at all — the developer calls
c.next() / c.prev() from their own click handlers.
Core instance API
type Carousel = {
next(): void;
prev(): void;
scrollToIndex(i: number): void;
readonly index: number; // current snapped slide (leftmost)
readonly count: number; // slide count
on(evt: 'change', cb: (index: number) => void): () => void; // returns unsubscribe
destroy(): void;
};
- Index tracking =
IntersectionObserveron slides. Accurate, fires on swipe too — no scroll-position math. Single code path across all browsers.- Deferred upgrade: swap to
scrollsnapchange(snapped element handed to you directly) once Firefox ships it; drop the observer then.
- Deferred upgrade: swap to
next/prev/scrollToIndex=el.scrollTo()(orscrollIntoView) to the target child's offset. Smooth scroll + snap finish the job.destroy()disconnects the observer and removes listeners.
Responsive & touch
- Touch / drag / momentum: 100% native scroll. Zero JS.
- Items-per-view: pure CSS — the developer sizes slides
(
flex: 0 0 80%→33%at a breakpoint). Core is count-agnostic;indexis the leftmost snapped slide. - Scrollbar hidden (
scrollbar-width: none+::-webkit-scrollbar). Markers / arrows are the affordance; on touch the gesture is self-evident.
Effects (the "tactile" feel)
Pure CSS animation-timeline: view() on slides — scale / opacity react to scroll
position live as a slide nears center. Ships as an optional CSS snippet; the
developer opts in by adding a class. Degrades to plain snap where unsupported
(Safari partial today). No JS.
CSS sketch
.track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none; /* Firefox */
}
.track::-webkit-scrollbar { display: none; } /* Chrome/Safari */
.slide {
flex: 0 0 80%;
scroll-snap-align: center;
scroll-snap-stop: always; /* one swipe = one slide */
}
/* opt-in: tactile effect, degrades to plain snap */
.track.fx .slide {
animation: slide-fx linear both;
animation-timeline: view(inline);
}
@keyframes slide-fx {
entry 0%, exit 100% { scale: 0.9; opacity: 0.5; }
cover 50% { scale: 1; opacity: 1; }
}
/* CSS-first controls (Chrome 135+ / Safari 18.2+); JS dots() fills FF */
.track { scroll-marker-group: after; }
.slide::scroll-marker { /* dot styling */ }
Package shape
src/index.ts // createCarousel — core
src/dots.ts // dots(c, container) — FF/legacy fallback
src/autoplay.ts // autoplay(c, opts)
src/carousel.css // snap track + optional effects + native markers
Separate entry points → import only what you use. No runtime dependencies. Build with
tsc + a small bundler (tsup or equivalent).
Testing
- Core logic (index math,
changeemit,destroycleanup) — unit tests, jsdom with a fakeIntersectionObservershim. - Snap / scroll behavior — one Playwright smoke test in a real browser. jsdom can't scroll.