v1.0.0

<bmx-carousel>

A strip of slides the reader moves through.

12 properties · 2 events · 5 methods · 8 parts

Example

Northwind rebrand
Contoso annual report
Fabrikam launch
Try it without the mouse. Tab into the strip once, then use the arrows — Home and End jump to the ends. Then Tab again: the focus goes to the dots and not through the slides you cannot see. A carousel of six slides each holding a link normally costs a keyboard user six invisible tab stops; everything off screen here is inert.
It is a scroll-snap container, not a transform. So the flick physics on a phone are the ones that phone already has — momentum, the rubber-band at the ends, and what happens when a drag is interrupted — and none of it is imitated here. The cost is that the component cannot know where the strip is and has to ask: every decision it makes comes from a measured scroll offset rather than from an index it keeps.
One
Two
Three
Four
Five
Six
per-view is the only sizing property; the slides take an equal share of the viewport, so the layout is one number rather than a media query per breakpoint. Set it from your own breakpoints when you want a different count on a phone.
Autoplay never runs under prefers-reduced-motion, and no property overrides that: it is a statement about what makes somebody unwell rather than a preference about animation. It also stops while a pointer is over the carousel, while the focus is inside it, while the tab is in the background, and whenever the reader presses pause — and each of those is reported by name on bmxAutoplayChange, so your page can say why it is still. While the clock is running the live region is off; the moment the reader takes control it becomes polite.
Show markup
<bmx-carousel label="Featured work" per-view="1" slide-labels="Northwind rebrand,Contoso annual report,Fabrikam launch">
  <figure class="ex-slide">
    <div class="ex-art" style="background: linear-gradient(135deg, #3b5bdb, #0b7285)"></div>
    <figcaption>Northwind rebrand</figcaption>
  </figure>
  <figure class="ex-slide">
    <div class="ex-art" style="background: linear-gradient(135deg, #2b8a3e, #0c6b58)"></div>
    <figcaption>Contoso annual report</figcaption>
  </figure>
  <figure class="ex-slide">
    <div class="ex-art" style="background: linear-gradient(135deg, #a9450b, #862e9c)"></div>
    <figcaption>Fabrikam launch</figcaption>
  </figure>
</bmx-carousel>

