Skip to content

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>

Props

NameTypeDefaultNote
itemsT[]Required. Generic — each item renders via the slide snippet.
ariaLabelstringRequired. Names the carousel region.
indexnumber (bindable)0
loopbooleanfalseWrap from the last slide to the first and back.
draggablebooleantruePointer 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.
seamlessbooleanfalseOpt-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.
prevLabelstring'Previous slide'
nextLabelstring'Next slide'
slideLabel(item, index) => stringAccessible name per slide; defaults to "{n} of {total}".
dotLabel(index, count) => stringAccessible name per dot; defaults to "Go to slide {n} of {total}".
onchange(index: number) => void
slideSnippet<[T, number]>Required. Renders one slide.
classstringMerged 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

HookValuesStyles
data-activepresent on the active slide and dotOn .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-draggingpresent while a drag is underwayOn .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-seamlesspresent only when seamless && loopOn 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-clonepresent on a wrap-settle clone slideOn .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

HookValuesStyles
--hz-carousel-dot-size<length> — default 0.5remDot 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 12remMinimum 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

HookValuesStyles
.hz-carousel-viewportchild elementThe clip window and live region.
.hz-carousel-trackchild elementThe sliding row of slides. Its transform is an inline style; the transition and the drag cursor live here.
.hz-carousel-slidechild elementOne slide; off-screen ones are inert.
.hz-carousel-controlschild elementThe control row.
.hz-carousel-prevon a ButtonPrevious control; also a .hz-button. Its ::before carries the 44px touch target.
.hz-carousel-nexton a ButtonNext control; also a .hz-button. Its ::before carries the 44px touch target.
.hz-carousel-dotschild elementThe dot rail.
.hz-carousel-dotchild elementOne dot. Its ::before is a transparent tap target larger than the painted dot.
.hz-carousel-statuschild elementThe "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