Primitives Layout and display
Carousel
A row of slides that scrolls and snaps, with previous and next buttons and one marker per slide where the browser implements CSS carousels. Scrolling, snapping and those controls are the browser's; the package draws them and writes the contract. No autoplay. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - 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
On this page
Example
<section data-slean="carousel" aria-roledescription="carousel" aria-label="Native paths">
<div data-slean-part="viewport" tabindex="0"
data-slean-previous="Previous slide" data-slean-next="Next slide">
<div data-slean-part="slide" role="group" aria-roledescription="slide" aria-label="1 of 4">
<div class="panel">
<h3>Dialog</h3>
<p>A modal from <dialog> and two buttons with commandfor.</p>
<a href="/primitives/dialog">Read the dialog page</a>
</div>
</div>
<div data-slean-part="slide" role="group" aria-roledescription="slide" aria-label="2 of 4">
<div class="panel">
<h3>Popover</h3>
<p>The top layer, light dismiss and focus return from one attribute.</p>
<a href="/primitives/popover">Read the popover page</a>
</div>
</div>
<div data-slean-part="slide" role="group" aria-roledescription="slide" aria-label="3 of 4">
<div class="panel">
<h3>Accordion</h3>
<p>One open item at a time from a shared name on details elements.</p>
<a href="/primitives/accordion">Read the accordion page</a>
</div>
</div>
<div data-slean-part="slide" role="group" aria-roledescription="slide" aria-label="4 of 4">
<div class="panel">
<h3>Date field</h3>
<p>Segmented entry, validation and a picker in one input element.</p>
<a href="/primitives/date-field">Read the date field page</a>
</div>
</div>
</div>
</section><script lang="ts">
import '@svelte-lean/styles/carousel.css';
</script>
<section data-slean="carousel" aria-roledescription="carousel" aria-label="Native paths">
<div data-slean-part="viewport" tabindex="0"
data-slean-previous="Previous slide" data-slean-next="Next slide">
<div data-slean-part="slide" role="group" aria-roledescription="slide" aria-label="1 of 4">
<div class="panel">
<h3>Dialog</h3>
<p>A modal from <dialog> and two buttons with commandfor.</p>
<a href="/primitives/dialog">Read the dialog page</a>
</div>
</div>
<div data-slean-part="slide" role="group" aria-roledescription="slide" aria-label="2 of 4">
<div class="panel">
<h3>Popover</h3>
<p>The top layer, light dismiss and focus return from one attribute.</p>
<a href="/primitives/popover">Read the popover page</a>
</div>
</div>
<div data-slean-part="slide" role="group" aria-roledescription="slide" aria-label="3 of 4">
<div class="panel">
<h3>Accordion</h3>
<p>One open item at a time from a shared name on details elements.</p>
<a href="/primitives/accordion">Read the accordion page</a>
</div>
</div>
<div data-slean-part="slide" role="group" aria-roledescription="slide" aria-label="4 of 4">
<div class="panel">
<h3>Date field</h3>
<p>Segmented entry, validation and a picker in one input element.</p>
<a href="/primitives/date-field">Read the date field page</a>
</div>
</div>
</div>
</section>
<style>
.panel {
block-size: 100%;
padding: 1.5rem;
border-radius: 0.625rem;
background: var(--slean-muted);
}
</style>Why this implementation exists
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. Component libraries rebuild it with transforms and pointer handlers and then have to rebuild momentum, snapping and keyboard support too.
The rest, buttons and markers, is now 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 in a group the browser exposes as tabs. Chrome ships them; Firefox and Safari do not yet, and there the carousel stays a snapping row. Svelte Lean adds no script to fill that gap: the content is reachable either way.
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
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@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; without them the browser names them in the language of its own interface.
The stylesheet sets container-type: scroll-state on the viewport and the slides to fade a button that cannot scroll and fill the marker of the snapped slide. 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. |
Runtime profile
Tier 0: the carousel has no behavior module, the Vite plugin maps carousel to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript. The buttons and markers are pseudo-elements the browser creates from carousel.css.
Accessibility contract
- 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 exposed as tabs named after the slides, the current one selected, and the arrow keys move between them; the buttons are named buttons that 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.
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) |
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
Static HTML. The first slide is in view on load.
Before hydration
Nothing is attached; scrolling, snapping and the generated controls work before hydration.
Styling
carousel.css lays out the row and its snap points, hides the scrollbar where the buttons and markers exist, places the buttons at the ends of the marker row, draws their chevrons as two background strokes in a rotated circle (mirrored under :dir(rtl)), and uses scroll-state container queries for the faded button and the filled, wider marker. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-fg | var(--slean-neutral-12) | color |
--slean-space-4 | 1rem | gap |
--slean-radius-lg | 0.875rem | border-radius |
--slean-space-3 | 0.75rem | 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-accent | oklch(54% 0.19 258) | 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.
Compatibility notes
Scroll snapping is Baseline 2020 and widely available. ::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.
Examples
Several slides per view
A slide is as wide as the viewport by default. Set a narrower flex-basis on the slides
in the application's CSS; snapping still aligns a slide's start, and the generated buttons scroll
one viewport width at a time.
/* Application CSS: slides narrower than the viewport. Snapping still aligns a slide's start,
* and the generated buttons scroll one viewport width at a time. */
.projects [data-slean-part='slide'] {
flex-basis: min(18rem, 80%);
}Testing
apps/playground/tests/primitives/display.spec.tsPlaywright: open state, exclusive groups, stretched links, roles and names, carousel scrolling, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/carousel/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/carousel/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/carousel.cssthe optional stylesheet