<style>
  .ex-slide { margin: 0; }
  .ex-art { block-size: 11rem; border-radius: 0.5rem; }
  .ex-slide figcaption { margin-block-start: 0.5rem; font-size: 0.875rem; color: #5b6472; }
</style>

<div class="row" style="margin-block-start: 1.5rem">
  <span class="note">
    <strong>Try it without the mouse.</strong> Tab into the strip once, then use the arrows &mdash;
    <kbd>Home</kbd> and <kbd>End</kbd> jump to the ends. Then Tab again: the focus goes to the dots and
    <em>not</em> through the slides you cannot see. A carousel of six slides each holding a link normally costs a
    keyboard user six invisible tab stops; everything off screen here is <code>inert</code>.
  </span>
</div>

<div class="row" style="margin-block-start: 1rem">
  <span class="note">
    It is a <code>scroll-snap</code> container, not a transform. So the flick physics on a phone are the ones that
    phone already has &mdash; momentum, the rubber-band at the ends, and what happens when a drag is interrupted &mdash;
    and none of it is imitated here. The cost is that the component cannot know where the strip is and has to ask:
    every decision it makes comes from a measured scroll offset rather than from an index it keeps.
  </span>
</div>

<div class="row" style="margin-block-start: 1.5rem">
  <bmx-carousel label="Three at a time" per-view="3" hide-indicator>
    <div class="ex-card">One</div>
    <div class="ex-card">Two</div>
    <div class="ex-card">Three</div>
    <div class="ex-card">Four</div>
    <div class="ex-card">Five</div>
    <div class="ex-card">Six</div>
  </bmx-carousel>
</div>

<style>
  .ex-card {
    display: flex;
    align-items: center;
    justify-content: center;
    block-size: 6rem;
    border: 1px solid #d5dae2;
    border-radius: 0.5rem;
    background: #f7f9fb;
    font-weight: 600;
  }
</style>

<div class="row" style="margin-block-start: 1rem">
  <span class="note">
    <code>per-view</code> is the only sizing property; the slides take an equal share of the viewport, so the layout
    is one number rather than a media query per breakpoint. Set it from your own breakpoints when you want a different
    count on a phone.
  </span>
</div>

<div class="row" style="margin-block-start: 1rem">
  <span class="note">
    <strong>Autoplay never runs under <code>prefers-reduced-motion</code></strong>, and no property overrides that:
    it is a statement about what makes somebody unwell rather than a preference about animation. It also stops while a
    pointer is over the carousel, while the focus is inside it, while the tab is in the background, and whenever the
    reader presses pause &mdash; and each of those is reported by name on <code>bmxAutoplayChange</code>, so your page
    can say <em>why</em> it is still. While the clock is running the live region is <code>off</code>; the moment the
    reader takes control it becomes <code>polite</code>.
  </span>
</div>
<bmx-carousel label="Featured work">
  <figure>…</figure>
  <figure>…</figure>
</bmx-carousel>

BUILT ON THE PLATFORM'S SCROLLER, NOT ON A TRANSFORM

The usual carousel owns a strip and moves it with a transform, and then has to reimplement everything the reader's device already does: momentum, rubber-banding at the ends, the feel of a flick, and - the one that is never quite right - what happens when a drag is interrupted. This one is a scroll-snap container with the slides slotted into it, so all of that is the browser's, natively, on the hardware it is running on.

The cost is that the component no longer knows where the strip is; it has to ask. Every decision is therefore made from a measured scroll offset rather than from an index this component keeps - see src/core/carousel.ts. A component that kept its own index beside a scroll container would have two sources of truth, and they disagree the first time somebody drags.

A GROUP BY DEFAULT, TABS ONLY WHEN THEY ARE REALLY TABS

The host is a group with aria-roledescription="carousel" and the dots are an indicator: they say where you are and let you jump, and they claim nothing more. That is what almost every carousel is, and it is what the WAI pattern describes.

tabs turns the picker into a real tablist, for the case where each slide is a named destination the reader is choosing between rather than a position in a sequence. It is opt-in because the wrong one of these is a lie to a screen reader: announcing a set of photographs as tabs tells somebody they are expected to choose, when the honest answer is that there are six pictures and they are welcome to look at them.

In tabs mode the tab and its slide cannot be joined by an id. The tabs are drawn in this shadow root and the slides are the author's own light-DOM children, and an ID reference does not cross a node tree - defect 56, and the same constraint that shaped bmx-tabs. The association is carried by element reflection where the browser has it and left off where it does not, which loses a relationship and no content. The slide's own name is set directly, so it is never the thing that goes missing.

WHAT AUTOPLAY IS NOT ALLOWED TO DO

Under prefers-reduced-motion it does not run, and no property overrides that. It also stops while a pointer is over the carousel, while the focus is inside it, while the tab is in the background, and whenever the reader presses pause - each reported by name, so the component can say why it is still. And while the clock is running the live region is off: a carousel that announces a slide every five seconds interrupts the rest of the page forever, for something nobody asked to happen. The moment the reader takes control it becomes polite. The policy is autoplayState and announcement, both pure.

A SLIDE OUT OF VIEW IS STILL IN THE TAB ORDER

Which is the defect this pattern ships with almost everywhere: a carousel of six slides, each holding a link, costs a keyboard user six invisible tab stops. Everything not on screen is inert.

Properties

PropertyAttributeTypeDefaultDescription
autoplay autoplay boolean false Move on its own. Never under prefers-reduced-motion - see above.
hideControls hide-controls boolean false Hide the previous and next controls, when the page provides its own.
hideIndicator hide-indicator boolean false Hide the dots or tablist.
index index number 0 The slide showing now, counting from 0. Assignable.
interval interval number 5000 How long each slide is shown, in milliseconds.
label label string What this carousel is. A carousel with no name is announced as "carousel" and nothing else, which tells a reader that something is here and not what.
loop loop boolean false Whether the end wraps round to the beginning. Off by default: a carousel that silently returns to the first slide is one a reader cannot tell they have finished, and "have I seen all of these?" is the question this pattern is worst at answering.
orientation orientation BmxOrientation 'horizontal' Which way it travels.
perView per-view number 1 How many slides are on screen at once.
slideLabels slide-labels string[] | string [] A name for each slide. Used for the tab labels in tabs mode and for each slide's own accessible name in either. Without them a slide is announced as "3 of 12", which is a position rather than a description - true, and less than the author knows. Read through src/core/markup.ts, so a comma-separated or JSON attribute from a server-rendered template is the list it looks like.
step step number How many slides one press of previous or next moves. Defaults to a full view.
tabs tabs boolean false Draw the picker as a real tablist rather than as an indicator. Only when each slide genuinely is a named destination. See the note above: the wrong one of these is a lie to a screen reader.

Events

EventDetailDescription
bmxAutoplayChange BmxCarouselAutoplayDetail The clock started or stopped, and why.
bmxChange BmxCarouselChangeDetail The slide changed.

Methods

MethodSignatureDescription
goTo goTo(index: number) => Promise<void> Go to a slide by its index.
next next() => Promise<void> Move to the next slide.
pause pause() => Promise<void> Stop the clock until play() or the pause control says otherwise.
play play() => Promise<void> Start the clock, if anything else will let it run.
previous previous() => Promise<void> Move to the previous slide.

Slots

SlotDescription
(default) The slides. Anything at all; this component has no opinion about them.

CSS shadow parts

PartDescription
dot One dot.
frame
indicator The dots, or the tablist in tabs mode.
next The control that steps forward.
pause The autoplay control.
previous The control that steps back.
status The live region.
viewport The scrolling container.

CSS custom properties

PropertyDescription
--bmx-carousel-control-background Their fill.
--bmx-carousel-control-border Their outline.
--bmx-carousel-control-color Their glyph.
--bmx-carousel-control-size The diameter of the previous and next controls.
--bmx-carousel-dot-color A dot that is not current.
--bmx-carousel-dot-current-color The current one.
--bmx-carousel-dot-gap The space between dots.
--bmx-carousel-dot-size The diameter of one dot.
--bmx-carousel-focus-ring The focus indicator.
--bmx-carousel-gap The space between slides.
--bmx-carousel-per-view How many slides fit across. Set from the per-view property.
--bmx-carousel-radius The corner radius of the viewport.
--bmx-carousel-tab-padding The room inside a tab, in tabs mode.