Skip to content

spec(Carousel): opt-in snap paging through useSnapPaging - #9666

Open
cixzhang wants to merge 4 commits into
mainfrom
spec/carousel-snap-paging
Open

cixzhang wants to merge 4 commits into
mainfrom
spec/carousel-snap-paging

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Exact decision needed

How a Carousel opts into touch paging, what a plain snapping Carousel ships, and what one page is on a carousel that shows several items. All three are decided: one prop, hasSnap={useSnapPaging()}, and a page is one view.

Existing authority searched

component:Carousel; spec:AST-094 FR3, FR6 and DEC-25 (the pager's paging rule); component:Lightbox FR7 (gallery paging); spec:AST-057 FR1 and FR10 (the swipe={createSwipe()} config prop); spec:AST-107 FR6 and architecture:public-component-api INV22 (hook plus create… factory); architecture:react-component-runtime INV13 (no lazy import()). Open pull requests #8146 (internal pager) and #8153 (Carousel adopting it under hasSnap) are the overlapping work: this record makes that paging opt-in. No current claim decides the opt-in, its name, or the page size.

Decision

  • One prop, hasSnap, whose type widens from boolean to boolean | SnapPaging (FR5). An additive patch: no rename, deprecation, or codemod.
    • hasSnap omitted or false: no snapping.
    • hasSnap / hasSnap={true}: native CSS scroll snap, exactly as released, with no pager code (FR6).
    • hasSnap={useSnapPaging(options?)}: snap paging on the same per-item stops (FR7). createSnapPaging(element, options?) is the non-React path (FR9).
  • A flick moves one view by default, settling on the item start edge nearest one viewport on; the last page rests where the row ends. {pages: 'item'} moves one item per flick. Under right-to-left, pages run from the inline start. The item option's feel is not yet measured against UIKit's group paging (evidence gap in FR7).
  • Only the useSnapPaging / createSnapPaging module imports the pager, tree-shaken by module boundary with no lazy import(), so a Carousel without the paging config bundles none of the pager's roughly 40 KB (FR8).
  • Native snap does not depend on Chromium-only carousel CSS (::scroll-button(), ::scroll-marker, scroll-state queries, snap events); those may only be behavior-neutral @supports extras (FR6).
  • hasSnap's third state deviates from the on/off rule for is… / has… booleans in architecture:public-component-api INV3. The owner accepted it on 2026-11-02 to keep one snap prop; it is listed with that record's known deviations.
  • DEC-2 records the owner's decisions: the hook and its name, the single hasSnap prop, the rule deviation, the page size, and the useSnap<Variant> / createSnap<Variant> naming family.
  • Lightbox's gallery shares the pager's rule but not the config: it drives its own full-width row.

Changed records

  • component:Carousel: at-a-glance, compatibility, ownership, public concepts (hasSnap as boolean | SnapPaging, SnapPaging, SnapPagingOptions with pages), FR5–FR9, representative states, verification map (finger and iPad-style trackpad, default and {pages: 'item'}, preview stories, bundle test), DEC-2, open questions (none).
  • spec:AST-094: FR3's paging paragraph and DEC-25 name a Carousel given hasSnap={useSnapPaging()}.
  • architecture:public-component-api: a known-deviation entry for Carousel hasSnap under INV3.

Rejected alternatives

  • One hook with a variant option: every variant's code would ship with any import.
  • A separate paging prop beside hasSnap: two props would describe one choice.

Claim scope and non-goals

Snapping and snap paging on Carousel only. Looping, mouse-drag paging, autoplay, dots and slide semantics stay uncontracted.

Evidence and implementation relationship

Implementation follows in #8146 and #8153, which will move paging behind useSnapPaging and add view paging as the default.

Writing rubric results

  • R1 narration: none; the DEC dates the owner's decisions and states them in the present.
  • R2 research in the record: browser-support findings reduced to the decision's reasons in DEC-2; no tables or sources in the record.
  • R3 evidence naming a change: none.
  • R4 rejected alternatives over one line or outside a decision: none in the record.
  • R5 pull request or issue references remaining: none in the records.
  • R6 ruled open questions still listing options: none; the prop and page-size questions are answered, folded into FR5, FR7 and DEC-2, and removed.

Scope

  • This PR contains specification records only.
  • It records observable behavior and preserves equivalent internal implementations.
  • Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract.
  • Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
  • It records one intentional durable decision, not a rescue for a separable tagalong.
  • The canonical knowledge owner, affected current claims, and overlapping open work are named; any ownership collision is resolved or called out for human decision.
  • Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
  • Public text and artifacts contain no internal Meta context.

Validation

  • pnpm install --frozen-lockfile
  • pnpm check:knowledge
  • Prettier
  • git diff --check

@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 3, 2026 1:48am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 3, 2026
@astracat-bot

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Nov 3, 2026, 2:08 AM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spec needs an engineering owner's decision before it can be approved, and some record fixes are needed either way.

Approval waits on an engineering owner approving the new Carousel snap contract (FR5–FR10, AR3, DEC-2, DEP-0018, CLN-0029) and the spec:AST-094 FR3 and DEC-25 edits, ruling OQ1 (is a snap-paging page one item or one view?), and deciding whether snap: boolean | SnapPaging stands as an exception to the <thing>Behavior rule in architecture:public-component-api INV2/INV3. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

Blocking:

  • Carousel FR7 turns snap paging off for a fine-pointer trackpad, but spec:AST-094 FR43, which this pull request leaves unchanged, says gestures attach for every pointer and a pager follows a trackpad swipe, so FR7 or FR43 needs to change.
  • spec:AST-107 FR4 still names hasSnap as Carousel's path and keeps everything else in the gesture and motion modules internal, so it needs amending for snap, useSnapPaging and createSnapPaging.
  • FR6 requires scrollend, IntersectionObserver and inline mandatory, which shipped Carousel does not use (it reads useScrollOverflow and sets x mandatory), so please state the observable rule instead and drop "from an effect" from FR8.
  • review_triggers lists api and bundle, which are not supported triggers and are dropped by the routing tools, so use public-api.
  • FR7 is marked proposed inside a current record; once OQ1 is ruled, mark it settled and fold OQ1 into DEC-2.

Smaller: DEC-2 narrates the decision ("On 2026-11-02 the owner decided…") and carries survey sentences and an unsourced "about 40 KB" figure, which the writing rubric moves out of the record.

[Automated review]

| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
| FR5 | `snap` takes `boolean \| SnapPaging`. Absent or `false`: the scroll area does not snap. `true` (written `snap`): native snap, FR6. A `SnapPaging` value: snap paging, FR7. `SnapPaging` is opaque and made only by `useSnapPaging`. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: `scroll-snap-type: inline mandatory` on the scroll area, `scroll-snap-align: start` on each direct child, and `scroll-padding` matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The edge buttons scroll with `scrollBy`, and the current position comes from `scrollend` and `IntersectionObserver`. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |
| FR7 | Snap paging uses native snap's stops (FR6): each direct child's start edge, measured from the inline start and mirrored in right-to-left layout, with the last stops clamped where the row ends so no empty space shows. A gap sits between stops and is never a stop. Under a finger, and under a trackpad where the primary pointer is coarse, a release picks its rest stop by `spec:AST-094` FR3's paging rule, with one stop as one page: at most one stop from the stop the drag started on. The settle starts as `spec:AST-094` FR6's paging paragraph says. Where the primary pointer is fine, a mouse or trackpad keeps native snapping. An edge button steps about one view: the viewport width less half an item, landing on the nearest stop and moving at least one stop. Open question OQ1 asks whether one page stays one item. | DEC-2; `spec:AST-094` FR3, FR6, DEC-25 | proposed |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This turns snap paging off for a fine-pointer trackpad, but spec:AST-094 FR43 (unchanged here) says "Gestures attach for every pointer, a mouse, a pen, and a finger, not only a coarse primary pointer" and "A pager follows a trackpad swipe". Either follow FR43's trackpad pager path here, or amend FR43 with this exception and its reason. The FR7 verification row ("a fine-pointer scroll that runs the pager fails") needs the same decision.

| FR3 | The handoff happens once per edge transition, and only while the edge button held focus. An edge reached by swipe, resize, or wheel while focus is elsewhere moves nothing. | `architecture:interaction-modality` INV8 | settled |
| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
| FR5 | `snap` takes `boolean \| SnapPaging`. Absent or `false`: the scroll area does not snap. `true` (written `snap`): native snap, FR6. A `SnapPaging` value: snap paging, FR7. `SnapPaging` is opaque and made only by `useSnapPaging`. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: `scroll-snap-type: inline mandatory` on the scroll area, `scroll-snap-align: start` on each direct child, and `scroll-padding` matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The edge buttons scroll with `scrollBy`, and the current position comes from `scrollend` and `IntersectionObserver`. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shipped Carousel reads its edges from useScrollOverflow (a scroll listener plus the shared resize observer) and sets scrollSnapType: 'x mandatory', so pinning scrollend, IntersectionObserver and inline mandatory makes today's hasSnap non-conforming, with no gap listed. Stating the observable rule instead (edge-button state follows every scroll, momentum and programmatic included) keeps the equivalent implementation valid.

approved_at: 2026-10-22
owners: [cixzhang]
review_triggers: [behavior, accessibility]
review_triggers: [behavior, accessibility, api, bundle]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

api and bundle are not supported review triggers: pnpm specs:for drops them, and the baseline routing script rejects api. The supported name is public-api, so [public-api, behavior, accessibility] routes what this change meant to route.

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 1bd9215

⏳ Building… Until it's ready, Storybook · Sandbox open the branch's previous preview.

Current run: Heavy CI deferred: runs when marked ready and before merge. Lint, typecheck and any path-filtered checks ran on this push.


Generated by PR Enrichment workflow | View current CI run

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 1bd9215

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spec is owner-approved at this head; two record fixes are still needed before it merges.

Blocking:

  • Carousel FR6 requires the position to come from scrollend and IntersectionObserver (with inline mandatory), which shipped Carousel does not use, while FR5 and the at-a-glance table say native snap is unchanged and no gap is listed, so please state the observable rule (or list the gap) so today's hasSnap stays conforming and no second position tracker is needed.
  • review_triggers lists api and bundle, which the routing tools reject or drop, so please use public-api.

Smaller: the new INV3 entry names only the third state, not the INV2 union rule or the per-device paging value; DEC-2 and that entry still narrate the decision ("On 2026-11-02 the owner decided…"); and the "about 40 KB" figure has no measurement behind it.

[Automated review]

| FR3 | The handoff happens once per edge transition, and only while the edge button held focus. An edge reached by swipe, resize, or wheel while focus is elsewhere moves nothing. | `architecture:interaction-modality` INV8 | settled |
| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
| FR5 | `hasSnap` takes `boolean \| SnapPaging`. Omitted or `false`: the scroll area does not snap. `true` (written `hasSnap`): native snap, FR6, exactly as released, with no gesture code. A `SnapPaging` value, made only by `useSnapPaging`: snap paging, FR7; the carousel snaps on native snap's per-item stops and the pager settles on those same stops. `true` never pages. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. The third state is a known deviation from `architecture:public-component-api` INV3, which keeps a `has…` boolean to a true on/off (DEC-2). | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: `scroll-snap-type: inline mandatory` on the scroll area, `scroll-snap-align: start` on each direct child, and `scroll-padding` matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The edge buttons scroll with `scrollBy`, and the current position comes from `scrollend` and `IntersectionObserver`. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shipped Carousel sets x mandatory and reads its edges from the shared useScrollOverflow hook (a scroll listener plus the shared resize observer, also used by TabList and Table sticky columns), so pinning inline mandatory, scrollend and IntersectionObserver here makes today's hasSnap non-conforming even though FR5 calls it "exactly as released". Stating the observable rule (edge-button availability follows every scroll, momentum and programmatic included, and every resize) keeps the shared tracker valid; otherwise list the difference as an implementation gap.

This branch was successfully deployed

1 active deployment
Preview — 1bd92151 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant