# Carousel Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a tiny, framework-agnostic scroll-snap carousel: a zero-dep core plus opt-in `dots()`/`autoplay()` modules and a CSS file. **Architecture:** Native scroll handles touch/momentum; CSS handles snap and motion; JS is a thin layer. Core `createCarousel(track)` returns an instance (`next/prev/scrollToIndex/index/count/on/destroy`) with index tracking via IntersectionObserver. Controls are CSS-first (native `::scroll-marker`/`::scroll-button`); JS `dots()` is the Firefox/legacy fallback. **Tech Stack:** TypeScript, tsup (build), Vitest + jsdom (unit), Playwright (browser smoke). No runtime deps. Design reference: `docs/plans/2026-06-30-carousel-design.md`. --- ## Task 1: Scaffold the package **Files:** - Create: `package.json`, `tsconfig.json`, `tsup.config.ts`, `vitest.config.ts`, `.gitignore`, `src/index.ts` **Step 1: Write `package.json`** ```json { "name": "carousel", "version": "0.0.0", "type": "module", "sideEffects": ["*.css"], "exports": { ".": "./dist/index.js", "./dots": "./dist/dots.js", "./autoplay": "./dist/autoplay.js", "./carousel.css": "./src/carousel.css" }, "files": ["dist", "src/carousel.css"], "scripts": { "build": "tsup src/index.ts src/dots.ts src/autoplay.ts --format esm --dts", "test": "vitest run", "test:e2e": "playwright test", "check": "tsc --noEmit" }, "devDependencies": { "@playwright/test": "^1", "jsdom": "^25", "tsup": "^8", "typescript": "^5", "vitest": "^2" } } ``` **Step 2: Write `tsconfig.json`** ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "verbatimModuleSyntax": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "noEmit": true, "skipLibCheck": true }, "include": ["src", "tests"] } ``` **Step 3: Write `vitest.config.ts`** ```ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', include: ['tests/**/*.test.ts'] }, }); ``` **Step 4: Write `.gitignore`** ``` node_modules dist test-results playwright-report ``` **Step 5: Stub `src/index.ts`** ```ts export {}; ``` **Step 6: Install and verify** Run: `yarn install && yarn check` Expected: installs cleanly, `tsc` exits 0. **Step 7: Commit** ```bash git add -A git commit -m "chore: scaffold carousel package" ``` --- ## Task 2: Core `createCarousel` — count, navigation, scrollToIndex **Files:** - Modify: `src/index.ts` - Test: `tests/core.test.ts` > jsdom has no layout/scroll. Provide a minimal `IntersectionObserver` shim in the > test setup and assert on `scrollTo` calls (spy) rather than real scrolling. **Step 1: Write the failing test** ```ts // tests/core.test.ts import { beforeEach, expect, test, vi } from 'vitest'; import { createCarousel } from '../src/index.ts'; // Minimal IO shim — records instances so tests can fire entries manually. class IOShim { static last: IOShim | null = null; cb: IntersectionObserverCallback; elements: Element[] = []; constructor(cb: IntersectionObserverCallback) { this.cb = cb; IOShim.last = this; } observe(el: Element) { this.elements.push(el); } unobserve() {} disconnect() {} // helper: emit "slide i is the one intersecting" emit(i: number) { this.cb( this.elements.map((target, idx) => ({ target, isIntersecting: idx === i, intersectionRatio: idx === i ? 1 : 0, })) as unknown as IntersectionObserverEntry[], this as unknown as IntersectionObserver, ); } } vi.stubGlobal('IntersectionObserver', IOShim); function makeTrack(n: number): HTMLElement { const track = document.createElement('div'); for (let i = 0; i < n; i++) { const slide = document.createElement('div'); slide.className = 'slide'; track.append(slide); } track.scrollTo = vi.fn(); return track; } let track: HTMLElement; beforeEach(() => { track = makeTrack(3); }); test('count reflects slide children', () => { const c = createCarousel(track); expect(c.count).toBe(3); }); test('next/prev clamp to range and scroll to target offset', () => { const c = createCarousel(track); // give slides fake offsets (track.children[1] as HTMLElement).offsetLeft; // 0 in jsdom c.next(); expect(track.scrollTo).toHaveBeenCalled(); }); ``` **Step 2: Run test to verify it fails** Run: `yarn test tests/core.test.ts` Expected: FAIL — `createCarousel` not exported. **Step 3: Write minimal implementation** ```ts // src/index.ts export type CarouselEvent = 'change'; export type Carousel = { next(): void; prev(): void; scrollToIndex(i: number): void; readonly index: number; readonly count: number; on(evt: CarouselEvent, cb: (index: number) => void): () => void; destroy(): void; }; /** * Wrap a scroll-snap track element and return a carousel controller. * @param track - the overflow-x scroll container whose children are slides */ export function createCarousel(track: HTMLElement): Carousel { const slides = () => Array.from(track.children) as HTMLElement[]; let index = 0; const listeners = new Set<(i: number) => void>(); const clamp = (i: number) => Math.max(0, Math.min(i, slides().length - 1)); function scrollToIndex(i: number) { const target = slides()[clamp(i)]; if (target) { track.scrollTo({ left: target.offsetLeft, behavior: 'smooth' }); } } // index tracking: whichever slide is most intersecting is current const io = new IntersectionObserver( (entries) => { const hit = entries.find((e) => e.isIntersecting); if (!hit) return; const i = slides().indexOf(hit.target as HTMLElement); if (i !== -1 && i !== index) { index = i; listeners.forEach((cb) => cb(index)); } }, { root: track, threshold: 0.6 }, ); slides().forEach((s) => io.observe(s)); return { next: () => scrollToIndex(index + 1), prev: () => scrollToIndex(index - 1), scrollToIndex, get index() { return index; }, get count() { return slides().length; }, on(_evt, cb) { listeners.add(cb); return () => listeners.delete(cb); }, destroy() { io.disconnect(); listeners.clear(); }, }; } ``` **Step 4: Run test to verify it passes** Run: `yarn test tests/core.test.ts` Expected: PASS (2 tests). **Step 5: Commit** ```bash git add src/index.ts tests/core.test.ts git commit -m "feat: carousel core (count, navigation, scrollToIndex)" ``` --- ## Task 3: Core — `change` event fires on snap, `destroy` cleans up **Files:** - Test: `tests/core.test.ts` (add cases) **Step 1: Write failing tests** ```ts test('change fires with new index when a slide intersects', () => { const c = createCarousel(track); const seen: number[] = []; c.on('change', (i) => seen.push(i)); (IOShim.last as IOShim).emit(2); // user swiped to slide 2 expect(seen).toEqual([2]); expect(c.index).toBe(2); }); test('change does not re-fire for the same index', () => { const c = createCarousel(track); const seen: number[] = []; c.on('change', (i) => seen.push(i)); (IOShim.last as IOShim).emit(1); (IOShim.last as IOShim).emit(1); expect(seen).toEqual([1]); }); test('unsubscribe stops delivery; destroy disconnects observer', () => { const c = createCarousel(track); const seen: number[] = []; const off = c.on('change', (i) => seen.push(i)); off(); (IOShim.last as IOShim).emit(2); expect(seen).toEqual([]); const spy = vi.spyOn(IOShim.last as IOShim, 'disconnect'); c.destroy(); expect(spy).toHaveBeenCalled(); }); ``` **Step 2: Run to verify** Run: `yarn test tests/core.test.ts` Expected: PASS — implementation from Task 2 already covers these. If any fail, fix `src/index.ts` minimally. **Step 3: Commit (only if code changed)** ```bash git add -A git commit -m "test: carousel core change/destroy edge cases" ``` --- ## Task 4: `dots()` fallback module **Files:** - Create: `src/dots.ts` - Test: `tests/dots.test.ts` > Wires the developer's existing dot elements. Click → `scrollToIndex`. `change` → > toggle `aria-current`/`.active`. Engages only when native `::scroll-marker` is > unsupported (feature-detect via `CSS.supports('selector(::scroll-marker)')`); in > tests we force-enable by passing the dots explicitly. **Step 1: Write failing test** ```ts // tests/dots.test.ts import { expect, test, vi } from 'vitest'; import { dots } from '../src/dots.ts'; function fakeCarousel() { let cb: (i: number) => void = () => {}; return { index: 0, count: 3, next: vi.fn(), prev: vi.fn(), scrollToIndex: vi.fn(), on: (_e: string, fn: (i: number) => void) => { cb = fn; return () => {}; }, destroy: vi.fn(), fire: (i: number) => cb(i), }; } test('clicking a dot scrolls to its index', () => { const c = fakeCarousel(); const container = document.createElement('div'); container.innerHTML = ''; dots(c as never, container); (container.children[2] as HTMLButtonElement).click(); expect(c.scrollToIndex).toHaveBeenCalledWith(2); }); test('change marks the active dot with aria-current', () => { const c = fakeCarousel(); const container = document.createElement('div'); container.innerHTML = ''; dots(c as never, container); c.fire(1); expect(container.children[1].getAttribute('aria-current')).toBe('true'); expect(container.children[0].hasAttribute('aria-current')).toBe(false); }); ``` **Step 2: Run to verify it fails** Run: `yarn test tests/dots.test.ts` Expected: FAIL — `dots` not found. **Step 3: Implement** ```ts // src/dots.ts import type { Carousel } from './index.ts'; /** * Wire the developer's existing dot elements to a carousel. * Progressive fallback: prefer native ::scroll-marker where supported. * @param c - carousel instance * @param container - element whose children are the dot controls */ export function dots(c: Carousel, container: HTMLElement): () => void { const items = Array.from(container.children) as HTMLElement[]; const onClick = (i: number) => () => c.scrollToIndex(i); const handlers = items.map((el, i) => { const h = onClick(i); el.addEventListener('click', h); return h; }); function mark(active: number) { items.forEach((el, i) => { if (i === active) { el.setAttribute('aria-current', 'true'); el.classList.add('active'); } else { el.removeAttribute('aria-current'); el.classList.remove('active'); } }); } mark(c.index); const off = c.on('change', mark); return () => { off(); items.forEach((el, i) => el.removeEventListener('click', handlers[i])); }; } ``` **Step 4: Run to verify it passes** Run: `yarn test tests/dots.test.ts` Expected: PASS (2 tests). **Step 5: Commit** ```bash git add src/dots.ts tests/dots.test.ts git commit -m "feat: dots() fallback control module" ``` --- ## Task 5: `autoplay()` module **Files:** - Create: `src/autoplay.ts` - Test: `tests/autoplay.test.ts` > Timer calls `c.next()`. Pause on `pointerenter`/`focusin`, resume on leave/blur, > stop on manual interaction. Loops back to 0 at the end. Use fake timers. **Step 1: Write failing test** ```ts // tests/autoplay.test.ts import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { autoplay } from '../src/autoplay.ts'; function fakeCarousel(count = 3) { let index = 0; return { get index() { return index; }, count, next: vi.fn(() => { index = (index + 1) % count; }), prev: vi.fn(), scrollToIndex: vi.fn(), on: () => () => {}, destroy: vi.fn(), }; } beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); test('advances every interval', () => { const c = fakeCarousel(); autoplay(c as never, { interval: 1000, root: document.createElement('div') }); vi.advanceTimersByTime(2000); expect(c.next).toHaveBeenCalledTimes(2); }); test('pause on pointerenter, resume on pointerleave', () => { const c = fakeCarousel(); const root = document.createElement('div'); autoplay(c as never, { interval: 1000, root }); root.dispatchEvent(new Event('pointerenter')); vi.advanceTimersByTime(3000); expect(c.next).not.toHaveBeenCalled(); root.dispatchEvent(new Event('pointerleave')); vi.advanceTimersByTime(1000); expect(c.next).toHaveBeenCalledTimes(1); }); test('stop() halts and removes listeners', () => { const c = fakeCarousel(); const stop = autoplay(c as never, { interval: 1000, root: document.createElement('div') }); stop(); vi.advanceTimersByTime(5000); expect(c.next).not.toHaveBeenCalled(); }); ``` **Step 2: Run to verify it fails** Run: `yarn test tests/autoplay.test.ts` Expected: FAIL — `autoplay` not found. **Step 3: Implement** ```ts // src/autoplay.ts import type { Carousel } from './index.ts'; /** * Options for autoplay. */ export type AutoplayOptions = { /** ms between advances */ interval: number; /** element whose hover/focus pauses playback (usually the track wrapper) */ root: HTMLElement; }; /** * Auto-advance a carousel, pausing on hover/focus. * @param c - carousel instance * @param opts - interval (ms) and the root element to bind pause events to * @returns stop function that halts playback and removes listeners */ export function autoplay(c: Carousel, opts: AutoplayOptions): () => void { let timer: ReturnType | undefined; const tick = () => c.next(); const start = () => { timer ??= setInterval(tick, opts.interval); }; const pause = () => { clearInterval(timer); timer = undefined; }; opts.root.addEventListener('pointerenter', pause); opts.root.addEventListener('pointerleave', start); opts.root.addEventListener('focusin', pause); opts.root.addEventListener('focusout', start); start(); return () => { pause(); opts.root.removeEventListener('pointerenter', pause); opts.root.removeEventListener('pointerleave', start); opts.root.removeEventListener('focusin', pause); opts.root.removeEventListener('focusout', start); }; } ``` **Step 4: Run to verify it passes** Run: `yarn test tests/autoplay.test.ts` Expected: PASS (3 tests). **Step 5: Commit** ```bash git add src/autoplay.ts tests/autoplay.test.ts git commit -m "feat: autoplay() module with hover/focus pause" ``` --- ## Task 6: `carousel.css` **Files:** - Create: `src/carousel.css` > No test — it's static CSS, validated by the Playwright smoke test in Task 7. **Step 1: Write the stylesheet** ```css /* carousel.css — snap track, hidden scrollbar, native markers, opt-in effects */ .track { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; scroll-marker-group: after; /* native dots where supported */ } .track::-webkit-scrollbar { display: none; } .slide { flex: 0 0 80%; scroll-snap-align: center; scroll-snap-stop: always; /* one swipe = one slide */ } /* native CSS markers (Chrome 135+, Safari 18.2+) */ .slide::scroll-marker { content: ''; width: 0.6rem; height: 0.6rem; border-radius: 50%; background: currentColor; opacity: 0.4; } .slide::scroll-marker:target-current { opacity: 1; } /* opt-in tactile effect; degrades to plain snap where unsupported */ @supports (animation-timeline: view()) { .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; } } } ``` **Step 2: Commit** ```bash git add src/carousel.css git commit -m "feat: carousel.css (snap, native markers, opt-in effects)" ``` --- ## Task 7: Playwright smoke test (real scroll) **Files:** - Create: `playwright.config.ts`, `e2e/demo.html`, `e2e/smoke.test.ts` > jsdom can't scroll. One real-browser test proves snap + `next()` actually move. **Step 1: Write `playwright.config.ts`** ```ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: 'e2e', use: { baseURL: 'http://localhost:5173' }, webServer: { command: 'npx http-server e2e -p 5173 -s', port: 5173, reuseExistingServer: true }, }); ``` **Step 2: Write `e2e/demo.html`** A page that imports the built core from `../dist/index.js`, builds a 3-slide track, exposes `window.c = createCarousel(track)`. Include `carousel.css`. ```html
0
1
2
``` **Step 3: Write the test** ```ts // e2e/smoke.test.ts import { expect, test } from '@playwright/test'; test('next() scrolls the track and updates index', async ({ page }) => { await page.goto('/demo.html'); const before = await page.evaluate(() => document.getElementById('t')!.scrollLeft); await page.evaluate(() => (window as any).c.next()); await page.waitForTimeout(500); // smooth scroll settle const after = await page.evaluate(() => document.getElementById('t')!.scrollLeft); expect(after).toBeGreaterThan(before); await expect.poll(() => page.evaluate(() => (window as any).c.index)).toBe(1); }); ``` **Step 4: Build, then run** Run: `yarn build && yarn test:e2e` Expected: PASS — `scrollLeft` increases, `index` becomes 1. **Step 5: Commit** ```bash git add playwright.config.ts e2e/ git commit -m "test: playwright smoke for real scroll behavior" ``` --- ## Task 8: README + verify the public API **Files:** - Create: `README.md` **Step 1: Write a short README** — install, the three import paths, a copy-paste example wiring arrows (manual handlers) + `dots()` fallback + native CSS markers, and the browser-support table from the design doc. **Step 2: Final verification** Run: `yarn check && yarn test && yarn build` Expected: all green; `dist/` contains `index.js`, `dots.js`, `autoplay.js` with `.d.ts`. **Step 3: Commit** ```bash git add README.md git commit -m "docs: README with usage and browser support" ``` --- ## Notes for the executor - **DRY/YAGNI:** no loop mode, no DOM generation, no fade variant — explicitly deferred. - **Fold review fixes** into the related task's commit; don't leave "feature + fix" pairs. - The IO shim and fake-carousel helpers are deliberately tiny — don't promote them to a framework.