Carousel

Slides on a scroll-snapping track with dots that link to each one, scrolled by the browser, with an optional module so following a dot costs no history entry.

Example

View Code
<section class="carousel" aria-roledescription="carousel" aria-label="Featured work">
    <div data-track role="group" aria-label="Slides" tabindex="0">
        <article id="work-1" data-slide><h3>A trail map</h3><p>Printed in two colours.</p></article>
        <article id="work-2" data-slide><h3>A field guide</h3><p>Three hundred pages.</p></article>
        <article id="work-3" data-slide><h3>A season of posters</h3><p>Twelve of them.</p></article>
    </div>
    <ol data-dots role="list">
        <li><a href="#work-1" aria-label="Slide 1"></a></li>
        <li><a href="#work-2" aria-label="Slide 2"></a></li>
        <li><a href="#work-3" aria-label="Slide 3"></a></li>
    </ol>
</section>

When to use it

A set of things of equal weight that would take too much room laid out at once: work samples, quotes, photographs. Not for anything a reader must see, since a slide out of view is a slide most people never scroll to.

How it works

The track is a row that scrolls and snaps, so dragging, swiping, and a trackpad all work with nothing added. The dots underneath are ordinary links to each slide's id, which means the browser scrolls to a slide when one is followed, the keyboard reaches them by Tab, and the CSS needs no script. The one module, described below, is optional.

The scrollbar under the track is hidden, since the dots already say where you are and the bar is noise beneath a track that snaps. Scrolling itself is untouched: dragging, swiping, the trackpad and the arrow keys all still work.

data-slides sets how many slides are visible at once, and each slide takes an equal share of the track less the gaps. Smooth scrolling comes from a token, --yeti-carousel-scroll, rather than from the reset, because a components-layer declaration would otherwise outrank it; the token itself collapses to auto under prefers-reduced-motion, so the preference still reaches the track. The track itself carries tabindex="0" and a name, because a region that scrolls has to be reachable from the keyboard; once it has focus, the arrow keys scroll it.

<section class="carousel" data-slides="2" aria-roledescription="carousel" aria-label="Quotes">
    <div data-track role="group" aria-label="Slides" tabindex="0">
        <blockquote id="quote-1" data-slide><p>It just worked.</p></blockquote>
        <blockquote id="quote-2" data-slide><p>No build step.</p></blockquote>
        <blockquote id="quote-3" data-slide><p>Nothing to learn.</p></blockquote>
    </div>
    <ol data-dots role="list">
        <li><a href="#quote-1" aria-label="Quote 1"></a></li>
        <li><a href="#quote-2" aria-label="Quote 2"></a></li>
        <li><a href="#quote-3" aria-label="Quote 3"></a></li>
    </ol>
</section>

Accessibility

Label the region and mark it with aria-roledescription="carousel", so it is announced as a carousel rather than as an anonymous group. Every dot is a link with an aria-label, because a dot has no room for text and there is no visually-hidden utility in Yeti.

The dots take you to a slide; they do not tell you which slide you are on. CSS cannot know that, and the module deliberately does not track it either: watching scroll position to light up a dot is a different and much larger job. If that matters for what you are building, the honest answer is a list rather than a carousel.

Following a dot is a navigation to a fragment, and every navigation adds an entry to the browser's history. A reader who looked at four slides would then need four presses of back to leave the page, which is why carousel.js exists: it takes the click and scrolls the track itself, so the dots cost no history and the URL is left alone. Load it with <script type="module" src="…/js/carousel.js">. The module is optional and the dots are ordinary links without it, so a page that never loads it still works, at the price of those history entries. Modified clicks, the ones that open a new tab, are left to the browser either way.

Browser-drawn scroll markers will one day do this in CSS and report the current slide as well. Today they are in one engine, so the module is the honest answer.

Attributes

Attribute Type Values Default Description
data-slides enum 1, 2, 3, 4 1 How many slides are visible at once.
data-gap enum none, xs, sm, md, lg, xl, 2xl, 3xl, xs-sm, xs-md, xs-lg, xs-xl, xs-2xl, xs-3xl, sm-md, sm-lg, sm-xl, sm-2xl, sm-3xl, md-lg, md-xl, md-2xl, md-3xl, lg-xl, lg-2xl, lg-3xl, xl-2xl, xl-3xl, 2xl-3xl md Space between slides, and above the dots.

Markers

Attributes that descendants carry, not the root.

Attribute Type Values On Description
data-track boolean > * The scrolling track that holds the slides.
data-slide boolean > [data-track] > * One slide, with an id a dot can link to.
data-dots boolean > * The list of links to the slides, one per slide.

Children

  • > [data-track]: exactly 1. The scrolling track holding the slides. A scrolling region is an interactive one, so give it role="group", a name, and tabindex="0".
  • [data-slide]: at least 2. One slide each, with an id a dot can link to.
  • > [data-dots]: 0 to 1. A list of links, one per slide, each named with aria-label. With carousel.js loaded the module scrolls the track itself, so following one adds no history entry.

Tokens

Token Description
--yeti-carousel-dot Colour of a dot.
--yeti-carousel-dot-size Diameter of a dot.
--yeti-control-size The pressable area around a dot.
--yeti-space-md The default gap between slides.
--yeti-carousel-scroll How the track scrolls when a dot is followed.
--yeti-space-xs Gap between the dots.
--yeti-duration-fast How long a dot takes to grow under the pointer.
--yeti-ease The curve of that transition.
Internal tokens (may change between minor versions) - `--_yeti-slides` - `--_yeti-gap`

Accessibility

  • Required attributes: aria-label or aria-labelledby
  • Label the region and give it aria-roledescription="carousel" so it is announced as one. The dots are links to slide ids, so the browser does the scrolling and the keyboard works without help; each needs an aria-label, since a dot has no room for text and Yeti ships no visually-hidden utility. They jump to a slide but cannot report which slide is showing, because CSS cannot know that. Loading carousel.js keeps the back button useful by scrolling the track instead of navigating; without it the dots still work, and each one followed is one press of back. Nothing essential should live behind a slide a reader has to find. The track scrolls, so it must be reachable from the keyboard: give it role="group", an aria-label, and tabindex="0". It is a group rather than a region because the carousel itself is already the landmark.
Key Action
Tab Focuses the track, then each dot in turn.
ArrowLeft / ArrowRight Scroll the track.
Enter Scrolls to that dot's slide.

Browser support

  • Used without guards: scroll snap, scroll-behavior, scrollbar-width
  • Behind @supports: nothing

JavaScript

Optional enhancement: components/carousel/carousel.js. The component works without it.

Available since 7.0.0.