Slab

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.

Styled example As it ships
Forms

Set and braced by 6 a.m. Check the grade stakes before the truck backs in.

Pour

38 cubic yards of 4,000 psi, placed from the northeast corner.

Finish

Bull float, edge, then a broom finish across the slope.

Cure

Keep it wet for seven days before it takes a load.

HTML
<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>
Forms

Set and braced by 6 a.m. Check the grade stakes before the truck backs in.

Pour

38 cubic yards of 4,000 psi, placed from the northeast corner.

Finish

Bull float, edge, then a broom finish across the slope.

Cure

Keep it wet for seven days before it takes a load.

HTML
<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

Install
pnpm add slab-elements lit
HTML
<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>

Attributes
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
Properties
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[]
Events
Name Type Description
change Event
Slots
Name Type Description
(default) Element The slides
CSS parts
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);
  }
`;