Disclosure
Carousel
Slides one after another that swipe and snap with CSS, with Previous, Next and a marker per slide.
- Element
<slab-carousel>- Built on
CSS scroll snap + <button>s- As Markdown
- carousel.md
Example
Here's a styled version, <slab-styled-carousel>. It's not
in the package — it's an example of what you can build with the
headless element. Copy it and make it yours at the
bottom of this page. Switch to "As it ships" to see the same markup
with the headless element and no CSS at all. That's your starting
point.
Set and braced by 6 a.m. Check the grade stakes before the truck backs in.
38 cubic yards of 4,000 psi, placed from the northeast corner.
Bull float, edge, then a broom finish across the slope.
Keep it wet for seven days before it takes a load.
<slab-styled-carousel label="Pour day">
<div>
<strong>Forms</strong>
<p>Set and braced by 6 a.m. Check the grade stakes before the truck backs in.</p>
</div>
<div>
<strong>Pour</strong>
<p>38 cubic yards of 4,000 psi, placed from the northeast corner.</p>
</div>
<div>
<strong>Finish</strong>
<p>Bull float, edge, then a broom finish across the slope.</p>
</div>
<div>
<strong>Cure</strong>
<p>Keep it wet for seven days before it takes a load.</p>
</div>
</slab-styled-carousel>
Set and braced by 6 a.m. Check the grade stakes before the truck backs in.
38 cubic yards of 4,000 psi, placed from the northeast corner.
Bull float, edge, then a broom finish across the slope.
Keep it wet for seven days before it takes a load.
<slab-carousel label="Pour day">
<div>
<strong>Forms</strong>
<p>Set and braced by 6 a.m. Check the grade stakes before the truck backs in.</p>
</div>
<div>
<strong>Pour</strong>
<p>38 cubic yards of 4,000 psi, placed from the northeast corner.</p>
</div>
<div>
<strong>Finish</strong>
<p>Bull float, edge, then a broom finish across the slope.</p>
</div>
<div>
<strong>Cure</strong>
<p>Keep it wet for seven days before it takes a load.</p>
</div>
</slab-carousel>
Usage
pnpm add slab-elements lit
<script type="module">
import 'slab-elements/carousel.js';
</script>
<slab-carousel label="Featured">
<figure>…</figure>
<figure>…</figure>
<figure>…</figure>
</slab-carousel>
Each child is a slide. The sliding and snapping are CSS scroll snap, which every browser has, so swiping and scrolling work without a script. The buttons are real <button>s rather than Chromium’s ::scroll-button() and ::scroll-marker, so they’re named and styled the same way in every browser. Set autoplay only when rotation is worth the interruption: it pauses while the carousel is hovered or focused, and its first button stops it.
Keyboard
| Key | What it does |
|---|---|
| Tab | Moves through the rotation button, Previous, Next, the slides and the markers. Slides out of view are skipped. |
| Enter, Space | Presses the focused button. |
| Left, Right | Scrolls the focused slides to the previous or next slide. |
Behavior
A carousel with no visual styles, following the APG carousel pattern: a region of slides, one after another, with buttons to move between them.
The slides are a CSS scroll snap container, so swiping, scrolling and the arrow keys on the focused slides move between them natively, in every browser, and land each slide in place. Previous and Next, a marker button per slide, and a button to start and stop rotation when autoplay is set are real <button>s, so they're named, focusable and styled the same way everywhere, rather than the ::scroll-button() and ::scroll-marker pseudo-elements only Chromium has.
Each slotted element is a slide. It's a [role=group] with aria-roledescription="slide" and named "1 of 3" and so on, unless it has a role or a name of its own. Slides scrolled out of view are inert, so neither focus nor a screenreader lands in one that can't be seen.
index is the current slide's, from 0. Previous and Next are aria-disabled rather than disabled at either end, so focus stays on them, unless loop is set, where they wrap. The current slide's marker has aria-current="true".
autoplay moves to the next slide every interval milliseconds, wrapping at the end. It pauses while the pointer is over the carousel or focus is in it, and the rotation button stops and starts it. The slides are an aria-live region while it's stopped, so moving to a slide announces it.
Laying out the slides and snapping them is what a carousel does, so that's functional and done here: each slide is as wide as the carousel. Style the rest through its parts and :state(first), :state(last) and :state(rotating).
API
<slab-carousel>
| Name | Type | Default | Description |
|---|---|---|---|
label
Required
|
string |
Names the carousel. | |
autoplay
|
boolean |
false |
Moves to the next slide every interval milliseconds until it's stopped. |
index
|
number |
0 |
The current slide's, from 0. |
interval
|
number |
5000 |
How long each slide shows for when autoplay is set, in milliseconds. |
loop
|
boolean |
false |
Previous and Next wrap from one end to the other. |
next-label
|
string |
The next button's text and so its accessible name. "Next slide" when unset. | |
previous-label
|
string |
The previous button's text and so its accessible name. "Previous slide" when unset. | |
start-label
|
string |
The rotation button's text while rotation is stopped. "Start slide rotation" when unset. | |
stop-label
|
string |
The rotation button's text while the slides rotate. "Stop slide rotation" when unset. | |
version
|
string |
| Name | Type | Description |
|---|---|---|
markerLabel
|
(index: number) => string |
Names each slide's marker, which has no text, from the slide's index. "Slide" and its number when unset. |
slideLabel
|
(index: number, total: number) => string |
Names each slide without a name of its own, from its index and how many slides there are. "1 of 3" and so on when unset. |
slides
Read-only
|
Element[] |
| Name | Type | Description |
|---|---|---|
change
|
Event |
| Name | Type | Description |
|---|---|---|
(default)
|
Element |
The slides |
| Name | Description |
|---|---|
carousel
|
The region |
controls
|
The rotation, previous and next buttons |
current
|
The current slide's marker |
marker
|
Each slide's marker |
markers
|
The markers |
next
|
The button to the next slide |
previous
|
The button to the previous slide |
rotation
|
The button that starts and stops rotation |
slides
|
The scroll snap container of the slides |
Copy and own it
<slab-styled-carousel> isn't in the package. It's an example —
copy it into your project and change whatever you want. Under the
hood, it's just the headless element, subclassed, with
styles set. That's it. Keyboard, focus, ARIA, forms, and
events all come from the headless element, and the styles only reach
in through ::part() and :state(), same as
yours would.
To use it, copy these files, swap the tag in
@customElement() for one of your own, and import it.
tokens.styles.ts holds the colors and fonts every styled
example shares, so you only need it once.
carousel.ts
import { customElement } from 'lit/decorators.js';
import Carousel from 'slab-elements/carousel.js';
import styles from './carousel.styles.js';
import tokens from './tokens.styles.js';
declare global {
interface HTMLElementTagNameMap {
'slab-styled-carousel': StyledCarousel;
}
}
/**
* Carousel, styled: slides in a raised box, with square buttons and a course
* of markers under them, the current one in ember.
*/
@customElement('slab-styled-carousel')
export default class StyledCarousel extends Carousel {
static override styles = [tokens, styles];
}
carousel.styles.ts
import { css } from 'lit';
/*
The slides sit above the markers and the buttons, which share a row. At
either end, Previous and Next are "aria-disabled" rather than disabled, so
they keep focus. They're styled from Carousel's ":state(first)" and
":state(last)".
*/
export default css`
:host {
color: var(--ink);
font-family: var(--font-body);
}
[part~='carousel'] {
align-items: center;
display: grid;
gap: 0.75rem 1rem;
grid-template-areas:
'slides slides'
'markers controls';
grid-template-columns: 1fr auto;
}
[part~='slides'] {
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
grid-area: slides;
scrollbar-width: none;
&:focus-visible {
outline: 3px solid var(--ember);
outline-offset: 3px;
}
}
::slotted(*) {
padding: 1.25rem 1.5rem;
}
[part~='controls'] {
display: flex;
gap: 0.5rem;
grid-area: controls;
}
[part~='rotation'],
[part~='previous'],
[part~='next'] {
background-color: var(--paper);
block-size: 2.5rem;
border: var(--border);
box-shadow: 3px 3px 0 var(--ink);
color: var(--ink);
cursor: pointer;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 600;
padding: 0 0.75rem;
transition:
background-color 100ms,
box-shadow 100ms;
&:hover {
background-color: var(--chalk);
}
&:active {
box-shadow: 1px 1px 0 var(--ink);
}
&:focus-visible {
outline: 3px solid var(--ember);
outline-offset: 3px;
}
}
:host(:state(first)) [part~='previous'],
:host(:state(last)) [part~='next'] {
background-color: var(--chalk);
border-style: dashed;
box-shadow: none;
color: var(--ash);
cursor: not-allowed;
}
[part~='markers'] {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
grid-area: markers;
}
[part~='marker'] {
background-color: var(--paper);
block-size: 0.875rem;
border: var(--border);
cursor: pointer;
inline-size: 0.875rem;
padding: 0;
transition: background-color 100ms;
&:hover {
background-color: var(--chalk);
}
&:focus-visible {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
}
[part~='current'] {
background-color: var(--ember);
&:hover {
background-color: var(--ember);
}
}
@media (prefers-reduced-motion: reduce) {
[part~='rotation'],
[part~='previous'],
[part~='next'],
[part~='marker'] {
transition: none;
}
}
`;
tokens.styles.ts
import { css } from 'lit';
/*
The styled components' palette and type, taken from the logo: ink for the
courses, ember for the base, paper behind them.
Each is a short alias for a "--slab-styled-*" property, with the default as
its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
which every shadow root inherits. Were the defaults declared under those
names here instead, each host's own declaration would shadow the page's.
Each default is "light-dark()", so the components follow the page's
"color-scheme": "dark" for dark, "light" for light, and "light dark" to
follow the operating system. Ink and paper trade places in the dark, so
ink is always the text and paper always what's behind it. Contrast is given
as light, then dark.
*/
export default css`
:host {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}
`;