docs: carousel design
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# 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:
|
||||
|
||||
1. **Core** — `createCarousel(track, opts?)` wraps one scroll container, returns an
|
||||
instance. Zero deps.
|
||||
2. **Sugar modules** (opt-in imports) — `dots()`, `autoplay()`. Wire the developer's
|
||||
**existing** markup to the instance. Create no DOM, ship no CSS.
|
||||
3. **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
|
||||
|
||||
```ts
|
||||
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** = `IntersectionObserver` on 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.
|
||||
- `next` / `prev` / `scrollToIndex` = `el.scrollTo()` (or `scrollIntoView`) 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; `index` is 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
|
||||
|
||||
```css
|
||||
.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, `change` emit, `destroy` cleanup) — unit tests, jsdom
|
||||
with a fake `IntersectionObserver` shim.
|
||||
- **Snap / scroll behavior** — one Playwright smoke test in a real browser. jsdom
|
||||
can't scroll.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Carousels with CSS — Chrome for Developers](https://developer.chrome.com/blog/carousels-with-css)
|
||||
- [`::scroll-button()` — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::scroll-button)
|
||||
- [Creating CSS carousels — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Overflow/Carousels)
|
||||
- [Using scroll snap events — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll_snap/Using_scroll_snap_events)
|
||||
- [CSS `scroll-state()` container queries — Chrome for Developers](https://developer.chrome.com/blog/css-scroll-state-queries)
|
||||
Reference in New Issue
Block a user