Skip to content
Contact me

All building blocks

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

  1. section The region, named by its heading.
  2. h2 The carousel heading.
  3. button The 2 arrows, "Previous cards" and "Next cards", hidden with hidden until the script has run.
  4. ul The scrolling area, with scroll-snap-type.
  5. li A card, which snaps to the left edge of the area.
  6. a The card link, the one Tab reaches.

HTML code

HTML
<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>
CSS
/* 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 page body.
  • 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.

AttributeElementDescription
aria-labelledby="carousel-title"sectionThe region takes the name of its heading, "Destinations", and shows up in the list of landmarks.
hiddenArrows divRemoved by the script on load. Without JavaScript, no arrows that would not work, and the scrollbar stays visible.
class="visually-hidden"span in each buttonNames the arrows "Previous cards" and "Next cards" without showing the text.
aria-hidden="true"Arrow spanThe "←" or "→" character is not read: the button name is enough.
aria-controls="carousel-track"buttonTies each arrow to the list it scrolls, through the list id.
aria-disabled="true"buttonAt the end of the list, the arrow stays focusable and the screen reader announces it as "dimmed".
list role (implicit)ulThe screen reader announces the number of cards on entry: "list, 7 items".

Keyboard navigation

KeyAction
TabMoves from the arrows to the link of the 1st card, then from card to card, and the list scrolls to show the link
Shift+TabGoes back to the link of the previous card
Enter or Space on an arrowScrolls the list by one view, without moving focus
Left arrow or Right arrow on a card linkScrolls the list by one card, without moving focus. The link can then go out of view

Tab order

  1. The "Previous cards" arrow, dimmed at first.
  2. The "Next cards" arrow.
  3. The "Lisbon" link.
  4. The "Kyoto" link.
  5. The "Montreal" link.
  6. 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.

ActionAnnouncement
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-disabled state.
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, ul and li.
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

Beyond the APG

New featureWhat it addsBrowsersIn 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-markerGenerates 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.

Code
/* 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.

Code
/* 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.

Code
@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;
  }
}