Skip to content

Component

carousel

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and carousel.css. No scripts.

Source

Copy both files, or run npx nojsui add carousel.

Source for carousel
<!-- .sk-carousel — a scroll-snap carousel. The scrolling, the snapping, the
     keyboard and the touch gesture are all the scroll container's own.

     The list stays a list. Its items are the content; the previous/next
     buttons and the dots are generated by CSS in engines that support
     ::scroll-button() and ::scroll-marker, and are simply absent elsewhere —
     where the carousel is still complete, because scrolling a scroll container
     never depended on them.

     tabindex="0" is the one thing markup has to supply: a scroll container is
     only keyboard-scrollable if it can take focus, and no engine does that for
     an element whose children are not themselves focusable. -->
<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">
          Light dismiss, Esc and focus return, from the popover attribute.
        </p>
      </article>
    </li>

    <li class="sk-carousel__slide">
      <article class="sk-carousel__card">
        <h3 class="sk-carousel__title">Dialog</h3>
        <p class="sk-carousel__text">
          Modality and focus containment, from the dialog element.
        </p>
      </article>
    </li>

    <li class="sk-carousel__slide">
      <article class="sk-carousel__card">
        <h3 class="sk-carousel__title">Tabs</h3>
        <p class="sk-carousel__text">
          One tab stop and arrow-key selection, from a radio group.
        </p>
      </article>
    </li>

    <li class="sk-carousel__slide">
      <article class="sk-carousel__card">
        <h3 class="sk-carousel__title">Accordion</h3>
        <p class="sk-carousel__text">
          Exclusive open state, from the name attribute on details.
        </p>
      </article>
    </li>

    <li class="sk-carousel__slide">
      <article class="sk-carousel__card">
        <h3 class="sk-carousel__title">Toast</h3>
        <p class="sk-carousel__text">
          Announced politely, then removed on a timer, with no script.
        </p>
      </article>
    </li>
  </ul>
</div>
/* .sk-carousel — a scroll-snap carousel, with generated controls where the
   engine has them.

   The base is a scroll container with scroll-snap, which is Baseline widely
   available: it swipes, it scrolls, it snaps, and it takes arrow keys once the
   container is focusable. That is a whole carousel, and it is what every engine
   gets.

   The enhancement is that CSS can now generate the controls: ::scroll-button()
   for previous/next and ::scroll-marker for the dots, with the browser owning
   their disabled state, their current-item state and their keyboard behaviour.
   Verified 2026-08-20 against the engines this kit tests: Chromium 151 has all
   of it, WebKit 26.5 and Firefox 153 have none of it. So the whole control
   layer sits inside @supports and its absence is the documented Tier B/C
   experience — no dots, no arrows, same content, same scrolling.

   Specs:
   - scroll-snap ............ https://drafts.csswg.org/css-scroll-snap-1/
   - ::scroll-button() ...... https://drafts.csswg.org/css-overflow-5/#scroll-buttons
   - ::scroll-marker ........ https://drafts.csswg.org/css-overflow-5/#scroll-markers
   - scroll-marker-group .... https://drafts.csswg.org/css-overflow-5/#scroll-marker-group-property
   - overscroll-behavior .... https://drafts.csswg.org/css-overscroll-1/ */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. */
.sk-carousel {
  --_gap: var(--sk-carousel-gap, var(--sk-space-md));
  --_slide-inline-size: var(--sk-carousel-slide-inline-size, 18rem);
  --_card-bg: var(--sk-carousel-card-bg, var(--sk-color-surface));
  --_card-fg: var(--sk-carousel-card-fg, var(--sk-color-text));
  --_card-border: var(--sk-carousel-card-border-color, var(--sk-color-border));
  --_card-radius: var(--sk-carousel-card-radius, var(--sk-radius-lg));
  --_text-fg: var(--sk-carousel-text-fg, var(--sk-color-text-muted));
  --_button-bg: var(--sk-carousel-button-bg, var(--sk-color-surface-raised));
  --_button-fg: var(--sk-carousel-button-fg, var(--sk-color-text));
  --_button-border: var(--sk-carousel-button-border-color, var(--sk-color-border-strong));
  --_marker: var(--sk-carousel-marker-color, var(--sk-color-border-strong));
  --_marker-current: var(--sk-carousel-marker-color-current, var(--sk-color-accent));
  --_marker-size: var(--sk-carousel-marker-size, var(--sk-space-xs));

  /* Wide enough for the button itself, so the gutter is a gutter and not a
     44px control squeezed into 40px of space. */
  --_button-gutter: var(
    --sk-carousel-button-gutter,
    calc(var(--sk-size-tap-target) + var(--sk-space-2xs))
  );

  /* The generated buttons are positioned against this, not against the
     scroller — a box inside the scroller would scroll away with the slides. */
  position: relative;
}

