Motivation
carousel is the first value to graduate into the canonical pool under ADR-018's rule — it already has 3+ identified consumers (feature, testimonial, pricing, cast, beyond today's gallery). A carousel is not a distinct kind of content; it is the same homogeneous items on a scroll-snap track. That makes it a layout mode, not a wrapper rune — the same shape as tabs/accordion/datatable: identical content, a progressive-enhancement behavior layer.
Today the capability exists only in gallery and is coupled to that block, so it cannot be reused as-is.
Current state
gallery (packages/runes/src/tags/gallery.ts, config in packages/runes/src/config.ts Gallery) already:
- accepts
layout (grid | carousel | masonry) and emits data-layout via a meta modifier; - emits an items container
[data-name="items"] holding [data-name="item"] figures.
The behavior (packages/behaviors/src/behaviors/gallery.ts, galleryBehavior) is the part that is not shareable:
- it is registered/scoped to
[data-rune="gallery"]; setupCarousel triggers on el.getAttribute('data-layout') === 'carousel', finds [data-name="items"]/[data-name="item"], and injects nav buttons with hard-coded rf-gallery__nav classes;- the mechanism itself is small: a CSS scroll-snap track plus two buttons that
scrollBy one item-width, plus arrow-key handling. The heavy lifting is already CSS.
So the carousel concept is sound and proven; only its binding is gallery-specific.
Design
The work has two phases.
Phase A — generalise the behavior + define the contract
Add carousel to the canonical const (ADR-018), so adopting runes import the same token.
Define the shared carousel DOM contract that layout="carousel" implies:
- a host element carrying
data-layout="carousel" (already the engine's output for a layout modifier); - a track container marked with an agreed
data-name (generalise gallery's [data-name="items"]); - item elements marked with an agreed
data-name (generalise [data-name="item"]). Document this contract alongside the canonical token so every adopting rune emits the same shape.
Lift the carousel behavior out of gallery into a standalone, block-agnostic behavior bound on [data-layout="carousel"] (not [data-rune="gallery"]). Nav chrome moves to block-relative or shared rf-carousel__* classes rather than rf-gallery__nav. gallery keeps its lightbox behavior and now consumes the shared carousel behavior for its carousel layout, leaving its rendered output unchanged.
Collapse-to-carousel target (CSS-first). Building on SPEC-099's collapse semantics, allow a rune's collapsed (mobile) form to be a carousel instead of a stack. The arrangement flip (grid → scroll-snap row at the breakpoint) is CSS-only and nearly free. The behavior's nav-button chrome is explicit-desktop only (layout="carousel"); the responsive collapse path relies on native touch/trackpad scroll and does not mount the JS affordances. This deliberately avoids a matchMedia mount/unmount lifecycle in the behavior layer; that complexity is only taken on if a concrete need for buttons on the collapsed mobile carousel later appears (a non-goal here).
Carousel scope: always-on vs collapse-to (decision)
layout="carousel" and "grid on desktop → carousel on mobile" are two distinct intents and must not be the same switch — conflating them would leave no way to express a desktop carousel. They are kept orthogonal:
layout="carousel" is an all-viewport carousel — the carousel is the design, at every width. It degrades gracefully: when the items fit their container it renders as a static row and only engages scroll + the desktop JS nav on overflow (so a wide desktop with few items is not a forced slider). It is a deliberate, explicit opt-in — never automatic — because an auto-carousel on desktop carries discoverability/accessibility cost.- "grid desktop → carousel mobile" is a separate composition: a
grid/list base layout plus a new collapse-to dial — collapse-to="stack | carousel", default stack. Below the rune's existing collapse breakpoint the collapsed form is a scroll-snap row instead of a stacked column (the CSS-only flip from A.4), with no JS nav on the responsive path.
collapse-to is the collapsed form and is orthogonal to collapse (the breakpoint); it is therefore not the "second layout-collapse attribute" SPEC-099 ruled out (that was a second breakpoint). With layout="carousel", collapse-to is moot (already a carousel at all widths). Canonical author surface:
{% feature layout="grid" collapse="md" collapse-to="carousel" %} <!-- grid above md, swipe row below -->
{% feature layout="carousel" %} <!-- carousel at every width -->