Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a63a035
feat(core): a pan takes wheel and trackpad scrolling, and a pinch a c…
vjeux Oct 31, 2026
673e731
feat(gestures): a trackpad scroll ends where its fingers lift, uncert…
vjeux Oct 31, 2026
837bfde
feat(gestures): a followed trackpad pan keeps moving at the fingers' …
vjeux Oct 31, 2026
addbe4b
fix(gestures): a pan follows a trackpad only where its owner allows i…
vjeux Oct 31, 2026
8edb2ef
fix(gestures): a followed pan's momentum after the lift stays prevented
vjeux Nov 2, 2026
b5e6cd5
fix(gestures): a kept trackpad stream waits out FR43's pause bound, a…
vjeux Nov 2, 2026
15e75a0
fix(gestures): a pan detached or disabled mid-swipe releases at its l…
vjeux Nov 2, 2026
2512632
fix(gestures): the wheel latch hears wheels through the shared input …
vjeux Nov 2, 2026
b132538
docs(gestures): the wheel end windows cite FR43 as the record states …
vjeux Nov 2, 2026
2071e89
chore(gestures): the wheel module's comments and README describe it a…
vjeux Nov 2, 2026
442fa25
fix(gestures): a window blur or a hidden page cancels a trackpad pan
vjeux Nov 2, 2026
e8c3a28
fix(gestures): a pan reads a trackpad's direction by its own rule, ax…
vjeux Nov 2, 2026
1a0cd94
fix(gestures): a cancelled trackpad pan leaves the rest of its stream…
vjeux Nov 2, 2026
319f362
chore(core): UIKit's paging values and math, measured on iOS 26.2
vjeux Oct 31, 2026
a211ae8
chore(core): a pager that pages like a UIKit paging scroll view
vjeux Nov 1, 2026
34195d2
feat(core): a snapping Carousel pages under a finger, with no new prop
vjeux Oct 29, 2026
9745d22
chore(preview): pager sandbox stories, preview build only
vjeux Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/carousel-paging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@astryxdesign/core': patch
---

[feat] Carousel: under a finger, a `hasSnap` carousel with its buttons pages like an iOS paging scroll view. Each swipe moves one child and settles on that child's start edge on UIKit's paging curve; the content follows the finger and rubber-bands at the ends, even while JavaScript is busy. A mouse, a trackpad and the keyboard keep native snapping, and so does a finger without the buttons, because the buttons are the single-pointer way to page.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Under a finger with buttons the keyboard does not keep native snapping: useCarouselPaging takes the arrow keys, Home and End (asserted in CarouselPaging.test.tsx, 'arrow keys move one child, Home and End to the ends').

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Under a finger with the buttons the keyboard does not keep native snapping: useCarouselPaging takes the arrow keys, Home and End, and scrollLeft stays 0. Suggest: a mouse and a trackpad keep native snapping; under a finger with the buttons the arrow keys move one child.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Under a finger with the buttons the keyboard does not keep native snapping: useCarouselPaging takes the arrow keys, Home and End, and scrollLeft stays 0. Suggest: a mouse and a trackpad keep native snapping; under a finger with the buttons, the arrow keys, Home and End move one child.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Under a finger with the buttons, the keyboard does not keep native snapping: useCarouselPaging takes the arrow keys, Home and End, and scrollLeft stays 0. Suggest: a mouse and a trackpad keep native snapping; under a finger with the buttons, the arrow keys, Home and End move one child.


This changes existing `hasSnap` carousels on touch screens. A fast swipe now moves one child instead of coasting across several, and while the touch pager runs the content does not scroll natively: it fires no `scroll` events and its `scrollLeft` stays 0. To keep native scrolling under a finger, render the carousel with `hasButtons={false}`. The buttons and the handle keep their step of about one viewport, landing on a child; with `hasLoop`, Next on the last child returns to the first, as it already did with a pointer. Under Reduce Motion a programmatic scroll cross-fades on a coarse pointer and jumps at once on any other.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/gestures-wheel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: a pan follows a trackpad's two-finger scroll where its owner allows it (`allowedScrollTypes: ['continuous']`, UIKit's `allowedScrollTypesMask`; none by default), along its own axis and after the scrollers under the pointer, and never a mouse wheel's notches, which scroll what is under the pointer; the screen-edge pan takes none; and a pinch takes a ctrl-wheel from a trackpad or a mouse (AST-094 FR43, DEC-24). A watcher pan never takes the wheel, and a wheel another handler already prevented stays the page's. Input the deltas cannot place, Shift+wheel included, reads as a mouse wheel; a trackpad scroll a pan follows keeps moving at the fingers' speed until their lift is read, ends there still moving, and the momentum never moves it. Not exported from the package entry point.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/uikit-pager.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: a pager for touch presentations that pages like a UIKit paging scroll view. A horizontal pan moves the pages one to one and rubber-bands at the ends. The release picks the page with UIKit's rule and settles on UIKit's paging curve on the compositor, and a finger can catch it mid-flight. It takes every pointer: a mouse drags it as a finger does, and its pan follows a trackpad's horizontal scroll, at most one page per swipe. A mouse wheel's scroll stays the page's. Its motion values are internal constants. Not exported from the package entry point.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/uikit-paging-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: UIKit's paging values and math for touch presentations. The thresholds that pick the page a release settles on, the settle that carries it there and `setContentOffset(animated:)` are measured on iOS 26.2 and replayed against the recorded frames to under half a pixel. Not exported from the package entry point.

