<bmx-carousel>
A strip of slides the reader moves through.
12 properties · 2 events · 5 methods · 8 parts
Example
inert.
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.
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.
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 —
<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 — 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.
</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 — 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
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
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
| Event | Detail | Description |
|---|---|---|
bmxAutoplayChange |
BmxCarouselAutoplayDetail |
The clock started or stopped, and why. |
bmxChange |
BmxCarouselChangeDetail |
The slide changed. |
Methods
| Method | Signature | Description |
|---|---|---|
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
| Slot | Description |
|---|---|
(default) |
The slides. Anything at all; this component has no opinion about them. |
CSS shadow parts
| Part | Description |
|---|---|
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
| Property | Description |
|---|---|
--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. |