diff --git a/src/dots.ts b/src/dots.ts
new file mode 100644
index 0000000..9d5e539
--- /dev/null
+++ b/src/dots.ts
@@ -0,0 +1,47 @@
+import type { Carousel } from './index.ts';
+
+const NOOP = () => {};
+
+/**
+ * Wire the developer's existing dot elements to a carousel.
+ * Progressive fallback: no-ops where native ::scroll-marker is supported, so the
+ * native marker group is the sole control there (no double dots).
+ * @param c - carousel instance
+ * @param container - element whose children are the dot controls
+ * @returns cleanup function (a noop when native markers are used)
+ */
+export function dots(c: Carousel, container: HTMLElement): () => void {
+ // Native markers present → let CSS own the controls.
+ if (typeof CSS !== 'undefined' && CSS.supports('selector(::scroll-marker)')) {
+ return NOOP;
+ }
+
+ const items = Array.from(container.children) as HTMLElement[];
+
+ const handlers = items.map((el, i) => {
+ const h = () => c.scrollToIndex(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]);
+ });
+ };
+}
diff --git a/tests/dots.test.ts b/tests/dots.test.ts
new file mode 100644
index 0000000..418aba8
--- /dev/null
+++ b/tests/dots.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, 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),
+ };
+}
+
+function dotContainer() {
+ const el = document.createElement('div');
+ el.innerHTML = '';
+ return el;
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+test('clicking a dot scrolls to its index', () => {
+ const c = fakeCarousel();
+ const container = dotContainer();
+ 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 = dotContainer();
+ 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);
+});
+
+test('no-ops (no listeners wired) when native ::scroll-marker is supported', () => {
+ vi.stubGlobal('CSS', { supports: () => true }); // jsdom has no CSS global
+
+ const c = fakeCarousel();
+ const container = dotContainer();
+ const cleanup = dots(c as never, container);
+ (container.children[0] as HTMLButtonElement).click();
+ expect(c.scrollToIndex).not.toHaveBeenCalled();
+ expect(typeof cleanup).toBe('function'); // noop cleanup, safe to call
+ cleanup();
+});