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>layout="rail" swaps the sliding track for a real horizontally-scrolling row with
several cards visible at once, like a storefront shelf. The browser drives it: touch, trackpad,
wheel, the scrollbar, and the arrow keys all scroll it natively, and a mouse can drag it too.
The prev/next buttons move it one screenful at a time. There is no dots or counter indicator
in this layout, since several items are visible at once.
<Carousel items={railProducts} layout="rail" ariaLabel="Featured products">
{#snippet slide(product)}
<Card class="hz-card--outlined" padding="md" rounded="md">
{#snippet media()}
<Image src={productArt(product)} alt="" aspectRatio="4/3" fit="cover" />
{/snippet}
<p class="rail-product-name">{product.name}</p>
<p class="rail-product-price">${product.price}</p>
</Card>
{/snippet}
</Carousel>Control how many cards show at once with --hz-carousel-item-width. Set a
plain length, or a calc() for an exact count no matter how wide the
container is. --hz-carousel-gap sets the space between cards.
<!-- A plain value: every card is 12rem wide, with a wider gap. -->
<Carousel
items={railProducts}
layout="rail"
ariaLabel="Featured products"
class="rail-fixed-width"
>
{#snippet slide(product)}
<Card class="hz-card--outlined" padding="md" rounded="md">
{#snippet media()}
<Image src={productArt(product)} alt="" aspectRatio="4/3" fit="cover" />
{/snippet}
<p class="rail-product-name">{product.name}</p>
<p class="rail-product-price">${product.price}</p>
</Card>
{/snippet}
</Carousel>
<style>
:global(.rail-fixed-width) {
--hz-carousel-item-width: 12rem;
--hz-carousel-gap: 1.5rem;
}
</style>The same property takes a calc() for an exact count instead of a fixed width
— this row always shows three:
<!-- Exactly three visible, whatever the container width. -->
<Carousel
items={railProducts}
layout="rail"
ariaLabel="Featured products"
class="rail-exactly-three"
>
{#snippet slide(product)}
<Card class="hz-card--outlined" padding="md" rounded="md">
{#snippet media()}
<Image src={productArt(product)} alt="" aspectRatio="4/3" fit="cover" />
{/snippet}
<p class="rail-product-name">{product.name}</p>
<p class="rail-product-price">${product.price}</p>
</Card>
{/snippet}
</Carousel>
<style>
:global(.rail-exactly-three) {
--hz-carousel-item-width: calc((100% - 2 * 1rem) / 3);
}
</style>snap (the default) snaps the row to a card's start as you scroll or drag. Turn
it off for free, continuous scrolling that stops wherever you release it.
<Carousel items={railProducts} layout="rail" snap={false} ariaLabel="Featured products">
{#snippet slide(product)}
<Card class="hz-card--outlined" padding="md" rounded="md">
{#snippet media()}
<Image src={productArt(product)} alt="" aspectRatio="4/3" fit="cover" />
{/snippet}
<p class="rail-product-name">{product.name}</p>
<p class="rail-product-price">${product.price}</p>
</Card>
{/snippet}
</Carousel>loop in a rail wraps the row in either direction: scroll or drag past either
end and it carries on rather than stopping. A looping row also hides its scrollbar, since
there's no real start or end for it to point to. The row still scrolls with touch, trackpad,
wheel, the keyboard, and a mouse drag, exactly as before. The cards that appear wrapped around
at either edge are hidden copies: they become the real, clickable cards once the row settles
onto them. See the note below for how those copies treat the mouse.
<Carousel items={railProducts} layout="rail" loop snap={false} ariaLabel="Featured products">
{#snippet slide(product)}
<Card class="hz-card--outlined" padding="md" rounded="md">
{#snippet media()}
<Image src={productArt(product)} alt="" aspectRatio="4/3" fit="cover" />
{/snippet}
<p class="rail-product-name">{product.name}</p>
<p class="rail-product-price">${product.price}</p>
</Card>
{/snippet}
</Carousel>Hover effects near the wrap seam
interactiveClones if your cards rely on hover. Either way, the copies stay hidden
from screen readers. Setting the prop is your promise that the cards contain no links, buttons,
or other focusable elements. One inside a hidden copy would be reachable by keyboard while
invisible to screen readers, so development builds warn if they find one.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 | In 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. |
snap | boolean | true | Rail only. true snaps the row to item starts as you scroll; false is free, continuous scrolling. |
loop | boolean | false | Wrap 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. |
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. Ignored in the rail layout, since loop is already continuous there. |
interactiveClones | boolean | false | Rail + 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. |
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. |
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
| Hook | Values | Styles |
|---|---|---|
data-active | present on the active slide and dot | On .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-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. 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-snap | present when layout="rail" and snap is not false | On 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-loop | present when the rail loop is effectively active | On 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-seamless | present 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-clone | present on a buffer slide | On .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
| 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. |
--hz-carousel-item-width | <length> — default clamp(9rem, 20%, 18rem), layout='rail' only | Each 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' only | Gap 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' only | Block 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
| Hook | Values | Styles |
|---|---|---|
.hz-carousel-viewport | child element | The 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-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.
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