Files
carousel/docs/plans/2026-06-30-carousel-design.md
T
2026-06-30 19:55:31 +03:00

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:

  1. CorecreateCarousel(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

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

.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