Carousel
An accessible, manually rotated carousel: a draggable slide track, labeled slides, previous and next controls, arrow-key steering, and a live region announcing changes. It does not auto-rotate, by design.
Import
import { Carousel } from "@hyzer-labs/ui"Demo
The slide-track settle animation honors --hz-duration-* / --hz-ease-*.
See Motion for the token values and the @hyzer-labs/ui/motion script-side helpers built on them.
<Carousel items={quotes} ariaLabel="Customer quotes">
{#snippet slide(quote)}
<Blockquote cite={quote.who}>{quote.text}</Blockquote>
{/snippet}
</Carousel>indicator="dots" swaps the counter for clickable slide pickers. Each dot is
a labeled button, and aria-current marks the active slide. Position changes still
announce through the live region, so screen readers keep the "n of total" information either
way.
<Carousel items={quotes} indicator="dots" ariaLabel="Customer quotes">
{#snippet slide(quote)}
<Blockquote cite={quote.who}>{quote.text}</Blockquote>
{/snippet}
</Carousel>Without loop the controls disable at the ends. With it, navigation wraps both
ways, including a drag flicked past the first or last slide.
<!-- loop: wraps in both directions, controls never disable -->
<Carousel items={quotes} loop ariaLabel="Customer quotes">
{#snippet slide(quote)}
<Blockquote cite={quote.who}>{quote.text}</Blockquote>
{/snippet}
</Carousel>controls="focus" keeps the prev/next buttons and indicator in the DOM and
fully operable. They are hidden only visually, until :hover or :focus-within reveals the whole row together (Tab into the carousel, or hover it with a mouse). That is
the WCAG 2.5.7 non-dragging alternative to the drag gesture. The row never uses display, visibility, aria-hidden, or inert, so it stays reachable by keyboard and screen readers whatever the
visual reveal does.
seamless makes every wrap settle forward through a hidden clone instead of sweeping
backward through the row. That covers a drag flicked past the last slide, the buttons, the
dots, and the arrow keys.
<!-- controls="focus": the row is hidden until hover/focus reveals it -->
<!-- seamless: every ±1 loop wrap settles through a clone, never a -->
<!-- backward sweep through the row -->
<Carousel
draggable
loop
seamless
controls="focus"
items={quotes}
ariaLabel="Customer quotes (drag)"
>
{#snippet slide(quote)}
<Blockquote cite={quote.who}>{quote.text}</Blockquote>
{/snippet}
</Carousel>This is a theme example done with CSS alone, not a new prop. A plain consumer class
restyles the dots into flat segments of a thin progress trackline, with the current
slide's segment colored --hz-intent-primary, and hides the chevrons until
they receive keyboard focus.
The chevrons stay in the DOM, in the tab order, and Enter/Space-operable throughout:
never display, visibility, aria-hidden, or inert. That is the same accessibility treatment controls="focus" ships (see the Drag tab), applied here to the two controls
individually so the trackline itself stays visible. Every color comes from --hz-color-*/--hz-intent-* tokens.
<Carousel
items={quotes}
indicator="dots"
ariaLabel="Customer quotes"
class="minimal-carousel"
>
{#snippet slide(quote)}
<Blockquote cite={quote.who}>{quote.text}</Blockquote>
{/snippet}
</Carousel>
<style>
:global(.minimal-carousel .hz-carousel-controls) {
gap: 0.5rem;
}
/* Hidden until :focus-visible: still in the DOM, in the tab order,
and Enter/Space-operable throughout; never display/visibility/
aria-hidden/inert. */
:global(.minimal-carousel .hz-carousel-prev),
:global(.minimal-carousel .hz-carousel-next) {
opacity: 0;
}
:global(.minimal-carousel .hz-carousel-prev:focus-visible),
:global(.minimal-carousel .hz-carousel-next:focus-visible) {
opacity: 1;
}
:global(.minimal-carousel .hz-carousel-dots) {
flex: 1;
gap: 0.25rem;
}
:global(.minimal-carousel .hz-carousel-dot) {
width: auto;
flex: 1;
height: 3px;
border-radius: var(--hz-radius-full);
scale: 1;
background-color: var(--hz-color-border);
}
:global(.minimal-carousel .hz-carousel-dot[data-active]) {
scale: 1;
background-color: var(--hz-intent-primary);
}
</style>Props
| Name | Type | Default | Note |
|---|---|---|---|
items | T[] | — | Required. Generic — each item renders via the slide snippet. |
ariaLabel | string | — | Required. Names the carousel region. |
index | number (bindable) | 0 | |
loop | boolean | false | Wrap from the last slide to the first and back. |
draggable | boolean | true | Pointer drag to slide (drag wraps when loop is set — seamlessly, when seamless is also set). Off leaves keyboard, buttons, and dots working. |
controls | 'visible' | 'focus' | 'visible' | focus keeps the prev/next buttons and indicator in the DOM and fully operable, hidden only visually until :hover/:focus-within reveals the whole row together — the WCAG 2.5.7 non-dragging alternative, always reachable. |
seamless | boolean | false | Opt-in continuous boundary wrap: every ±1 loop step — drag, buttons, dots, arrow keys — settles through a hidden clone instead of sweeping back through the row. Only meaningful with loop; an inert no-op without it. |
indicator | 'counter' | 'dots' | 'counter' | The "1 / 3" counter, or clickable slide-picker dots. |
prevLabel | string | 'Previous slide' | |
nextLabel | string | 'Next slide' | |
slideLabel | (item, index) => string | — | Accessible name per slide; defaults to "{n} of {total}". |
dotLabel | (index, count) => string | — | Accessible name per dot; defaults to "Go to slide {n} of {total}". |
onchange | (index: number) => void | — | |
slide | Snippet<[T, number]> | — | Required. Renders one slide. |
class | string | — | Merged after the hz-carousel class. |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-carousel
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-active | present on the active slide and dot | On .hz-carousel-slide and .hz-carousel-dot. Off-screen slides carry inert instead — they are clipped by the viewport, not hidden — so target :not([inert]) for the visible one. |
data-dragging | present while a drag is underway | On .hz-carousel-track. The settle transition is suppressed while it is present so the track follows the pointer 1:1; a grab/grabbing cursor pairs with it. |
data-controls | 'visible' | 'focus' | On the root, always stamped, both values. Presentation only: the controls markup is identical either way. focus visually hides the whole control row until :hover/:focus-within reveals it together — the row stays in the DOM, in the a11y tree, and fully operable throughout (the WCAG 2.5.7 non-dragging alternative to drag). Default 'visible'. |
data-seamless | present only when seamless && loop | On the root. Absent when seamless is set without loop — an inert no-op — so the hook reflects effective behavior, never advertising a wrap that cannot happen. |
data-clone | present on a wrap-settle clone slide | On .hz-carousel-slide. Rendered only mid-wrap, seamless loop, a distance-1 boundary crossing (drag, buttons, an adjacent-wrap dot, or arrow keys) — an inert, aria-hidden copy of the opposite-end slide the track settles into before silently resetting to the real target. Never counted in count, "{n} of {total}", or the dot rail; never focusable. |
Custom properties
| Hook | Values | Styles |
|---|---|---|
--hz-carousel-dot-size | <length> — default 0.5rem | Dot diameter (the painted size; the tap target is larger). Declared on .hz-carousel-dot itself, so set it there — declaring it on .hz-carousel will not reach, since the local declaration beats your inherited value. |
--hz-carousel-focus-min-height | <length> — default 12rem | Minimum height of .hz-carousel-viewport, data-controls='focus' only. The revealed control row is an absolutely positioned overlay with no reserved layout space, so on a short carousel it can cover most of the slide — this keeps slide content clear of the row regardless of the slide's own height. data-controls='visible' needs no reserved space (its row sits in normal flow below the viewport) and is unaffected. |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-carousel-viewport | child element | The clip window and live region. |
.hz-carousel-track | child element | The sliding row of slides. Its transform is an inline style; the transition and the drag cursor live here. |
.hz-carousel-slide | child element | One slide; off-screen ones are inert. |
.hz-carousel-controls | child element | The control row. |
.hz-carousel-prev | on a Button | Previous control; also a .hz-button. Its ::before carries the 44px touch target. |
.hz-carousel-next | on a Button | Next control; also a .hz-button. Its ::before carries the 44px touch target. |
.hz-carousel-dots | child element | The dot rail. |
.hz-carousel-dot | child element | One dot. Its ::before is a transparent tap target larger than the painted dot. |
.hz-carousel-status | child element | The "1 / 3" counter. |
Accessibility
Built on the APG carousel pattern. The region and each slide carry aria-roledescription, and slides are named ('2 of 5'-style by default, or set your own with slideLabel).
There is no auto-rotation, so the viewport is an aria-live="polite" region and slide changes announce themselves. Arrow keys, Home, and End move between slides while focus is inside the carousel.
controls="focus" hides the control row visually only. It never uses display, visibility, aria-hidden, or inert, so the row stays reachable by Tab and appears on hover. That is the WCAG 2.5.7 alternative to the drag gesture.
References: APG Carousel pattern · WCAG 2.5.7 Dragging Movements