# Carousel

> A scroll-snapping row with native scroll buttons and markers where the browser draws them.

Page: https://svelte-lean.vercel.app/primitives/carousel · Markdown: https://svelte-lean.vercel.app/primitives/carousel.md · Index: https://svelte-lean.vercel.app/llms.txt

A row of slides that scrolls and snaps, for featured content or a gallery; where CSS carousels exist the browser adds the buttons and markers.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/carousel.css';

	const slides = [
		{ title: 'Dialog', text: 'A modal from <dialog> and two buttons with commandfor.' },
		{ title: 'Popover', text: 'The top layer, light dismiss and focus return from one attribute.' },
		{ title: 'Accordion', text: 'One open item at a time from a shared name on details elements.' },
		{ title: 'Date input', text: 'Typed entry, validation and a picker from one input of type date.' }
	];
</script>

<section data-slean="carousel" aria-roledescription="carousel" aria-label="Native paths">
	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
	<div
		data-slean-part="viewport"
		tabindex="0"
		data-slean-previous="Previous slide"
		data-slean-next="Next slide"
	>
		{#each slides as slide, i (slide.title)}
			<div
				data-slean-part="slide"
				role="group"
				aria-roledescription="slide"
				aria-label="{i + 1} of {slides.length}"
			>
				<div class="panel">
					<strong>{slide.title}</strong>
					<p>{slide.text}</p>
				</div>
			</div>
		{/each}
	</div>
</section>

<style>
	.panel {
		display: grid;
		align-content: start;
		gap: 0.5rem;
		block-size: 100%;
		min-block-size: 10rem;
		padding: 1.5rem;
		border-radius: var(--slean-radius-lg);
		background: var(--slean-muted);
	}

	.panel p {
		margin: 0;
		color: var(--slean-fg-muted);
	}
</style>
```

No script. In Chrome the buttons and markers are generated from the stylesheet; elsewhere the row scrolls and snaps with its scrollbar.

## Examples

### Gallery

```svelte
<script lang="ts">
	import '@svelte-lean/styles/carousel.css';

	const photos = [
		{ name: 'Harbor at dawn', tint: 'linear-gradient(160deg, #f6d365, #fda085)' },
		{ name: 'Glacier lake', tint: 'linear-gradient(160deg, #a1c4fd, #4f7fd6)' },
		{ name: 'Pine ridge', tint: 'linear-gradient(160deg, #96e6a1, #2f8f5b)' },
		{ name: 'Desert road', tint: 'linear-gradient(160deg, #fbc2a4, #b4543a)' },
		{ name: 'Night market', tint: 'linear-gradient(160deg, #6a5acd, #1e1b4b)' }
	];
</script>

<section data-slean="carousel" aria-roledescription="carousel" aria-label="Photos">
	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
	<div
		data-slean-part="viewport"
		tabindex="0"
		data-slean-previous="Previous photo"
		data-slean-next="Next photo"
	>
		{#each photos as photo, i (photo.name)}
			<div
				data-slean-part="slide"
				role="group"
				aria-roledescription="slide"
				aria-label="{i + 1} of {photos.length}"
			>
				<figure>
					<div class="photo" style:background={photo.tint}></div>
					<figcaption>{photo.name}</figcaption>
				</figure>
			</div>
		{/each}
	</div>
</section>

<style>
	figure {
		margin: 0;
	}

	.photo {
		aspect-ratio: 21 / 9;
		border-radius: var(--slean-radius-lg);
	}

	figcaption {
		padding-block: 0.5rem 0;
		color: var(--slean-fg-muted);
		font-size: var(--slean-text-sm);
	}
</style>
```

### Several slides per view

```svelte
<script lang="ts">
	import '@svelte-lean/styles/carousel.css';

	const plans = [
		{ name: 'Starter', price: '$0', text: 'One project and the community forum.' },
		{ name: 'Team', price: '$12', text: 'Unlimited projects, roles and audit logs.' },
		{ name: 'Business', price: '$29', text: 'Single sign-on and a support contract.' },
		{ name: 'Enterprise', price: 'Custom', text: 'Dedicated region and a named contact.' },
		{ name: 'Education', price: '$0', text: 'Team features for schools and courses.' }
	];
</script>

<section data-slean="carousel" aria-roledescription="carousel" aria-label="Plans">
	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
	<div
		data-slean-part="viewport"
		tabindex="0"
		data-slean-previous="Previous plans"
		data-slean-next="Next plans"
	>
		{#each plans as plan, i (plan.name)}
			<div
				class="plan"
				data-slean-part="slide"
				role="group"
				aria-roledescription="slide"
				aria-label="{i + 1} of {plans.length}"
			>
				<strong>{plan.name}</strong>
				<span class="price">{plan.price}</span>
				<p>{plan.text}</p>
			</div>
		{/each}
	</div>
</section>

<style>
	/* Narrower slides show several per view; snapping still aligns a slide's start. */
	.plan {
		flex-basis: min(14rem, 80%);
		display: grid;
		align-content: start;
		gap: 0.25rem;
		padding: 1.25rem;
		border: 1px solid var(--slean-border);
		border-radius: var(--slean-radius-lg);
	}

	.price {
		font-size: var(--slean-text-lg);
		font-weight: 600;
	}

	.plan p {
		margin: 0;
		color: var(--slean-fg-muted);
		font-size: var(--slean-text-sm);
	}
</style>
```

A narrower flex-basis on the slides shows several at once; the buttons scroll one viewport width.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/carousel.css';
```

Name the root with `aria-label`, give the viewport `tabindex="0"`, and give each slide `role="group"`, `aria-roledescription="slide"` and its position as a label in the page language. Name the generated buttons with `data-slean-previous` and `data-slean-next` on the viewport; otherwise the browser names them in its own interface language.

The stylesheet sets `container-type: scroll-state` on the viewport and the slides. If your CSS sets another container type on a slide, keep `scroll-state` in the list.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<section data-slean="carousel" aria-roledescription="carousel">` | – | yes | Named with aria-label or aria-labelledby. |
| viewport | `<div tabindex="0">` | `viewport` | yes | The scroll container. data-slean-previous and data-slean-next name the generated buttons. |
| slide | `<div role="group" aria-roledescription="slide">` | `slide` | yes | aria-label with its position, such as "1 of 4"; its marker takes that name. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `ArrowLeft`/`ArrowRight` | focus on the viewport | Scrolls; snapping settles on a slide (native, every browser) |
| `Enter`/`Space` | focus on a generated button (Chromium) | Scrolls one page of the viewport (native) |
| `ArrowLeft`/`ArrowRight` | focus on a marker (Chromium) | Moves to the neighbouring marker and scrolls to its slide (native) |

## Styling

`carousel.css` lays out the row and its snap points, hides the scrollbar where the buttons and markers exist, draws the chevrons (mirrored under `:dir(rtl)`), and uses scroll-state container queries for the faded button and the filled marker. `data-color` on the root or an ancestor colors the current marker (primary by default). The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | gap |
| `--slean-radius-lg` | `calc(var(--slean-radius) * 1.4)` | border-radius |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | margin-block-start |
| `--slean-radius-full` | `9999px` | border-radius |
| `--slean-border-strong` | `var(--slean-neutral-8)` | background |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-color-main` | `var(--slean-primary-main)` | background-color |
| `--slean-icon-chevron-left` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M10 4L6 8l4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-icon-chevron-right` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask-image |

State selectors the stylesheet targets, all from the platform or ARIA: `::scroll-button(*)`, `::scroll-button(inline-end)`, `::scroll-button(inline-start)`, `::scroll-marker`, `::scroll-marker-group`, `:dir(rtl)`.

Variant attributes: `data-slean-previous`; `data-slean-next`.

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `scroll-snap`, `::scroll-button()`, `::scroll-marker`, `::scroll-marker-group`, `scroll-state queries`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `scroll-snap, ::scroll-button(), ::scroll-marker`

Tier 0: no behavior module, the Vite plugin maps `carousel` to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript. Scrolling, snapping and the generated controls are the browser's; the buttons and markers are pseudo-elements it creates from `carousel.css`.

Why this implementation

Most of a carousel is scrolling: a row wider than its box, moved by touch, trackpad or keys, stopping on a slide. Scroll snapping does that in every browser, and a focusable viewport gives the keyboard the same. Libraries that rebuild it with transforms and pointer handlers have to rebuild momentum, snapping and keyboard support too.

The buttons and markers are CSS as well. `::scroll-button()` asks the browser for a button that scrolls the viewport and disables itself at the end; `::scroll-marker` asks for one marker per slide, exposed as tabs. Chrome ships them; in Firefox and Safari the carousel stays a snapping row, and Svelte Lean adds no script to fill the gap: the content is reachable either way. There is no autoplay.

### The browser owns

- scrolling, snapping, touch and trackpad swipes, arrow keys on the focused viewport
- scrolling a focused element inside a slide into view
- in Chromium: the previous and next buttons, one marker per slide, their roles, names, keyboard and disabled state

### Svelte Lean owns

- carousel.css: the snapping row, markers requested as tabs, the button and marker layout, the drawn chevrons, the faded button and the filled marker
- the contract: roles, labels, the names of the buttons, and why there is no autoplay
- documentation

Accessibility

- The root is a region named by its label with the role description carousel; each slide is a group with the role description slide and a label such as `1 of 4`.
- The viewport is focusable, so ArrowLeft and ArrowRight scroll it in every browser; Tab moves into the slides and the browser scrolls a focused link into view.
- In Chrome the markers are tabs named after the slides, the current one selected, and the arrow keys move between them; the buttons are named buttons the browser disables at the ends. The stylesheet asks for tabs with `scroll-marker-group: after tabs`; the default mode, links, is exposed by Chrome 154 as a navigation of links.
- No autoplay: moving content needs a pause control and a stop on hover and focus, which would make the carousel a Tier 2 behavior. No live region either, since scrolling changes no content.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Scroll snap` | Baseline 2020, widely available | Not applicable within the support policy |
| `::scroll-button(), ::scroll-marker, ::scroll-marker-group` | Chrome 135; not in Firefox or Safari at the time of writing | No buttons and no markers; the viewport keeps its scrollbar |
| `scroll-marker-group: after tabs` | Chrome 151 parses the keyword; Chrome 154 exposes the default, links, as a navigation of links | The declaration is dropped and scroll-marker-group: after applies |
| `Container scroll-state queries` | Chrome 133 | Not used: the buttons and markers they style do not exist there |
| `:dir()` | Baseline 2023, widely available | Not applicable within the support policy |

Without JavaScript

Fully functional, including the generated buttons and markers in Chrome.

Server rendering and hydration

Static HTML. The first slide is in view on load.

Nothing is attached; scrolling, snapping and the generated controls work before hydration.

Compatibility

Scroll snapping is Baseline 2020. `::scroll-button()`, `::scroll-marker` and `::scroll-marker-group` are in Chrome 135 and not in Firefox or Safari at the time of writing; container scroll-state queries, used only to style them, are in Chrome 133. Elsewhere the viewport keeps its scrollbar and the arrow keys, touch and trackpad move it.

Tests and source

- `apps/playground/tests/primitives/display.spec.ts` Playwright: open state, exclusive groups, stretched links, roles and names, carousel scrolling, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/carousel/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/carousel/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/carousel.css` the optional stylesheet