.sk-carousel__track {
  display: flex;
  gap: var(--_gap);
  margin: 0;
  padding: 0;
  list-style: none;

  /* overflow-inline is the logical property and every engine this kit targets
     has it, but it is not Baseline widely available yet, so the physical
     fallback is stated first. In a horizontal writing mode the two are the
     same declaration; only a vertical mode tells them apart. */
  overflow-x: auto;
  scroll-snap-type: inline mandatory;

  /* A swipe that runs off the last slide should stop there rather than scroll
     the page behind it. */
  overscroll-behavior-inline: contain;
}

@supports (overflow-inline: auto) {
  .sk-carousel__track {
    overflow-inline: auto;
  }
}

.sk-carousel__slide {
  flex: 0 0 auto;
  inline-size: min(var(--_slide-inline-size), 100%);
  scroll-snap-align: center;
}

.sk-carousel__card {
  block-size: 100%;
  padding: var(--sk-space-md);
  border: var(--sk-border-width) solid var(--_card-border);
  border-radius: var(--_card-radius);
  background: var(--_card-bg);
  color: var(--_card-fg);
}

.sk-carousel__title {
  margin-block-end: var(--sk-space-2xs);
  font-size: var(--sk-text-md);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-carousel__text {
  margin: 0;
  color: var(--_text-fg);
  font-size: var(--sk-text-sm);
}

/* Smooth scrolling is the only motion here, and it is what the generated
   buttons and dots use to move between slides. Under `reduce` the jump is
   instant, which is the same destination without the travel. */
@media (prefers-reduced-motion: no-preference) {
  .sk-carousel__track {
    scroll-behavior: smooth;
  }
}

/* ---------------------------------------------------------------------------
   Generated controls

   Everything below is enhancement. Without it there are no arrows and no dots,
   and the carousel still scrolls, snaps and takes the keyboard.

   The value of letting CSS generate these rather than writing markup for them
   is that the browser owns their state: a ::scroll-button() is disabled at the
   end of its axis on its own, and the ::scroll-marker for the visible slide
   matches :target-current without anything having to track it.
--------------------------------------------------------------------------- */

@supports selector(::scroll-button(inline-start)) {
  /* The gutter the arrows sit in, so they never cover a slide — an arrow laid
     over the text it is meant to reveal is a worse control than no arrow.
     Reserved here rather than on the base, because an engine with no arrows
     should not pay for the space they would have used. */
  .sk-carousel {
    padding-inline: var(--_button-gutter);
  }

  .sk-carousel__track::scroll-button(inline-start),
  .sk-carousel__track::scroll-button(inline-end) {
    position: absolute;
    inset-block-start: 50%;
    translate: 0 -50%;
    z-index: 1;
    inline-size: var(--sk-size-tap-target);
    block-size: var(--sk-size-tap-target);
    border: var(--sk-border-width) solid var(--_button-border);
    border-radius: var(--sk-radius-full);
    background: var(--_button-bg);
    color: var(--_button-fg);
    font-size: var(--sk-text-lg);
    line-height: 1;
    cursor: pointer;
  }

  /* The glyphs are content, and content has a direction — these are the
     logical keywords, so they swap themselves in a right-to-left document. */
  .sk-carousel__track::scroll-button(inline-start) {
    content: "‹" / "Previous";
    inset-inline-start: 0;
  }

  .sk-carousel__track::scroll-button(inline-end) {
    content: "›" / "Next";
    inset-inline-end: 0;
  }

  /* The engine disables the button at the end of its axis. Fading it rather
     than hiding it keeps the control from moving as you scroll. */
  .sk-carousel__track::scroll-button(inline-start):disabled,
  .sk-carousel__track::scroll-button(inline-end):disabled {
    opacity: 0.35;
    cursor: default;
  }
}

@supports (scroll-marker-group: after) and selector(::scroll-marker) {
  .sk-carousel__track {
    scroll-marker-group: after;

    /* The group is generated as a child of the scroller but is laid out
       outside the scrollport, so it needs the gap reserved here. */
    padding-block-end: var(--sk-space-sm);
  }

  .sk-carousel__track::scroll-marker-group {
    display: flex;
    justify-content: center;
    gap: var(--sk-space-2xs);
    padding-block-start: var(--sk-space-sm);
  }

  .sk-carousel__slide::scroll-marker {
    content: "";
    inline-size: var(--_marker-size);
    block-size: var(--_marker-size);
    border: var(--sk-border-width) solid var(--_marker);
    border-radius: var(--sk-radius-full);
    background: none;
    cursor: pointer;
  }

  /* :target-current is the marker for the slide currently in view. The browser
     decides which one that is; nothing here tracks scroll position. */
  .sk-carousel__slide::scroll-marker:target-current {
    border-color: var(--_marker-current);
    background: var(--_marker-current);
  }

  /* The marker group is laid out inside the scroller's box, so it counts
     toward the 50% the buttons centre on and would drag them below the
     slides. Take back half of what the dots occupy. Scoped to this block
     because it is only true when there are dots. */
  .sk-carousel__track::scroll-button(inline-start),
  .sk-carousel__track::scroll-button(inline-end) {
    inset-block-start: calc(50% - (var(--sk-space-sm) + var(--_marker-size)) / 2);
  }
}

/* Under forced colours the dots must not be an invisible ring on Canvas, and
   the current one has to stay distinguishable from the rest. */
@media (forced-colors: active) {
  @supports selector(::scroll-marker) {
    .sk-carousel__slide::scroll-marker {
      border-color: CanvasText;
    }

    .sk-carousel__slide::scroll-marker:target-current {
      background: Highlight;
      border-color: Highlight;
    }
  }
}

Browser support

Baseline widely available Experimental — behind an origin trial or flag

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Scroll snapwidely69796811696811
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
::scroll-button4 of its partslimited135135135
Scroll markers4 of its partslimited135135135
scroll-behaviorwidely61793615.4613615.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-label on 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).

