SPEC-100
ID:SPEC-100Status:accepted

Carousel as a shared layout mode

Promote carousel from a gallery-coupled behavior to a canonical layout mode (ADR-018) that any rune can opt into via layout="carousel". Generalise gallery's carousel into a block-agnostic, contract-bound behavior; define the shared track/item DOM contract behind the carousel token; then adopt it across the candidate runes, starting with feature.

Target: next minor (after SPEC-099).

Implements 1
Implemented by 9
Related 4

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

  1. Add carousel to the canonical const (ADR-018), so adopting runes import the same token.

  2. 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.
  3. 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.

  4. 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).

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