Sandbox
The carousel
In audits, I keep finding carousels that scroll on their own and take away the card you were still reading. Here, the list with scroll-snap only moves when you click an arrow or scroll it yourself.
- Built with
- scroll-snap and buttons
- Browsers
- Chrome 105+, Firefox 121+, Safari 16+
- JavaScript
- 19 lines, for the arrows
Demo
The arrows scroll the list by one view. The number of visible cards grows with the width of the carousel, and the next card sticks out into the margin to show there is more.
Destinations
Lisbon
Seven hills, yellow trams and tiled façades.
Kyoto
More than 1,600 Buddhist temples and moss gardens.
Montreal
A bilingual city built around Mount Royal.
Edinburgh
A castle on an extinct volcano, above the Old Town.
Marrakesh
The souks of the medina and Jemaa el-Fnaa square.
Reykjavík
The world's northernmost capital of a sovereign state.
Oaxaca
Colorful markets and a cuisine known for its seven moles.
Anatomy
sectionThe region, named by its heading.h2The carousel heading.buttonThe 2 arrows, "Previous cards" and "Next cards", hidden withhiddenuntil the script has run.ulThe scrolling area, withscroll-snap-type.liA card, which snaps to the left edge of the area.aThe card link, the one Tab reaches.
HTML code
<section class="carousel" aria-labelledby="carousel-title">
<h2 id="carousel-title">Destinations</h2>
<!-- Arrows: hidden until the script has run -->
<div class="carousel__arrows" hidden>
<button
type="button"
data-direction="-1"
aria-controls="carousel-track"
>
<span aria-hidden="true">←</span>
<span class="visually-hidden">Previous cards</span>
</button>
<button
type="button"
data-direction="1"
aria-controls="carousel-track"
>
<span aria-hidden="true">→</span>
<span class="visually-hidden">Next cards</span>
</button>
</div>
<!-- Scrolling list: one card = one list item -->
<ul class="carousel__track" id="carousel-track">
<li>
<h3><a href="https://en.wikipedia.org/wiki/Lisbon">Lisbon</a></h3>
<p>…</p>
</li>
<li>…</li>
</ul>
</section>
<script>
// Arrows: scroll by one view, without moving focus
const track = document.querySelector('.carousel__track');
const arrows = document.querySelector('.carousel__arrows');
const [prev, next] = arrows.querySelectorAll('button');
const update = () => {
const atStart = track.scrollLeft < 1;
const atEnd = track.scrollLeft + track.clientWidth > track.scrollWidth - 1;
prev.setAttribute('aria-disabled', atStart);
next.setAttribute('aria-disabled', atEnd);
};
arrows.addEventListener('click', (e) => {
const button = e.target.closest('button');
if (button && button.getAttribute('aria-disabled') !== 'true') {
track.scrollBy({ left: button.dataset.direction * track.clientWidth });
}
});
track.addEventListener('scroll', update);
window.addEventListener('resize', update);
arrows.hidden = false;
update();
</script>/* The carousel measures its own width (container query),
not the width of the screen */
.carousel {
container-type: inline-size;
}
.carousel__track {
--visible: 1;
--peek: 32px;
--gap: 12px;
display: flex;
gap: var(--gap);
/* The next card sticks out into the margin */
padding-inline: var(--peek);
scroll-padding-inline: var(--peek);
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.carousel__track li {
flex: 0 0 calc((100% - (var(--visible) - 1) * var(--gap)) / var(--visible));
scroll-snap-align: start;
}
@container (min-width: 480px) {
.carousel__track {
--visible: 2;
--peek: 36px;
}
}
@container (min-width: 720px) {
.carousel__track {
--visible: 3;
}
}
@container (min-width: 960px) {
.carousel__track {
--visible: 4;
--peek: 48px;
}
}
/* With the arrows, the scrollbar is no longer needed */
.carousel:has(.carousel__arrows:not([hidden])) .carousel__track {
scrollbar-width: none;
}
.carousel__arrows button[aria-disabled="true"] {
opacity: 0.55;
cursor: default;
}
@media (prefers-reduced-motion: no-preference) {
.carousel__track {
scroll-behavior: smooth;
}
}Usage
Use it for
- A series of items of the same kind, browsed in order.
- Cards that each lead to a page.
Avoid it for
- Autoplay. With no way to stop it and start it again, it fails RGAA criterion 13.8.
- Hiding the scrollbar without arrows. With a mouse and no trackpad, nothing visible scrolls the list anymore.
- Disabling an arrow with
disabled. At the end of the list, the button loses focus, which falls back to the pagebody. - Important information that only shows up when you scroll.
Accessibility
ARIA roles and attributes
3 ARIA attributes: aria-labelledby names the region, aria-disabled flags an arrow at the end of the list, aria-hidden silences the arrow character. No aria-roledescription="carousel": on entry, the screen reader already announces "list, 7 items", which says how to move through it.
| Attribute | Element | Description |
|---|---|---|
aria-labelledby="carousel-title" | section | The region takes the name of its heading, "Destinations", and shows up in the list of landmarks. |
hidden | Arrows div | Removed by the script on load. Without JavaScript, no arrows that would not work, and the scrollbar stays visible. |
class="visually-hidden" | span in each button | Names the arrows "Previous cards" and "Next cards" without showing the text. |
aria-hidden="true" | Arrow span | The "←" or "→" character is not read: the button name is enough. |
aria-controls="carousel-track" | button | Ties each arrow to the list it scrolls, through the list id. |
aria-disabled="true" | button | At the end of the list, the arrow stays focusable and the screen reader announces it as "dimmed". |
list role (implicit) | ul | The screen reader announces the number of cards on entry: "list, 7 items". |
Keyboard navigation
| Key | Action |
|---|---|
| Tab | Moves from the arrows to the link of the 1st card, then from card to card, and the list scrolls to show the link |
| Shift+Tab | Goes back to the link of the previous card |
| Enter or Space on an arrow | Scrolls the list by one view, without moving focus |
| Left arrow or Right arrow on a card link | Scrolls the list by one card, without moving focus. The link can then go out of view |
Tab order
- The "Previous cards" arrow, dimmed at first.
- The "Next cards" arrow.
- The "Lisbon" link.
- The "Kyoto" link.
- The "Montreal" link.
- The "Edinburgh" link, then the links of the 3 next cards, out of view at first: the list scrolls when a link gets focus.
With a screen reader
Example in Chrome with VoiceOver in English. The wording changes from one screen reader to another.
| Action | Announcement |
|---|---|
| Entering the region | "Destinations, region" |
| Tab to the 1st arrow | "Previous cards, dimmed, button" |
| Tab, then Enter | "Next cards, button"; the scrolling is not announced, the list stays readable item by item |
| Entering the list | "list, 7 items" |
Related RGAA criteria
The RGAA is the French accessibility standard. Its 106 criteria test WCAG 2.1 level AA.
- Criterion 7.1: Is each script, where needed, compatible with assistive technologies?
- The arrows are real buttons: the screen reader gets their name, their role and the
aria-disabledstate. - Criterion 7.3: Can each script be controlled with the keyboard and with any pointing device (except in specific cases)?
- The arrows work from the keyboard, with Enter or Space, as with a mouse.
- Criterion 9.3: On each web page, is each list correctly structured?
- The cards form a real list,
ulandli. - Criterion 10.7: On each web page, for each element that receives focus, is the focus visible?
- Each link that gets focus with Tab is brought into the visible area, where its focus shows.
- Criterion 12.8: On each web page, is the tab order consistent?
- The tab order follows the visual order: the arrows, then the cards.
- Criterion 13.8: On each web page, can the user control each moving or blinking content?
- Nothing moves without a user action.
Other references
WCAG 2.2
EN 301 549
- Accessibility requirements for ICT products and services (V4.1.1, PDF)Clauses 9.1.3.1, 9.2.1.1, 9.2.2.2, 9.2.4.3, 9.2.4.7, 9.4.1.2
Beyond the APG
| New feature | What it adds | Browsers | In the demo |
|---|---|---|---|
::scroll-button() | Generates the previous and next arrows in CSS, named by the alternative text of content. | Chrome 135+, Edge 135+ | Experimental variation |
::scroll-marker | Generates in CSS one navigation dot per card, tied to its card. | Chrome 135+, Edge 135+ | No |
Variations
Arrows generated in CSS, Experimental
::scroll-button() creates the arrows with no JavaScript, named by the alternative text of content. Only Chrome and Edge generate them, since April 2025: elsewhere, keep the scrollbar. At the end of the list, the browser disables them and focus falls back to the page, as with disabled: in production, keep the arrows from the demo instead.
Destinations, arrows in CSS
Lisbon
Seven hills, yellow trams and tiled façades.
Kyoto
More than 1,600 Buddhist temples and moss gardens.
Montreal
A bilingual city built around Mount Royal.
Edinburgh
A castle on an extinct volcano, above the Old Town.
Marrakesh
The souks of the medina and Jemaa el-Fnaa square.
Reykjavík
The world's northernmost capital of a sovereign state.
Oaxaca
Colorful markets and a cuisine known for its seven moles.
The arrows only show up in Chrome and Edge. Elsewhere, the scrollbar stays. Go to the end of the list with Enter: focus leaves the arrow.
/* At the end of the list, the browser disables the arrow:
focus falls back to the page */
.carousel__track::scroll-button(left) {
content: "←" / "Previous cards";
}
.carousel__track::scroll-button(right) {
content: "→" / "Next cards";
}One card per view
flex: 0 0 100% shows a single card, and padding-inline: 0 removes the margin where the next one stuck out. The arrows then become the only visible clue that there is more.
Destinations, one card per view
Lisbon
Seven hills, yellow trams and tiled façades.
Kyoto
More than 1,600 Buddhist temples and moss gardens.
Montreal
A bilingual city built around Mount Royal.
Edinburgh
A castle on an extinct volcano, above the Old Town.
Marrakesh
The souks of the medina and Jemaa el-Fnaa square.
Reykjavík
The world's northernmost capital of a sovereign state.
Oaxaca
Colorful markets and a cuisine known for its seven moles.
/* With no margin, the next card no longer sticks out */
.carousel__track {
padding-inline: 0;
scroll-padding-inline: 0;
}
.carousel__track li {
flex: 0 0 100%;
}Grid on large screens
With a container query, the same list turns into a grid when the carousel is wide enough. Every card shows, and the arrows disappear since nothing scrolls anymore.
Destinations, as a grid
Lisbon
Seven hills, yellow trams and tiled façades.
Kyoto
More than 1,600 Buddhist temples and moss gardens.
Montreal
A bilingual city built around Mount Royal.
Edinburgh
A castle on an extinct volcano, above the Old Town.
Marrakesh
The souks of the medina and Jemaa el-Fnaa square.
Reykjavík
The world's northernmost capital of a sovereign state.
Oaxaca
Colorful markets and a cuisine known for its seven moles.
@container (min-width: 520px) {
.carousel__track {
display: grid;
grid-template-columns: repeat(2, 1fr);
overflow: visible;
}
/* Nothing scrolls anymore: the arrows disappear */
.carousel__arrows {
display: none;
}
}