commit 01fe0649ceb16928e84e69347e046baf951094c7 Author: Ilia Mashkov Date: Tue Jun 30 19:55:31 2026 +0300 docs: carousel design diff --git a/docs/plans/2026-06-30-carousel-design.md b/docs/plans/2026-06-30-carousel-design.md new file mode 100644 index 0000000..ed45a49 --- /dev/null +++ b/docs/plans/2026-06-30-carousel-design.md @@ -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)