RFC-0027: Carousel
- Status: Accepted (2026-10-17; the directions were decided in the companion research)
- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-17
- Related:
- RFC-0020 (surfaces), RFC-0024 (motion levels, the page-wide pause), RFC-0025 (overlays,
Lightbox), RFC-0026 (icons), RFC-0028 (image fit, planned); docs/design/carousels.md, the research: platform status, the APG pattern, the evidence on auto-rotating heroes, libraries compared and the decisions taken here (sources there).
- RFC-0020 (surfaces), RFC-0024 (motion levels, the page-wide pause), RFC-0025 (overlays,
Summary
A Carousel catalog component: its children are slides, in four variants (hero, rail, gallery, testimonials). It works without JavaScript (a scroll-snap track, markers as real links) and a small first-party module adds buttons, position, announcements and, for heroes only and off by default, guarded autoplay. The store enforces the evidence: few hero slides, no autoplay by default, no carousel inside a carousel.
Motivation
- Heroes, product and logo rails, testimonials and galleries are among the most requested landing-page parts; agents have only
Marquee(a moving strip, not navigable) andLightbox. - Carousels are easy to get wrong: auto-rotation nobody can stop, slides hidden from keyboard users, layout shift, a lazy first image. Making them data the store checks lets every site get them right.
Proposal
1. The component
{ "component": "Carousel", "props": { "label": "Featured stories", // required; never the word "carousel" "variant": "hero" | "rail" | "gallery" | "testimonials", "perView": 1 | 2 | 3 | 4, // rail; absent = as many as fit (itemWidth) "itemWidth": "s" | "m" | "l", "peek": "none" | "s" | "m", // a part of the next slide shows "align": "start" | "center", "gap": "none" | "s" | "m" | "l" | "xl", "aspect": "16:9" | "21:9" | "4:3" | "1:1" | "3:4", // required for hero and gallery "controls": "both" | "arrows" | "markers" | "none", "markers": "numbers" | "dots" | "labels" | "thumbs", "loop": true, // hero only (27d) "autoplay": "off" | "slow" | "normal" // hero only, off by default (27d) }, "children": ["slide-1", "slide-2", "…"] }2. Markup (no JavaScript needed)
<section role="region" aria-roledescription="carousel" aria-label>around a focusable scroll-snap track; each slide agroupwitharia-roledescription="slide"and a name (“2 of 5”).- Markers are
<nav>links to the slides (scroll-target-group: autogives the current one a style in Chromium); arrow buttons are rendered hidden for the module to reveal, so their place is kept. - Hero slide 1’s images load eagerly with high priority; every other slide’s images are lazy.
3. The module (27b)
/_cloudhal/carousel.1.js, loaded only with a carousel: prev/next (by a slide or a view, aware of the ends and RTL), the current position (scrollend or IntersectionObserver) on markers and a “2 / 5” counter, marker clicks that scroll the track instead of jumping the page, a polite announcement after the reader moves, inert off-screen hero slides, and the guarded autoplay of 27d. No scroll hijacking.
4. Rules the store enforces
- Slides: hero 2–5, rail and testimonials 2–12, gallery up to 24.
aspectis required for hero and gallery;controls: noneonly on a rail with a peek.- No carousel inside a carousel, a
Marqueeor aScrolly; overlays are never slides. - The label never contains “carousel” (assistive technology already says it).
- (27d) Autoplay only on a hero, at most one per page, at least 5 s and long enough for the longest slide’s text; never below motion
standard, under reduced motion or while the reader has paused motion; it stops for good on any interaction and always has a rotation button first.
5. Checks
render_page adds carousel-label (named carousel and slides) and carousel-lcp (hero slide 1 not lazy, other slides lazy) in 27a; focus, CLS and autoplay checks with the module.
Invariants
- Every slide is reachable without JavaScript, by scrolling and by the marker links.
- Nothing moves on its own unless the site asked for it, motion allows it, and the reader can stop it.
- Slides keep one frame size: nothing shifts as the reader moves.
Alternatives considered
| Option | Pros | Cons | Why not |
|---|---|---|---|
Native ::scroll-button() / ::scroll-marker |
No script at all | Chromium only; markers exposed as tabs without panels | Anchors until the accessibility settles and Safari/Firefox ship |
| A library (Splide, Embla, Swiper) | Mature behaviour | 6–20 KB, third party, transform-based | Native scroll plus ~4 KB of our own |
Extend Lightbox into a gallery |
One component | Different job (a viewer, not a strip) | A gallery may open a Lightbox later |
| A JSON Schema contract change | Rust mirrors it | Like RFC-0024/0025 components, the catalog checks props | Catalog-only |
Compatibility & migration
New component only; nothing existing changes. Rollback: the catalog entry and its renderer.
Security & policy impact
None new: props are enums and short text; the module is first-party under script-src 'self'.
Rollout
| Phase | Scope |
|---|---|
| 27a | Rail and hero without JavaScript: catalog, rules, rendering, CSS, image loading, label and LCP checks |
| 27b | carousel.1.js: buttons, position, announcements, inert fallback, markers that scroll; focus and CLS checks |
| 27c | gallery (thumbnails) and testimonials |
| 27d | Hero loop and autoplay with every guard; the autoplay check |
| 27e | Theme hero areas accept a hero carousel; DESIGN.md, get_design and the build eval |
Open questions
None; docs/design/carousels.md decided them.
Status
- 27a shipped.
- Catalog.
Carouselwith variantsheroandrail;galleryandtestimonialscome in 27c. Props:label,perView,itemWidth,peek,align,gap,aspect,controls, andmarkers(numbers or dots). - Rules.
- Hero 2–5 slides, rail 2–12.
- A hero needs an
aspect. controls: noneonly on a rail with a peek.- The label never says “carousel”.
- No overlay as a slide; no carousel inside a Carousel, Marquee or Scrolly.
catalogPromptprefers a static hero.
- Markup.
- A
regionwitharia-roledescription(Turkish too), a focusable scroll-snap track, and each child wrapped as a named slide (“2 of 5”) byslideWrapper, in both renderers. - Markers are links to the slides;
scroll-target-groupand:target-currenthighlight the current one in Chromium. - Arrows are rendered with
data-ch-pending(visibility: hidden, their place kept) for the 27b module. - Hero slide 1’s images load with priority; the others stay lazy.
- A
- CSS.
perViewand peek through custom properties.- Container queries cap
perViewat 2 in a medium carousel and 1 in a narrow one. - A hero is full width; one frame per slide; wide frames become 4:5 in a narrow carousel.
- Smooth scrolling only where motion plays.
- Checks.
render_pageaddscarousel-labelandcarousel-lcp. It now remembers which images the page asked to be lazy before it loads them all. - Known gap (closed in 27e). A transparent header read the hero’s first slide only; later slides with other surfaces could differ.
- Catalog.
- 27b shipped.
- Module.
/_cloudhal/carousel.1.jsis 972 B gzipped and loads only with a carousel.- The arrows appear. They move by a slide (hero) or by what is in view (rail), through
scrollIntoView, so right-to-left and the site’sscroll-behaviorhold. At the ends they carryaria-disabled. - The current slide is measured from the slides’ boxes on
scroll/scrollend. It shows on its marker (aria-current). - Marker clicks scroll the track without jumping the page or changing the hash.
- A polite live region names the slide after the reader moves.
- A hero’s off-screen slides are
inert. - No wheel or drag handling.
- The arrows appear. They move by a slide (hero) or by what is in view (rail), through
- Check.
render_pageaddscarousel-focus: the arrows must have appeared (the module ran), and no focusable element may sit in an off-screen hero slide unless it is inert. - Design note. Position is measured from geometry rather than an IntersectionObserver. Hidden tabs (and the preview pane) pause observers; geometry stays correct after any move, and a short timer catches moves that cause no scroll.
- Module.
- 27c shipped.
- Variants.
gallery: up to 24 slides, each holding anImage. Anaspectis required. The image shows whole (contain) on a muted field until RFC-0028’s blur-fill.testimonials: 2–12 slides, one quote centred, the neighbours’ edges showing.
- Thumbs.
markers: "thumbs"is allowed only on a gallery. Each thumbnail is the slide’s first image, decorative (alt="") inside a link named “Slide N”. - Module.
carousel.2.js(carousel.1.jskept as an alias) makes off-screen gallery slides inert too, and moves a gallery by one slide.
- Variants.
- 27d shipped.
- Props.
loopandautoplay(off,slow8 s,normal6 s), on a hero only. - Rules.
- At most one autoplaying carousel per page.
- The longest slide’s text must fit the interval: at most 3 words per second (24 words for slow, 18 for normal). Otherwise the store asks for shorter text,
slow, or no autoplay.
- Markup. A rotation button comes first in the carousel, before the track. It is shown at the start of the controls row, clear of a transparent header, and stays hidden (
data-ch-pending) until the module allows motion. - Module.
carousel.3.js(carousel.1.jsandcarousel.2.jskept as aliases).- A looping hero wraps at its ends.
- Autoplay runs only while the site’s motion is
standardorexpressive, without reduced motion or the page-wide pause, in a visible tab, and with the carousel in view. - It pauses on hover. It stops for good on focus inside, a pointer press or a swipe; the rotation button restarts it.
- The live region is silent while it plays.
- Check.
render_pageaddscarousel-autoplay: the rotation control is the first focusable element and is named, and only one carousel per page plays.carousel-focusnow looks only at the arrows: a rotation control that stays hidden under reduced motion is correct.
- Props.
- 27e shipped.
- Theme hero areas.
heroSurfacenow reads a heroCarouselthat opens a page. The header turns transparent only when every slide opens with the same loudBand, so it reads on whichever slide shows. This closes 27a’s known gap.- Under a transparent header, each slide’s band keeps the header’s room above its content.
- A band holding only a picture fills what its padding leaves.
- Guidance.
get_design(DESIGN_GUIDANCE) and the starter’s DESIGN.md say when a hero carousel is right: equal offers, 2–5 slides, the strongest first, the same loud band under a transparent header, autoplay rarely and slow. They also say which variant fits logos, photographs and quotes.
- Eval.
- The design rubric’s lowest motion anchor names a carousel that rotates with no way to stop it.
CAROUSEL_EVAL_BRIEFS(offers,logos; run with--briefs offers,logos) test that the model picks a hero carousel and a rail.
- Theme hero areas.