Files
carousel/docs/plans/2026-06-30-carousel-implementation.md
T

18 KiB

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

{
  "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

{
  "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

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

export {};

Step 6: Install and verify

Run: yarn install && yarn check Expected: installs cleanly, tsc exits 0.

Step 7: Commit

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

// 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

// 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

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

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)

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

// 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 = '<button></button><button></button><button></button>';
  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 = '<button></button><button></button><button></button>';
  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

// 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

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

// 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

// 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<typeof setInterval> | 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

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

/* 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

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

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.

<!doctype html>
<link rel="stylesheet" href="../src/carousel.css" />
<div class="track" id="t" style="width:300px">
  <div class="slide" id="s0">0</div>
  <div class="slide" id="s1">1</div>
  <div class="slide" id="s2">2</div>
</div>
<script type="module">
  import { createCarousel } from '../dist/index.js';
  window.c = createCarousel(document.getElementById('t'));
</script>

Step 3: Write the test

// 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

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

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.