registry:ui · NativeHTML
carousel — the browser is the animator
A carousel IS a horizontally scrolled region: CSS scroll-snap pages it, native scrolling animates it (momentum, keyboard when focused), and JS only READS the position (rAF-throttled) to keep the dots honest. No slide cloning, no virtual window, no transition emulation — slides are ordinary direct children.
$npx jixoai-ui add carousel
https://ui.jixoai.com/r/carousel.json
overview
Overview
A scrolled region with three owned jobs — the snap, the join, the honest dots — and nothing else: the browser is the animator.
The track is a real scroller: overflow-x: auto over scroll-snap-type: x mandatory, one cell of a grid whose other cell hosts
the paging arrows (hung outside by a negative margin over the space token). Every
direct child is sized to --jx-slide-w and snapped to the start edge — the
slide law — so slides are ordinary content (figures, cards, sections) and a peeking
carousel is one CSS length away.
The component adds the parts the platform lacks: labelled prev/next arrows, a dot row
that commands the scroller, and the honesty rule — the active dot is whichever slide's
snap position is nearest the scroll offset, read rAF-throttled off the scroll event.
Adjacent paging rides scrollTo smooth; longer hops go instant, because
mandatory snap fights a mid-flight smooth scroll.
All eight axes resolve no-own on the root (census batch D1 — the supply chain is the point): the chrome is fixed micro-geometry over kernel space steps, so seven axes stamp-and-supply to the SLIDES, and theme is the frozen pole — every voice is the stylex :root emission, measured byte-identical across a dark island. Per-axis below; the shared grammar lives on the universal props page.
usage
Usage
Slides are any direct children — figures, cards, sections; slideWidth is the one geometry decision.
<script lang="ts">
import Carousel from '@ui/carousel.svelte';
</script>
<!-- width/height of each slide: slideWidth is the one geometry decision -->
<Carousel label="release stages" slideWidth="100%">
<figure>01 deploy</figure>
<figure>02 audit</figure>
<figure>03 ship</figure>
</Carousel>
<!-- any CSS length < 100% peeks the neighbors -->
<Carousel label="gallery" slideWidth="60%">…</Carousel>carousel
Drag/scroll the track (or focus it and use arrow keys), page with the dots or arrows — the active dot follows the nearest snap slide.
<script lang="ts">
import Carousel from '@ui/carousel.svelte';
</script>
<div class={cx(rt.wFull, rt.maxWXl)}>
<Carousel>
<figure class={cx(rt.panel)} style="display: flex; align-items: center; justify-content: center; gap: 1rem; min-height: 9rem; margin: 0;">
<span class={cx(rt.inkAccent)}>01</span> deploy
</figure>
<figure class={cx(rt.panel)} style="display: flex; align-items: center; justify-content: center; gap: 1rem; min-height: 9rem; margin: 0;">
<span class={cx(rt.inkAccent)}>02</span> audit
</figure>
<figure class={cx(rt.panel)} style="display: flex; align-items: center; justify-content: center; gap: 1rem; min-height: 9rem; margin: 0;">
<span class={cx(rt.inkAccent)}>03</span> ship
</figure>
</Carousel>
</div>props
Props
The table renders from the GENERATED meta; the eight axis rows render into the shared section beneath. EXTRA chain: 15 meta entries, zero duplicate keys − 8 axis-named = 7 family rows; no hidden lane, no extra lane (slideWidth is the family's own geometry literal — the native size attribute is never received, the §1 collision rule).
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| label | string | 'carousel' | The region's accessible name (aria-label) — a named grouping of slides; role=region with aria-roledescription="carousel" is the law. |
| slideWidth | string | '100%' | Slide width inside the snap track as any CSS length — '100%' (default) pages one-at-a-time; '80%' / '24rem' give the peeking carousel. Rides the --jx-slide-w variable the slide law sizes every direct child with. |
| dots | boolean | true | The dot row under the track (mounts only with more than one slide). Dots are real buttons that COMMAND the scroller — they never navigate; the arrows stay either way. |
| class | string | '' | Forwarded to the root region, after the family atoms. |
| children | Snippet | — | The slides — any element each, direct children of the snap track. Every direct child is sized to --jx-slide-w and snapped to the start edge (the slide law). |
| prevLabel | string | '‹' | The prev arrow's text (aria-label stays 'previous slide'). |
| nextLabel | string | '›' | The next arrow's text (aria-label stays 'next slide'). |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on carousel
All no-own, census batch D1 — the supply chain is the point. The chrome is fixed micro-geometry over kernel space steps, so seven axes stamp-and-supply to the SLIDES (grep receipts: zero -effective readers, zero density-channel reads in ui/carousel/), and theme is the frozen pole, measured: every painted voice is the stylex :root emission, byte-identical across a dark island. The paging motion is the platform's own — never re-mapped.
Reading the table: Property is the axis, Type is the real carrier or consumption it drives on THIS family, Default is the lane default — the named steps, number unit, and consumption are in each description.
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | ambient scope | SUPPLY-ONLY, MEASURED INERT ON THE CHROME — the §11 stamp lands on the root (the computed font-size moves to exactly the stamped px) and nothing follows: the arrows are fixed 2rem boxes over the base-type equation, the dots are 0.5rem, and the track gap is a fixed space step (measured: a stamped root leaves arrows, dots and track unmoved). The slides inherit the echo only when their own markup asks for em. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | ambient scope | SUPPLY-ONLY — stamps --jx-shape-effective + --jx-radius-factor-effective; zero readers (grep receipt: zero corner declarations in ui/carousel/). The track is a rectangular strip and the controls are circles-by-geometry, not corners. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | ambient scope | SUPPLY-ONLY — stamps --jx-radius-effective; zero readers (grep receipt), and the slides are the consumer's own markup — the §3 broadcast reaches whatever THEY compute against. Number unit: px. |
| density | 'small' | 'medium' | 'large' | 'xs' | '2xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | ambient scope | SUPPLY-ONLY ON SELF, SUPPLIED TO THE SLIDES — data-density stamps the root and the family's own chrome reads NONE of the re-based channels (grep receipt: the track gap is the fixed --jx-space-12 step, the dots' gap --jx-space-8, the arrows' type the fixed base-type equation — measured: a stamped lg rung leaves arrows, dots and track unmoved). What density moves is the SLIDES: composed slot content inherits the re-based channels through the supply. The number lane is inert (the coefficient composes at the :root rung scopes; the wrapper never re-declares). Number unit: coefficient. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | ambient scope | SUPPLY-ONLY — stamps --jx-color-effective; zero readers (grep receipt). The chrome is deliberately hue-neutral geometry; hue that paints goes through the jx-hue-* utilities on the surrounding tree, exactly as on chip. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | ambient scope | THE FROZEN POLE, MEASURED — every painted voice is the stylex :root emission (--jx-border/--jx-popover/--jx-foreground/--jx-primary/--jx-muted/--jx-ring/--jx-shadow-xs, plus kernel space channels): zero raw reads (grep receipt), so a resolved dark stamps .dark and NOTHING flips — measured byte-identical arrows and dots across the island. light and system stamp nothing — tree inheritance. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | ambient scope | SUPPLY-ONLY — the arrows' one shadow rides the typed --jx-shadow-xs intermediate (fixed micro-geometry, resolved at :root like every voice), and zero family css consumes the carrier (grep receipt: zero --jx-elevation reads). Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | ambient scope | SUPPLY-ONLY — stamps --jx-motion-effective; zero readers (grep receipt). The paging motion is the PLATFORM'S OWN: CSS scroll-snap pages, native momentum animates, adjacent scrollTo rides behavior:'smooth' (long hops go instant — mandatory snap fights mid-flight smooth scrolling), and the reduced-motion kill is the site's own. The component never re-maps the platform. Number unit: coefficient. |
Deviations, cited: the adoption is the census batch D1 row (explicit-props W3 — the FIRST-TIME no-own contract, the scroller being native chrome; openspec/changes/explicit-props/research/migration-census.md). The §1 collision rule: the native size attribute is never received — slideWidth owns the geometry under its own name. The falsified density token rows (--jx-icon / --jx-hit, documented by the W3-era page, read by nothing) retired with the hand table.
<script lang="ts">
import Carousel from '@ui/carousel.svelte';
</script>
<div class={cx(rt.gridSm2, rt.wFull)}>
<div class={cx(rt.panel)} data-probe="row-default">
<span class={cx(rt.note11)}>density ambient — the chrome's fixed micro-geometry</span>
<Carousel label="density ambient row">
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">one</figure>
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">two</figure>
</Carousel>
</div>
<div class={cx(rt.panel)} data-probe="row-lg">
<span class={cx(rt.note11)}>density="lg" — the chrome unmoved; the supply flows to the slides</span>
<Carousel label="density lg row" density="lg">
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">one</figure>
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">two</figure>
</Carousel>
</div>
<div class={cx(rt.panel)} data-probe="row-dark">
<span class={cx(rt.note11)}>theme="dark" — .dark stamps; nothing flips (the frozen pole)</span>
<Carousel label="dark row" theme="dark">
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">one</figure>
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">two</figure>
</Carousel>
</div>
</div>
<p class={cx(rt.mt8, rt.note12, rt.inkMuted70)}>
Measured: the lg rung's data-density lands on the root while arrows, dots and track
stay unmoved — the chrome rides fixed space steps, and the slides inherit the
re-based channels through the supply. The dark island flips nothing: every painted
voice is the stylex :root emission (byte-identical arrows and dots).
</p>carousel · the supply and the frozen pole
Measured: the lg rung's data-density lands on the root while arrows, dots and track stay unmoved — the chrome rides fixed space steps, and the slides inherit the re-based channels through the supply. The dark island flips nothing: every painted voice is the stylex :root emission (byte-identical arrows and dots).
<script lang="ts">
import Carousel from '@ui/carousel.svelte';
</script>
<div class={cx(rt.gridSm2, rt.wFull)}>
<div class={cx(rt.panel)} data-probe="row-default">
<span class={cx(rt.note11)}>density ambient — the chrome's fixed micro-geometry</span>
<Carousel label="density ambient row">
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">one</figure>
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">two</figure>
</Carousel>
</div>
<div class={cx(rt.panel)} data-probe="row-lg">
<span class={cx(rt.note11)}>density="lg" — the chrome unmoved; the supply flows to the slides</span>
<Carousel label="density lg row" density="lg">
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">one</figure>
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">two</figure>
</Carousel>
</div>
<div class={cx(rt.panel)} data-probe="row-dark">
<span class={cx(rt.note11)}>theme="dark" — .dark stamps; nothing flips (the frozen pole)</span>
<Carousel label="dark row" theme="dark">
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">one</figure>
<figure class={cx(rt.panel)} style="min-height: 6rem; display: grid; place-items: center; margin: 0;">two</figure>
</Carousel>
</div>
</div>
<p class={cx(rt.mt8, rt.note12, rt.inkMuted70)}>
Measured: the lg rung's data-density lands on the root while arrows, dots and track
stay unmoved — the chrome rides fixed space steps, and the slides inherit the
re-based channels through the supply. The dark island flips nothing: every painted
voice is the stylex :root emission (byte-identical arrows and dots).
</p><script lang="ts">
import Carousel from '@ui/carousel.svelte';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<Carousel label="responsive row" density={query<{ lg: DensityLane }, DensityLane>({ lg: 'large' }, 'small')}>…</Carousel>carousel · query()
Media keys are min-width: below 40rem the base applies — the small rung; at 40rem and wider the lg case wins. What moves is the root's data-density in the live DOM (lg ↔ sm, greppable) and the re-based channels the SLIDES inherit through the supply — the chrome itself stays fixed micro-geometry. Resize across 40rem.
<script lang="ts">
import Carousel from '@ui/carousel.svelte';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<Carousel label="responsive row" density={query<{ lg: DensityLane }, DensityLane>({ lg: 'large' }, 'small')}>…</Carousel>| Token | Default |
|---|---|
| the slide law | --jx-slide-w on every direct child |
| the track gap | --jx-space-12 (12px) |
| the paging arrows | 2rem box · --jx-popover ground |
| the arrow shadow | --jx-shadow-xs (typed alias) |
| the dots | 0.5rem · --jx-muted ground |
| the hooks | data-jx-carousel · -window · -track · -arrow · -dot |
a11y
Accessibility
The region names itself; the track is the keyboard surface (mandatory snap eats the native arrow scroll, so the track pages); every control is a real button — and nothing carries a pressed state, because selection is toggle-group's law.
Keyboard
| Key | Action |
|---|---|
| ← / → (track focused) | Page one slide back/forward — the track's own keydown pages, because mandatory snap eats the native ~40px arrow scroll |
| Tab | Moves focus through arrows, track, and dots — all real buttons with their own tab stops |
| Enter / Space | Activate the focused arrow or dot button |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | region + aria-roledescription="carousel" | On the root; aria-label comes from the label prop. |
| aria-label | previous slide / next slide / go to slide n | On the arrows and each dot — they command the scroller, they never navigate. |
| aria-current | "true" | On the active dot — the one whose snap position is nearest the scroll offset (rAF-throttled read). |
| tabindex | 0 | On the track — the scroll region itself is the keyboard surface. |
| no aria-pressed | law | No control carries a pressed state — a carousel is action + position; selection is toggle-group's contract. |