Component
carousel
Demo
tokens.css,
base.css and carousel.css. No scripts.
Source
Copy both files, or run npx nojsui add carousel.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Scroll snap | widely | 69 | 79 | 68 | 11 | 69 | 68 | 11 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| ::scroll-button4 of its parts | limited | 135 | 135 | — | — | 135 | — | — |
| Scroll markers4 of its parts | limited | 135 | 135 | — | — | 135 | — | — |
| scroll-behavior | widely | 61 | 79 | 36 | 15.4 | 61 | 36 | 15.4 |
Usage
A row of cards that scrolls, snaps, and — where the engine can generate them — gains previous/next arrows and a row of dots. No script, and no markup for the controls: the browser makes them, and owns their state.
<div class="sk-carousel">
<ul class="sk-carousel__track" tabindex="0" aria-label="Featured components">
<li class="sk-carousel__slide">
<article class="sk-carousel__card">
<h3 class="sk-carousel__title">Popover</h3>
<p class="sk-carousel__text">…</p>
</article>
</li>
<!-- one <li> per slide -->
</ul>
</div>
Two things in that markup are load-bearing:
tabindex="0"on the track. A scroll container is only keyboard scrollable if it can take focus, and no engine gives focus to a scroller whose children are not themselves focusable. Without it the arrow keys do nothing and the carousel is pointer-only — a WCAG 2.1.1 failure that is invisible until someone tries it.aria-labelon the track. It is a focusable region, so it needs a name; and because the element stays a<ul>, a screen reader still announces it as a list with a known number of items.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. carousel.css only ever reads them, so the nearest declaration
wins (ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-carousel-gap | --sk-space-md | Space between slides |
--sk-carousel-slide-inline-size | 18rem | Slide width, capped at the track’s own width |
--sk-carousel-card-bg | --sk-color-surface | Card background |
--sk-carousel-card-fg | --sk-color-text | Card text |
--sk-carousel-card-border-color | --sk-color-border | Card border |
--sk-carousel-card-radius | --sk-radius-lg | Card corner radius |
--sk-carousel-text-fg | --sk-color-text-muted | Card prose |
--sk-carousel-button-bg | --sk-color-surface-raised | Arrow background |
--sk-carousel-button-fg | --sk-color-text | Arrow glyph |
--sk-carousel-button-border-color | --sk-color-border-strong | Arrow border |
--sk-carousel-button-gutter | calc(var(--sk-size-tap-target) + var(--sk-space-2xs)) | Space reserved beside the track for the arrows. Only applied where the arrows exist — an engine without them does not pay for the space |
--sk-carousel-marker-color | --sk-color-border-strong | Dot outline |
--sk-carousel-marker-color-current | --sk-color-accent | The dot for the slide in view |
--sk-carousel-marker-size | --sk-space-xs | Dot diameter |
:root { --sk-carousel-slide-inline-size: 24rem; }
.tight-page { --sk-carousel-button-gutter: 0; }
Setting --sk-carousel-button-gutter: 0 puts the arrows over the edges of the
slides rather than beside them. That is a real layout, but it covers content —
prefer it only where the slides have quiet margins.
How it works
The whole base is one declaration on a scroll container:
.sk-carousel__track { overflow-inline: auto; scroll-snap-type: inline mandatory; }
Scroll snap is Baseline widely available, so swiping, scrolling, snapping and arrow keys work in every engine this kit supports. That is a complete carousel before any of the interesting parts arrive.
The interesting parts are that CSS can now generate the controls:
::scroll-button(inline-start) and ::scroll-button(inline-end) for the
arrows, ::scroll-marker on each slide for the dots. What makes them worth
using is not that they save markup — it is that the browser owns their state.
The previous arrow disables itself at the start of the scroll range. The dot
for the slide currently in view matches :target-current. Nothing in this
stylesheet observes scroll position, because nothing needs to.
content: "‹" / "Previous" sets the glyph and the alternative text separately,
so the arrow reads as “Previous” rather than as a punctuation character.
The arrows sit in inline padding on the wrapper rather than on top of the
slides. They are positioned against .sk-carousel, not the track: a box
positioned against the scroller would scroll away with the content.
Keyboard contract
Verified in Chromium 151 by measuring scrollLeft at each tab stop, not read
off the spec — see the Accessibility notes for where the two disagree.
| Key | Behaviour |
|---|---|
Tab | Three stops where the arrows exist: previous arrow, next arrow, then the track. One stop otherwise: the track |
Enter / Space on an arrow | Scrolls one page toward that end, landing on a snap point |
→ / ← with the track focused | Moves one slide. The keypress scrolls and mandatory snapping settles it on the next slide’s snap position — measured at 142px with a 288px slide, which is exactly where centring slide 2 puts it |
Home / End / Page Up / Page Down | Nothing, except Home in WebKit. These act on the block axis, and this carousel scrolls the inline one. Do not document them as carousel keys; measured across all three engines |
The last row is why this table is measured rather than copied. Every carousel
article lists Home/End, and on an inline-axis scroller they are inert.
Verified manually in VoiceOver and NVDA: not yet — do this before marking the component done, and change this line when you have.
Accessibility notes
The list stays a list. The track is a <ul> with an accessible name, so a
screen reader announces it as a named list of N items and the slides are list
items — the same thing they would be in a static page. Nothing here claims to
be a tablist, because nothing here implements one.
The dots are pointer-only in Chromium 151. They are clickable and they jump to the right slide, but they are not in the tab order — measured, not assumed. The spec describes the marker group as a single focusable stop with arrow-key navigation between markers; that is not what this build does.
This is not a keyboard trap or a 2.1.1 failure, because the dots are not the
only route to any slide: the arrows and the track’s own arrow keys reach every
one of them. It does mean the dots should be read as a progress indicator that
happens to be clickable, and a carousel must never put content behind them
that has no other route. If the tab order matters more than the dots, drop the
scroll-marker-group block; nothing else depends on it.
The arrows are real buttons to the browser, including their disabled state, so they are announced and skipped like any other disabled control.
Degradation
| Feature used | Baseline status | Behavior without it |
|---|---|---|
scroll-snap | widely | The floor. Absent, the track is a horizontally scrolling list — the content is all still reachable, it just does not snap |
::scroll-button() | limited | No arrows. WebKit 26.5 and Firefox 153 today. Scrolling, snapping and the keyboard are unchanged |
::scroll-marker | limited | No dots, and no reserved space for them. Same engines |
scroll-behavior | widely | Movement between slides is instant rather than smooth. This is also what everyone gets under prefers-reduced-motion: reduce, deliberately |
Because two of the four are limited, this component is marked
experimental: true and the docs page carries the warning badge. The base is
not experimental; the controls are.