@vjeux
141 changes: 140 additions & 1 deletion apps/storybook/stories/Carousel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const styles = stylex.create({
width: 160,
flexShrink: 0,
},
pagingCard: {
width: 200,
flexShrink: 0,
},
cardInner: {
padding: spacingVars['--spacing-3'],
},
Expand Down Expand Up @@ -59,8 +63,68 @@ const styles = stylex.create({
marginBottom: spacingVars['--spacing-2'],
fontFamily: typographyVars['--font-family-body'],
},
// One page per item: the pages are as wide as their inline-size container.
pages: {
maxWidth: 400,
containerType: 'inline-size',
},
page: {
width: '100cqw',
boxSizing: 'border-box',
height: 220,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
gap: spacingVars['--spacing-1'],
padding: spacingVars['--spacing-4'],
borderRadius: radiusVars['--radius-element'],
color: '#fff',
fontFamily: typographyVars['--font-family-body'],
},
pageTitle: {
margin: 0,
fontSize: 22,
fontWeight: 700,
color: 'inherit',
},
pageBody: {
margin: 0,
fontSize: 14,
color: 'inherit',
},
longPageSpacer: {
height: 120,
},
longPageTail: {
height: 1600,
},
});

const PAGES = [
{title: 'Plan', body: 'Pick a place and the dates.', color: '#3a6ea5'},
{title: 'Pack', body: 'A checklist for every trip.', color: '#527e51'},
{title: 'Go', body: 'Tickets and directions offline.', color: '#b05f34'},
{title: 'Share', body: 'Photos and notes for the group.', color: '#7a4f9a'},
{title: 'Remember', body: 'Your trips, a year later.', color: '#3a797f'},
];

function PagePanel({
title,
body,
color,
}: {
title: string;
body: string;
color: string;
}) {
return (
<div {...stylex.props(styles.page)} style={{backgroundColor: color}}>
<h3 {...stylex.props(styles.pageTitle)}>{title}</h3>
<p {...stylex.props(styles.pageBody)}>{body}</p>
</div>
);
}

const IMAGES = [
{id: 1, src: 'https://picsum.photos/id/1042/200/200', label: 'dark.jpg'},
{id: 2, src: 'https://picsum.photos/id/1043/200/200', label: 'light.jpg'},
Expand Down Expand Up @@ -101,7 +165,8 @@ const meta: Meta<typeof Carousel> = {
},
hasSnap: {
control: 'boolean',
description: 'Enable scroll-snap',
description:
'Enable scroll-snap; under a finger, with the buttons, one child per swipe',
},
},
decorators: [
Expand Down Expand Up @@ -325,6 +390,80 @@ export const Loop: Story = {
),
};

export const Paging: Story = {
name: 'Paging (Snap, Full-Width Pages)',
render: () => (
<div {...stylex.props(styles.pages)}>
<p {...stylex.props(styles.label)}>
Under a finger each swipe moves one page and settles like an iOS paging
scroll view; a mouse, a trackpad and the keyboard snap natively
</p>
<Carousel hasSnap gap={2} aria-label="Trip planner">
{PAGES.map(page => (
<PagePanel key={page.title} {...page} />
))}
</Carousel>
</div>
),
};

export const PagingLoop: Story = {
name: 'Paging (Loop)',
render: () => (
<div {...stylex.props(styles.pages)}>
<p {...stylex.props(styles.label)}>
Next on the last page returns to the first; Prev on the first goes to
the last
</p>
<Carousel hasSnap hasLoop gap={2} aria-label="Trip planner, looping">
{PAGES.map(page => (
<PagePanel key={page.title} {...page} />
))}
</Carousel>
</div>
),
};

export const PagingInLongPage: Story = {
name: 'Paging (In a Long Page)',
render: () => (
<div {...stylex.props(styles.pages)}>
<p {...stylex.props(styles.label)}>
A vertical drag on the pages scrolls the page; a horizontal one pages
</p>
<div {...stylex.props(styles.longPageSpacer)} />
<Carousel hasSnap gap={2} aria-label="Trip planner in a long page">
{PAGES.map(page => (
<PagePanel key={page.title} {...page} />
))}
</Carousel>
<div {...stylex.props(styles.longPageTail)} />
</div>
),
};

export const PagingCards: Story = {
name: 'Paging (Cards)',
render: () => (
<div {...stylex.props(styles.constrainedWidth)}>
<p {...stylex.props(styles.label)}>
Under a finger each swipe moves one card; the last cards rest where the
row ends
</p>
<Carousel hasSnap gap={2} aria-label="Feature cards, paging">
{PAGES.map(page => (
<Card key={page.title} xstyle={styles.pagingCard}>
<div {...stylex.props(styles.cardInner)}>
<p {...stylex.props(styles.cardTitle)}>{page.title}</p>
<p {...stylex.props(styles.cardDesc)}>{page.body}</p>
</div>
</Card>
))}
</Carousel>
</div>
),
};

export const ImperativeControl: Story = {
name: 'Imperative Control (handleRef)',
render: function ImperativeControlStory() {
Expand Down
Loading