jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — carousel

$npx jixoai-ui add carousel

https://ui.jixoai.com/r/carousel.json

scroll-snap no cloning dots page, track focuses

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.

Carousel usage
<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>

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

PropertyTypeDefaultDescription
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 booleantrueThe 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

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity 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.

PropertyTypeDefaultDescription
size 'small' | 'medium' | 'large' | 'auto' | numberambient scopeSUPPLY-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 scopeSUPPLY-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' | numberambient scopeSUPPLY-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 scopeSUPPLY-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 | stringambient scopeSUPPLY-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 scopeTHE 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' | numberambient scopeSUPPLY-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' | numberambient scopeSUPPLY-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.

the eight axes on carousel
<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

density ambient — the chrome's fixed micro-geometry
density="lg" — the chrome unmoved; the supply flows to the slides
theme="dark" — .dark stamps; nothing flips (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).

carousel-axes.svelte svelte
<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>
one real query() case
<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>

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.

TokenDefault
the slide law--jx-slide-w on every direct child
the track gap--jx-space-12 (12px)
the paging arrows2rem box · --jx-popover ground
the arrow shadow--jx-shadow-xs (typed alias)
the dots0.5rem · --jx-muted ground
the hooksdata-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

KeyAction
← / → (track focused)Page one slide back/forward — the track's own keydown pages, because mandatory snap eats the native ~40px arrow scroll
TabMoves focus through arrows, track, and dots — all real buttons with their own tab stops
Enter / SpaceActivate the focused arrow or dot button

ARIA

AttributeValueDescription
roleregion + aria-roledescription="carousel"On the root; aria-label comes from the label prop.
aria-labelprevious slide / next slide / go to slide nOn 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).
tabindex0On the track — the scroll region itself is the keyboard surface.
no aria-pressedlawNo control carries a pressed state — a carousel is action + position; selection is toggle-group's contract.
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h