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.
ariaLabelstring—Required. Names the carousel region.
indexnumber (bindable)0In the rail layout, the item nearest the scroll position — writing it scrolls the row there.
layout'single' | 'rail''single'single is the sliding track, one slide per view. rail is a horizontally-scrolling row, scrolled by the browser itself, with several items visible at once.
snapbooleantrueRail only. true snaps the row to item starts as you scroll; false is free, continuous scrolling.
loopbooleanfalseWrap from the last slide to the first and back. In the rail layout this renders a hidden buffer of cloned items so scrolling past either end continues without stopping.
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. Ignored in the rail layout, since loop is already continuous there.
interactiveClonesbooleanfalseRail + loop only. Lets tooltips and hover styles fire on the cloned cards near the wrap seam. Set it only when your slide content has no focusable elements: see the note in the Rail examples.
indicator'counter' | 'dots''counter'The "1 / 3" counter, or clickable slide-picker dots. Not shown in the rail layout — with several items visible at once there is no single position to indicate.
prevLabelstring'Previous slide'
nextLabelstring'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—
slideSnippet<[T, number]>—Required. Renders one slide.
classstring—Merged after the hz-carousel class.

Anything not listed above is forwarded as an attribute to the root element (or the native control in form components). So id, data-*, aria-*, and event handlers just work.

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, layout="single" only. Off-screen slides carry inert instead — they are clipped by the viewport, not hidden — so target :not([inert]) for the visible one. Rail has no single active slide, so no slide or dot ever carries it there.
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. In rail, the same hook also suppresses scroll-snap on the viewport for the duration (:has()) so mandatory snap does not fight the live drag.
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-layout'single' | 'rail'On the root, always stamped, both values. 'single' (default) is the sliding-track, one-slide-per-view layout; 'rail' is a native horizontal-scroll row with several slides visible at once — a different mechanism, not a variant.
data-snappresent when layout="rail" and snap is not falseOn the root, rail only — never stamped in single mode. Drives the viewport's scroll-snap-type: x mandatory. Absent with snap={false} for free continuous momentum scrolling.
data-looppresent when the rail loop is effectively activeOn the root, rail only. Stamped once loop is genuinely wrapping — content overflows and the clone buffer has mounted — never before that first measurement and never when loop is inert because the content does not overflow. The hook reflects effective behavior, the data-seamless doctrine: hides the themed scrollbar while present, since a scrollbar's position and size describe a fixed range that a looping rail does not have.
data-seamlesspresent only when seamless && loop, layout="single"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. Also absent in layout="rail": rail's loop is inherently continuous, so seamless has nothing to opt into there.
data-clonepresent on a buffer slideOn .hz-carousel-slide. Two producers: (1) single mode — 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; (2) rail — under loop, once content overflows, a full leading and a full trailing copy of every item, mounted after hydration, that the scroll position teleports across invisibly at the ends. Both are inert, aria-hidden, and 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.
--hz-carousel-item-width<length> — default clamp(9rem, 20%, 18rem), layout='rail' onlyEach slide's flex-basis. The percentage resolves against the viewport, so the visible count falls out of container width; the 9rem floor keeps a phone at roughly 2.5 items with a peeking edge. Set an exact count with a calc(), e.g. calc((100% - 2 * 1rem) / 3) for exactly three.
--hz-carousel-gap<length> — default var(--hz-space-sm, 1rem), layout='rail' onlyGap between rail slides. Single mode never applies a gap — it would break the 100%-per-slide transform math.
--hz-carousel-rail-inset<length> — default 0.25rem, layout='rail' onlyBlock padding on .hz-carousel-viewport. Rail forces overflow-y: hidden (the single-axis-auto rule), which would otherwise clip a focused item's ring or shadow — this keeps a small buffer clear above and below.

Part classes

HookValuesStyles
.hz-carousel-viewportchild elementThe clip window in single mode (and its aria-live="polite" region); the native scroll container in rail — overflow-x: auto, and a tab stop (tabindex="0") so keyboard scrolling works with zero JS. No aria-live in rail: nothing appears or disappears, the position is continuous, and it would announce every hydrated clone.
.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.

The rail layout is a real scroll container, so it works differently. Every item stays visible and reachable, with nothing hidden or inert, and the container itself is a single tab stop. Once it has focus, the native Arrow, Home, End, Page Up/Down, and Space keys scroll it with no extra handling, and tabbing into a partly visible card scrolls it fully into view.

References: APG Carousel pattern · WCAG 2.5.7 Dragging Movements