PropertyDefaultControls
--sk-carousel-gap--sk-space-mdSpace between slides
--sk-carousel-slide-inline-size18remSlide width, capped at the track’s own width
--sk-carousel-card-bg--sk-color-surfaceCard background
--sk-carousel-card-fg--sk-color-textCard text
--sk-carousel-card-border-color--sk-color-borderCard border
--sk-carousel-card-radius--sk-radius-lgCard corner radius
--sk-carousel-text-fg--sk-color-text-mutedCard prose
--sk-carousel-button-bg--sk-color-surface-raisedArrow background
--sk-carousel-button-fg--sk-color-textArrow glyph
--sk-carousel-button-border-color--sk-color-border-strongArrow border
--sk-carousel-button-guttercalc(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-strongDot outline
--sk-carousel-marker-color-current--sk-color-accentThe dot for the slide in view
--sk-carousel-marker-size--sk-space-xsDot 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.

KeyBehaviour
TabThree stops where the arrows exist: previous arrow, next arrow, then the track. One stop otherwise: the track
Enter / Space on an arrowScrolls one page toward that end, landing on a snap point
/ with the track focusedMoves 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 DownNothing, 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 usedBaseline statusBehavior without it
scroll-snapwidelyThe floor. Absent, the track is a horizontally scrolling list — the content is all still reachable, it just does not snap
::scroll-button()limitedNo arrows. WebKit 26.5 and Firefox 153 today. Scrolling, snapping and the keyboard are unchanged
::scroll-markerlimitedNo dots, and no reserved space for them. Same engines
scroll-behaviorwidelyMovement 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.