@excom/gesture-handler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.rush/temp/chunked-rush-logs/gesture-handler.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/gesture-handler.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/gesture-handler.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +6 -0
  15. package/gesture-handler.ts +1211 -0
  16. package/index.css +5 -0
  17. package/index.ts +29 -0
  18. package/package.json +52 -0
  19. package/rush-logs/gesture-handler.apply-exports.cache.log +1 -0
  20. package/rush-logs/gesture-handler.apply-exports.log +1 -0
  21. package/rush-logs/gesture-handler.build_docs.cache.log +1 -0
  22. package/rush-logs/gesture-handler.build_docs.log +1 -0
  23. package/rush-logs/gesture-handler.build_package-metas.cache.log +1 -0
  24. package/rush-logs/gesture-handler.build_package-metas.log +1 -0
  25. package/src/gesture-handler.css +207 -0
  26. package/support/custom-elements.json +989 -0
  27. package/support/demos/carousel.html +44 -0
  28. package/support/demos/pinch.html +10 -0
  29. package/support/demos/sheet.html +50 -0
  30. package/support/demos/swipe.html +24 -0
  31. package/support/dist-docs/gesture-handler.md +339 -0
  32. package/support/docs/README.md +92 -0
  33. package/support/package-meta.json +489 -0
  34. package/support/tests/carousel.view.test.ts +67 -0
  35. package/support/tests/gesture-handler.test.ts +625 -0
  36. package/support/tests/pointer-utils.ts +62 -0
  37. package/support/tests/sheet.view.test.ts +112 -0
  38. package/support/tests/swipe.view.test.ts +44 -0
  39. package/tsconfig.json +5 -0
@@ -0,0 +1,44 @@
1
+ <gesture-handler gesture-types="pan-x swipe" pointer-types="touch pen mouse" progress-axis="left"
2
+ range-ref=":scope > content-carousel" progress-min="-1" progress-max="1" snap-points="-1 0 1"
3
+ overshoot-resistance="0.2">
4
+ <quark-sheet>
5
+ :scope {
6
+ /* no wrap-around while dragging: clamp the ends */
7
+ &:has(content-carousel-slide[is-active]:first-of-type) { progress-min: 0; }
8
+ &:not(:has(content-carousel-slide[is-active]:first-of-type)) { progress-min: -1; }
9
+ &:has(content-carousel-slide[is-active]:last-of-type) { progress-max: 0; }
10
+ &:not(:has(content-carousel-slide[is-active]:last-of-type)) { progress-max: 1; }
11
+ @on gesture-handler-start {
12
+ content-carousel { is-scrubbing: ""; }
13
+ }
14
+ /* commit once the glide lands: the slide swap and the un-scrub paint together */
15
+ @on gesture-handler-snap {
16
+ /* snap-points "-1 0 1" → index 0 = previous, 1 = stay, 2 = next */
17
+ $snap: event.detail.index;
18
+ content-carousel {
19
+ is-scrubbing: none;
20
+ content-carousel-slide[is-active] + content-carousel-slide {
21
+ is-active: $snap == 2 or preserve;
22
+ }
23
+ content-carousel-slide:has(+ content-carousel-slide[is-active]) {
24
+ is-active: $snap == 0 or preserve;
25
+ }
26
+ content-carousel-slide[is-active] {
27
+ is-active: $snap == 1 or none;
28
+ }
29
+ }
30
+ }
31
+ }
32
+ </quark-sheet>
33
+ <content-carousel slide-animation="track">
34
+ <content-carousel-slide is-active>
35
+ <h2>One</h2>
36
+ </content-carousel-slide>
37
+ <content-carousel-slide>
38
+ <h2>Two</h2>
39
+ </content-carousel-slide>
40
+ <content-carousel-slide>
41
+ <h2>Three</h2>
42
+ </content-carousel-slide>
43
+ </content-carousel>
44
+ </gesture-handler>
@@ -0,0 +1,10 @@
1
+ <gesture-handler gesture-types="pan pinch rotate" pointer-types="touch pen mouse">
2
+ <figure>Drag, pinch, twist</figure>
3
+ <style>
4
+ #demo-gesture-handler-pinch > :first-child figure {
5
+ translate: var(--gesture-dx, 0px) var(--gesture-dy, 0px);
6
+ scale: var(--gesture-scale, 1);
7
+ rotate: var(--gesture-rotate, 0deg);
8
+ }
9
+ </style>
10
+ </gesture-handler>
@@ -0,0 +1,50 @@
1
+ <gesture-handler gesture-types="pan-y swipe" pointer-types="touch pen mouse" from-ref=":scope > content-drawer > header"
2
+ handoff-ref=":scope > content-drawer" progress-axis="up" range-ref=":scope > content-drawer" snap-points="0 1">
3
+ <quark-sheet>
4
+ :scope {
5
+ /* dragging an open sheet starts from fully open */
6
+ &:has(> content-drawer[is-open]) {
7
+ progress-offset: 1;
8
+ }
9
+ &:not(:has(> content-drawer[is-open])) {
10
+ progress-offset: 0;
11
+ }
12
+ @on gesture-handler-start {
13
+ content-drawer { is-scrubbing: ""; }
14
+ }
15
+ @on gesture-handler-end {
16
+ content-drawer { is-open: event.detail.snap == 1; is-scrubbing: none; }
17
+ }
18
+ }
19
+ </quark-sheet>
20
+ <button type="button" command="--open" commandfor="demo-gesture-sheet">Open sheet</button>
21
+ <content-drawer id="demo-gesture-sheet" class="absolute">
22
+ <header><h2>Drag me</h2></header>
23
+ <p>Two ways to dismiss it. Drag the header down and let go, or flick it —
24
+ that is the <code>from-ref</code> grab handle.</p>
25
+ <p>Or pull the text itself down: <code>handoff-ref</code> lets the sheet's
26
+ own content scroll first, and only once it is back at the top does the
27
+ next pull down become the same drag. Pulling up, or pulling down while
28
+ the text is scrolled, keeps scrolling as usual.</p>
29
+ <p>Scroll on. The sheet only takes the drag over at the very top, which is
30
+ how a native sheet behaves.</p>
31
+ <p>Keep going.</p>
32
+ <p>Nearly there.</p>
33
+ <p>Now scroll back up and pull once more — the sheet follows your finger
34
+ and snaps to <code>0</code> or <code>1</code> on release.</p>
35
+ </content-drawer>
36
+ <style>
37
+ #demo-gesture-handler-sheet > :first-child {
38
+ /* short enough that the text scrolls: the handoff needs a scroller */
39
+ content-drawer { max-block-size: 11rem; }
40
+ /* the drag handle owns its touches; the content keeps scrolling */
41
+ content-drawer > header {
42
+ position: sticky;
43
+ top: 0;
44
+ touch-action: none;
45
+ cursor: grab;
46
+ background-color: inherit;
47
+ }
48
+ }
49
+ </style>
50
+ </gesture-handler>
@@ -0,0 +1,24 @@
1
+ <gesture-handler gesture-types="pan-x swipe tap" pointer-types="touch pen mouse" range-px="240" progress-min="-1" progress-max="1"
2
+ snap-points="-1 0 1" overshoot-resistance="0.4">
3
+ <quark-sheet>
4
+ :scope {
5
+ @on gesture-handler-snap {
6
+ article { data-is-dismissed: event.detail.value != 0; }
7
+ }
8
+ @on gesture-handler-tap {
9
+ article { data-is-dismissed: none; }
10
+ }
11
+ }
12
+ </quark-sheet>
13
+ <article>
14
+ <h2>Swipe me away</h2>
15
+ <p>Flick left or right. Tap to bring it back.</p>
16
+ </article>
17
+ <style>
18
+ #demo-gesture-handler-swipe > :first-child article {
19
+ translate: calc(var(--gesture-progress, 0) * 240px);
20
+ rotate: calc(var(--gesture-progress, 0) * 8deg);
21
+ &[data-is-dismissed] { opacity: 0.25; }
22
+ }
23
+ </style>
24
+ </gesture-handler>
@@ -0,0 +1,339 @@
1
+ # gesture-handler
2
+
3
+ Let users drag, swipe, pinch and flick your UI — sheets, carousels, cards and images follow the finger, in CSS, with no per-frame script.
4
+
5
+
6
+ ```html
7
+ <gesture-handler gesture-types="pan-y swipe" pointer-types="touch pen mouse" from-ref=":scope > content-drawer > header"
8
+ handoff-ref=":scope > content-drawer" progress-axis="up" range-ref=":scope > content-drawer" snap-points="0 1">
9
+ <quark-sheet>
10
+ :scope {
11
+ /* dragging an open sheet starts from fully open */
12
+ &:has(> content-drawer[is-open]) {
13
+ progress-offset: 1;
14
+ }
15
+ &:not(:has(> content-drawer[is-open])) {
16
+ progress-offset: 0;
17
+ }
18
+ @on gesture-handler-start {
19
+ content-drawer { is-scrubbing: ""; }
20
+ }
21
+ @on gesture-handler-end {
22
+ content-drawer { is-open: event.detail.snap == 1; is-scrubbing: none; }
23
+ }
24
+ }
25
+ </quark-sheet>
26
+ <button type="button" command="--open" commandfor="demo-gesture-sheet">Open sheet</button>
27
+ <content-drawer id="demo-gesture-sheet" class="absolute">
28
+ <header><h2>Drag me</h2></header>
29
+ <p>Two ways to dismiss it. Drag the header down and let go, or flick it —
30
+ that is the <code>from-ref</code> grab handle.</p>
31
+ <p>Or pull the text itself down: <code>handoff-ref</code> lets the sheet's
32
+ own content scroll first, and only once it is back at the top does the
33
+ next pull down become the same drag. Pulling up, or pulling down while
34
+ the text is scrolled, keeps scrolling as usual.</p>
35
+ <p>Scroll on. The sheet only takes the drag over at the very top, which is
36
+ how a native sheet behaves.</p>
37
+ <p>Keep going.</p>
38
+ <p>Nearly there.</p>
39
+ <p>Now scroll back up and pull once more — the sheet follows your finger
40
+ and snaps to <code>0</code> or <code>1</code> on release.</p>
41
+ </content-drawer>
42
+ <style>
43
+ #demo-gesture-handler-sheet > :first-child {
44
+ /* short enough that the text scrolls: the handoff needs a scroller */
45
+ content-drawer { max-block-size: 11rem; }
46
+ /* the drag handle owns its touches; the content keeps scrolling */
47
+ content-drawer > header {
48
+ position: sticky;
49
+ top: 0;
50
+ touch-action: none;
51
+ cursor: grab;
52
+ background-color: inherit;
53
+ }
54
+ }
55
+ </style>
56
+ </gesture-handler>
57
+ ```
58
+
59
+
60
+ ## Features
61
+
62
+ - **Swipe / pan / pinch / rotate / tap / long-press** Each recognized gesture is a tag-prefixed event
63
+ - **Follow the finger in CSS** Every frame lands in `--gesture-*` custom properties: scrubbable bottom sheets, swipe-to-dismiss, pinch-to-zoom, pull-to-refresh, parallax
64
+ - **Snap & fling** `snap-points` with velocity projection, a CSS transition on release (`--gesture-snap-duration` / `--gesture-snap-ease`), `gesture-handler-snap` when it lands
65
+ - **Drivable** Bounds, `progress-offset` and `is-disabled` are attributes a Quark rule sets from the driven element's state
66
+ - **Scoped starts** `from-ref` for drag handles, `from-edge` for edge swipes
67
+ - **Scroll handoff** `handoff-ref` lets a sheet's own scrolling content take the drag over when it runs out of scroll — a native-feeling pull-to-close
68
+ - **Native scrolling kept** `touch-action` follows `gesture-types`, so the page still scrolls where you don't pan
69
+
70
+ ## Installation
71
+
72
+
73
+ `@excom/gesture-handler` v0.1.0
74
+
75
+ ```bash
76
+ pnpm add @excom/gesture-handler
77
+ ```
78
+
79
+ ```bash
80
+ npm install @excom/gesture-handler
81
+ ```
82
+
83
+ ```bash
84
+ yarn add @excom/gesture-handler
85
+ ```
86
+
87
+ ### Import
88
+
89
+ ```ts
90
+ import "@excom/gesture-handler";
91
+ ```
92
+
93
+
94
+
95
+ ## Usage
96
+
97
+ Wrap the surface the user touches. Pick the gestures with `gesture-types`; read the finger from `--gesture-*` in CSS (custom properties inherit, so any descendant can `var()` them); commit State on `gesture-handler-end` or `-snap` from a Quark `@on` block.
98
+
99
+ ```html
100
+ <gesture-handler gesture-types="pan-y swipe" progress-axis="up" range-ref=":scope > content-drawer" snap-points="0 1">
101
+ <quark-sheet>
102
+ :scope {
103
+ @on gesture-handler-start { content-drawer { is-scrubbing: ""; } }
104
+ @on gesture-handler-end { content-drawer { is-open: event.detail.snap == 1; is-scrubbing: none; } }
105
+ }
106
+ </quark-sheet>
107
+ <content-drawer>…</content-drawer>
108
+ </gesture-handler>
109
+ ```
110
+
111
+ `--gesture-progress` is the travel along `progress-axis` as a fraction of the range (`range-ref` measures the driven element; `range-px` is a literal), clamped to `progress-min`..`progress-max` with optional `overshoot-resistance`. Written alongside it every frame: `--gesture-dx` / `-dy`, `--gesture-x` / `-y`, `--gesture-scale`, `--gesture-rotate`, `--gesture-vx` / `-vy`, `--gesture-pointers`; once per gesture: `--gesture-range-px`, `--gesture-width` / `-height`. Everything else is derived from those in the element's own CSS — `--gesture-distance`, `--gesture-angle`, `--gesture-progress-px`, `--gesture-x-ratio` / `-y-ratio` — so `var()` them the same way.
112
+
113
+ Values persist after release until the next gesture starts. On release the default action writes `--gesture-progress` straight to `detail.snap` and the element's own `transition` settles it there over `--gesture-snap-duration` (`200ms`) with `--gesture-snap-ease` (`ease-out`), firing `gesture-handler-snap` when it lands; the transition is off while `is-active`, so the finger itself is never eased, and a new gesture that interrupts the settle simply cancels it (no `-snap`).
114
+
115
+ ### Driving Nucleus Kit elements
116
+
117
+ Elements that can be scrubbed expose a `--<tag>-…-progress` input and an `is-scrubbing` attribute; both default to the wrapping gesture-handler's `--gesture-progress`, so no mapping is needed:
118
+
119
+ | Element | Set up | While `is-scrubbing` |
120
+ | --- | --- | --- |
121
+ | `content-drawer` | `progress-axis` towards its open side, `range-ref` the drawer, `handoff-ref` the drawer too (it scrolls its own content) | Position follows `--content-drawer-open-progress` (`0` closed, `1` open), no transition |
122
+ | `content-carousel` | `slide-animation="track"`, `progress-min="-1" progress-max="1" snap-points="-1 0 1"` | The track follows `--content-carousel-progress` in slide widths |
123
+
124
+ The handoff is one Quark commit: the block that writes the final state (`is-open`, the active slide) also removes `is-scrubbing`, so the element switches from finger to State in the same paint. Commit on `-end` when a CSS transition should finish the motion (the drawer), on `-snap` when the element must be exactly at the snap point first (the carousel).
125
+
126
+ Anything else follows the same recipe: read `--gesture-*` in your own CSS, gate the mapping on a fact your sheet writes on start and clears on end.
127
+
128
+ Give a drag handle `touch-action: none` when using `from-ref`, so the browser does not scroll it away; without `from-ref` the element sets `touch-action` itself from `gesture-types`. Add `mouse` to `pointer-types` for desktop dragging.
129
+
130
+ ### Scroll handoff
131
+
132
+ `handoff-ref` names the scroll container(s) inside the surface whose *overscroll* starts a gesture (a `:scope`-relative selector; a comma list matches several). A pointer that goes down in one of them scrolls natively as usual. Only when its **first** move runs along `progress-axis`, the container is at its scroll limit that way, and `progress-offset` still has room to travel in that direction does the element cancel the native scroll for the rest of the touch and take the drag over — the same gesture, the same `--gesture-*` values and the same `-start` / `-end` / `-snap` events as a drag from a handle:
133
+
134
+ ```html
135
+ <gesture-handler gesture-types="pan-y swipe" progress-axis="up" snap-points="0 1"
136
+ from-ref=":scope > content-drawer > header" handoff-ref=":scope > content-drawer"
137
+ range-ref=":scope > content-drawer">
138
+ ```
139
+
140
+ An open sheet (`progress-offset: 1`) closes either from its header or by pulling its text down once the text is back at the top; pulling up, or pulling down mid-scroll, keeps scrolling. `handoff-ref` is additive — `from-ref` and `from-edge` starts are unchanged, and a `from-ref` handle inside a handoff container still starts on pointerdown. The element keeps `touch-action` out of the way while `handoff-ref` is set (the containers must be able to scroll), so give handles their own `touch-action: none`.
141
+
142
+ Mouse drags (`pointer-types="… mouse"`) take the same route with no native scroll to cancel: the first `pointermove` inside the container starts the gesture when the container is at its limit.
143
+
144
+ ### API Reference
145
+
146
+
147
+ #### Attributes
148
+
149
+ | Name | Surface | Type | Default | Values | Description |
150
+ | --- | --- | --- | --- | --- | --- |
151
+ | `gesture-types` | option | `tokenlist` | `"pan"` | `"pan"` \| `"pan-x"` \| `"pan-y"` \| `"pinch"` \| `"rotate"` \| `"swipe"` \| `"tap"` \| `"double-tap"` \| `"long-press"` | Gestures to recognize. `pan-x` / `pan-y` one axis (other stays native scroll); `pan` both; `pinch` / `rotate` need two fingers; `swipe` velocity on release; `tap` / `double-tap` / `long-press` fire events. |
152
+ | `from-ref` | option | `string` | | `<CSS Selector>` | Only start when pointerdown is inside this descendant (`:scope`-relative selector) — a drag handle, the sheet itself. Combined with `from-edge`, either qualifies. |
153
+ | `from-edge` | option | `tokenlist` | | `"left"` \| `"right"` \| `"top"` \| `"bottom"` | Only start within `edge-px` of these edges — edge swipes (back nav, pulling a closed sheet up). |
154
+ | `edge-px` | option | `number` | `40` | | Width of `from-edge` start zone (px). |
155
+ | `handoff-ref` | option | `string` | | `<CSS Selector>` | Scroll containers that hand overscroll to the gesture (`:scope`-relative selector, comma list matches several) — e.g. a sheet closed by pulling its own content down. Pointerdown inside one scrolls natively; gesture takes over only when first move runs along `progress-axis`, container is at that scroll limit, and `progress-offset` still has room that way. Additive to `from-ref` / `from-edge`. |
156
+ | `pointer-types` | option | `tokenlist` | `"touch pen"` | `"touch"` \| `"pen"` \| `"mouse"` | Pointer types that can start a gesture. Add `mouse` for desktop drag. |
157
+ | `max-pointers` | option | `number` | | | Extra pointers beyond this many are ignored. Unset = `2` when `pinch` / `rotate` listed, else `1`. |
158
+ | `threshold-px` | option | `number` | `8` | | Movement (px) before a pan is recognized. Taps / native scroll stay untouched below it. |
159
+ | `lock-axis` | option | `boolean` | | | A free `pan` locks to its dominant axis once recognized (`gesture-type` becomes `pan-x` / `pan-y`). |
160
+ | `arm-after` | option | `string` | | `"long-press"` | Pans only arm after this gesture — `long-press` for hold-then-drag. |
161
+ | `long-press-ms` | option | `number` | `500` | | Hold time (ms) for `long-press`; also tap time limit. |
162
+ | `double-tap-ms` | option | `number` | `300` | | Max gap (ms) between taps for `double-tap`. |
163
+ | `swipe-min-velocity` | option | `number` | `0.5` | | Release velocity (px/ms) that counts as a swipe. |
164
+ | `swipe-directions` | option | `tokenlist` | | `"left"` \| `"right"` \| `"up"` \| `"down"` | Swipe directions to report. Unset = all four. |
165
+ | `progress-axis` | option | `string` | | `"up"` \| `"down"` \| `"left"` \| `"right"` | Direction `--gesture-progress` grows. Unset = `down` for a `pan-y`-only element, else `right`. |
166
+ | `range-ref` | option | `string` | | `<CSS Selector>` | Element whose size along `progress-axis` is the range of `progress` `0`..`1` (`:scope`-relative, read once per gesture) — sheet being dragged, slide being swiped. |
167
+ | `range-px` | option | `number` | | | Literal range (px) instead of `range-ref`. |
168
+ | `progress-min` | option | `number` | `0` | | Lower bound of `progress`. |
169
+ | `progress-max` | option | `number` | `1` | | Upper bound of `progress`. |
170
+ | `progress-offset` | option | `number` | `0` | | Progress the gesture starts from. Set from a rule that reads the driven element's state (`1` while a sheet is open) so dragging it closed starts full. |
171
+ | `overshoot-resistance` | option | `number` | `0` | | Rubber-band past `progress-min` / `progress-max`: `0` clamps, `0.3` overshoots at a third of travel. |
172
+ | `snap-points` | option | `tokenlist` | | `<number>…` | Progress values to settle on after release (`0 0.5 1`). Target picked from position, fling velocity and swipe direction, reported as `detail.snap` on `-end`, settled on by the default action (a CSS transition, `--gesture-snap-duration` / `--gesture-snap-ease`). |
173
+ | `should-emit-move` | option | `boolean` | | | Fire `gesture-handler-move` every frame. Off by default; `--gesture-*` is enough for CSS. |
174
+ | `is-disabled` | hybrid | `boolean` | | | Ignore new pointers; a gesture in progress is cancelled. State-driven veto. |
175
+ | `is-active` | state | `boolean` | | | A pointer is down on the surface. Set from first pointer until release, so it also covers taps and pre-threshold phase. |
176
+ | `gesture-type` | state | `string` | | `"pan"` \| `"pan-x"` \| `"pan-y"` \| `"pinch"` \| `"rotate"` | Recognized gesture in progress, unset before recognition and after release. |
177
+ | `pointer-count` | state | `number` | | | Pointers currently down. |
178
+ | `gesture-direction` | state | `string` | | `"left"` \| `"right"` \| `"up"` \| `"down"` | Dominant travel direction of gesture in progress. |
179
+ | `last-gesture` | state | `string` | | `"pan"` \| `"pan-x"` \| `"pan-y"` \| `"pinch"` \| `"rotate"` \| `"swipe-left"` \| `"swipe-right"` \| `"swipe-up"` \| `"swipe-down"` \| `"tap"` \| `"double-tap"` \| `"long-press"` | What last gesture turned out to be — style a "just swiped" state from it. |
180
+
181
+ #### Provision
182
+
183
+ | Name | Type | Description |
184
+ | --- | --- | --- |
185
+ | `provision` | `GestureHandlerProvision` (`{ x: number; y: number; dx: number; dy: number; vx: number; vy: number; progress: number; scale: number; rotate: number; pointers: number; } & { type: \| "pan" \| "pan-x" \| "pan-y" \| "pinch" \| "rotate" \| "swipe" \| "tap" \| "double-tap" \| "long-press" \| null; pointerType: string; distance: number; rangePx: number; durationMs: number; snap: number \| null; swipe: "left" \| "right" \| "up" \| "down" \| null; }`) | Last `-start` / `-end` / `-cancel` snapshot (`type`, travel, velocity, progress, `snap`, `swipe`, …). Not an attribute; per-frame values live in `--gesture-*`. |
186
+
187
+ #### Fires
188
+
189
+ | Name | Type | Description |
190
+ | --- | --- | --- |
191
+ | `gesture-handler-start` | `GestureHandlerStartEvent` (`CustomEvent & { type: "gesture-handler-start"; detail: { x: number; y: number; dx: number; dy: number; vx: number; vy: number; progress: number; scale: number; rotate: number; pointers: number; } & { type: \| "pan" \| "pan-x" \| "pan-y" \| "pinch" \| "rotate" \| "swipe" \| "tap" \| "double-tap" \| "long-press" \| null; pointerType: string; distance: number; rangePx: number; durationMs: number; snap: number \| null; swipe: "left" \| "right" \| "up" \| "down" \| null; }; bubbles: true; cancelable: true; composed: true }`) | Gesture recognized: pan passed `threshold-px` (after `arm-after` if set), or a second finger for `pinch` / `rotate`. After `gesture-type` + `provision` set. `detail` = provision. Not cancelable — gate with `is-disabled`. |
192
+ | `gesture-handler-move` | `GestureHandlerMoveEvent` (`CustomEvent & { type: "gesture-handler-move"; detail: { x: number; y: number; dx: number; dy: number; vx: number; vy: number; progress: number; scale: number; rotate: number; pointers: number; } & { type: \| "pan" \| "pan-x" \| "pan-y" \| "pinch" \| "rotate" \| "swipe" \| "tap" \| "double-tap" \| "long-press" \| null; pointerType: string; distance: number; rangePx: number; durationMs: number; snap: number \| null; swipe: "left" \| "right" \| "up" \| "down" \| null; }; bubbles: true; cancelable: true; composed: true }`) | Once per frame while a recognized gesture moves, only with `should-emit-move`. `detail` = provision. `--gesture-*` always updates, event or not. |
193
+ | `gesture-handler-end` | `GestureHandlerEndEvent` (`CustomEvent & { type: "gesture-handler-end"; detail: { x: number; y: number; dx: number; dy: number; vx: number; vy: number; progress: number; scale: number; rotate: number; pointers: number; } & { type: \| "pan" \| "pan-x" \| "pan-y" \| "pinch" \| "rotate" \| "swipe" \| "tap" \| "double-tap" \| "long-press" \| null; pointerType: string; distance: number; rangePx: number; durationMs: number; snap: number \| null; swipe: "left" \| "right" \| "up" \| "down" \| null; }; bubbles: true; cancelable: true; composed: true }`) | Last pointer up after a recognized gesture. After `is-active` unset, `last-gesture` + `provision` set, and after `gesture-handler-swipe` if one was recognized. `detail.snap` = `snap-points` target from position / velocity / swipe (`null` without `snap-points`); `detail.swipe` = swipe direction. Default action: write `--gesture-progress` = `detail.snap`, which settles with a CSS transition (`--gesture-snap-duration` / `--gesture-snap-ease`), then `gesture-handler-snap`. `preventDefault()` leaves values where the finger left them. Persist until next gesture. |
194
+ | `gesture-handler-snap` | `GestureHandlerSnapEvent` (`CustomEvent & { type: "gesture-handler-snap"; detail: { value: number; index: number }; bubbles: true; cancelable: true; composed: true }`) | The settle transition reached the snap point (at once when there is nothing to animate; never when a new gesture interrupts it). `detail` = `{ value, index }` into `snap-points`. Commit here when consumer CSS follows `--gesture-progress` until the end. |
195
+ | `gesture-handler-cancel` | `GestureHandlerCancelEvent` (`CustomEvent & { type: "gesture-handler-cancel"; detail: { x: number; y: number; dx: number; dy: number; vx: number; vy: number; progress: number; scale: number; rotate: number; pointers: number; } & { type: \| "pan" \| "pan-x" \| "pan-y" \| "pinch" \| "rotate" \| "swipe" \| "tap" \| "double-tap" \| "long-press" \| null; pointerType: string; distance: number; rangePx: number; durationMs: number; snap: number \| null; swipe: "left" \| "right" \| "up" \| "down" \| null; }; bubbles: true; cancelable: true; composed: true }`) | Recognized gesture cut short: browser took pointer (`pointercancel`, usually native scroll), `is-disabled` set, or element left the document. `detail` = provision. No `-end`, no glide. |
196
+ | `gesture-handler-swipe` | `GestureHandlerSwipeEvent` (`CustomEvent & { type: "gesture-handler-swipe" \| `gesture-handler-swipe-${"left" \| "right" \| "up" \| "down"}`; detail: { direction: "left" \| "right" \| "up" \| "down"; velocity: number }; bubbles: true; cancelable: true; composed: true }`) | On release, velocity along dominant axis ≥ `swipe-min-velocity` and direction allowed by `swipe-directions`. `detail` = `{ direction, velocity }` (px/ms). Direction twin fires next (`gesture-handler-swipe-left` / `-right` / `-up` / `-down`). |
197
+ | `gesture-handler-tap` | `GestureHandlerPointEvent` (`CustomEvent & { type: \| "gesture-handler-tap" \| "gesture-handler-double-tap" \| "gesture-handler-long-press"; detail: { x: number; y: number }; bubbles: true; cancelable: true; composed: true }`) | Down and up without moving past `threshold-px`, within `long-press-ms`. `detail` = `{ x, y }` relative to the element. |
198
+ | `gesture-handler-double-tap` | `GestureHandlerPointEvent` (`CustomEvent & { type: \| "gesture-handler-tap" \| "gesture-handler-double-tap" \| "gesture-handler-long-press"; detail: { x: number; y: number }; bubbles: true; cancelable: true; composed: true }`) | Second tap within `double-tap-ms` of the previous (after its `gesture-handler-tap`). `detail` = `{ x, y }`. Pair consumed; a third tap starts over. |
199
+ | `gesture-handler-long-press` | `GestureHandlerPointEvent` (`CustomEvent & { type: \| "gesture-handler-tap" \| "gesture-handler-double-tap" \| "gesture-handler-long-press"; detail: { x: number; y: number }; bubbles: true; cancelable: true; composed: true }`) | Pointer stayed down without moving for `long-press-ms`. `detail` = `{ x, y }`. |
200
+
201
+ #### CSS Custom Properties
202
+
203
+ | Name | Syntax | Default | Description |
204
+ | --- | --- | --- | --- |
205
+ | `--gesture-x` | `<length>` | `0px` | Pointer centroid inside the element (px), horizontal — spotlight / parallax effects. Written every frame of a gesture; this is the resting value. |
206
+ | `--gesture-y` | `<length>` | `0px` | Pointer centroid inside the element (px), vertical. |
207
+ | `--gesture-dx` | `<length>` | `0px` | Horizontal travel since the gesture started (px) — drag-to-move, swipe-to-dismiss. |
208
+ | `--gesture-dy` | `<length>` | `0px` | Vertical travel since the gesture started (px). |
209
+ | `--gesture-vx` | `<number>` | `0` | Horizontal velocity over the last ~100 ms (px/ms). |
210
+ | `--gesture-vy` | `<number>` | `0` | Vertical velocity over the last ~100 ms (px/ms). |
211
+ | `--gesture-progress` | `<number>` | `0` | Travel along `progress-axis` as a fraction of the range, clamped to `progress-min`..`progress-max` — the value a scrubbable sheet, carousel or slider follows. Persists after release until the next gesture. |
212
+ | `--gesture-range-px` | `<length>` | `0px` | The measured range (`range-ref` size along the axis, or `range-px`). Written once, when the gesture starts. |
213
+ | `--gesture-width` | `<length>` | `0px` | The element's own width, measured when the gesture starts. |
214
+ | `--gesture-height` | `<length>` | `0px` | The element's own height, measured when the gesture starts. |
215
+ | `--gesture-scale` | `<number>` | `1` | Two-finger pinch ratio (`1` = unchanged) — pinch-to-zoom. |
216
+ | `--gesture-rotate` | `<angle>` | `0deg` | Two-finger rotation — rotate-to-adjust. |
217
+ | `--gesture-pointers` | `<integer>` | `0` | Pointers currently down. |
218
+ | `--gesture-distance` | `<length>` | `hypot(var(--gesture-dx), var(--gesture-dy))` | Length of the travel (px). Computed from `--gesture-dx` / `--gesture-dy`. |
219
+ | `--gesture-angle` | `<angle>` | `atan2(var(--gesture-dy), var(--gesture-dx))` | Direction of the travel: `0deg` right, `90deg` down. Computed from `--gesture-dx` / `--gesture-dy`. |
220
+ | `--gesture-progress-px` | `<length>` | `calc(var(--gesture-progress) * var(--gesture-range-px))` | `--gesture-progress` in px. Computed from `--gesture-progress` and `--gesture-range-px`. |
221
+ | `--gesture-x-ratio` | `<number>` | `tan(atan2(var(--gesture-x), var(--gesture-width)))` | `--gesture-x` as a 0..1 fraction of the element's width. Computed from `--gesture-x` and `--gesture-width`. |
222
+ | `--gesture-y-ratio` | `<number>` | `tan(atan2(var(--gesture-y), var(--gesture-height)))` | `--gesture-y` as a 0..1 fraction of the element's height. Computed from `--gesture-y` and `--gesture-height`. |
223
+ | `--gesture-snap-duration` | `<time>` | `200ms` | How long `--gesture-progress` takes to settle on its snap point after release. |
224
+ | `--gesture-snap-ease` | `<easing-function>` | `ease-out` | Easing of that settle. |
225
+
226
+ #### CSS Aliases
227
+
228
+ | Alias | Kind | Matches | Description |
229
+ | --- | --- | --- | --- |
230
+ | `:--gesture-handler` | element | `gesture-handler`, `.tag-gesture-handler` | |
231
+ | `:--gesture-handler--is-active` | state | `[is-active]`, `[data-active]` | |
232
+
233
+
234
+
235
+ ### Examples
236
+
237
+ #### Swipeable carousel
238
+
239
+ `slide-animation="track"` turns `content-carousel` into a draggable track. `progress-axis="left"` makes a leftward drag pull the next slide in; the `:has()` rules narrow the bounds at the first and last slide so nothing wraps mid-drag; the `gesture-handler-snap` block swaps `is-active` and un-scrubs in one commit.
240
+
241
+
242
+ ```html
243
+ <gesture-handler gesture-types="pan-x swipe" pointer-types="touch pen mouse" progress-axis="left"
244
+ range-ref=":scope > content-carousel" progress-min="-1" progress-max="1" snap-points="-1 0 1"
245
+ overshoot-resistance="0.2">
246
+ <quark-sheet>
247
+ :scope {
248
+ /* no wrap-around while dragging: clamp the ends */
249
+ &:has(content-carousel-slide[is-active]:first-of-type) { progress-min: 0; }
250
+ &:not(:has(content-carousel-slide[is-active]:first-of-type)) { progress-min: -1; }
251
+ &:has(content-carousel-slide[is-active]:last-of-type) { progress-max: 0; }
252
+ &:not(:has(content-carousel-slide[is-active]:last-of-type)) { progress-max: 1; }
253
+ @on gesture-handler-start {
254
+ content-carousel { is-scrubbing: ""; }
255
+ }
256
+ /* commit once the glide lands: the slide swap and the un-scrub paint together */
257
+ @on gesture-handler-snap {
258
+ /* snap-points "-1 0 1" → index 0 = previous, 1 = stay, 2 = next */
259
+ $snap: event.detail.index;
260
+ content-carousel {
261
+ is-scrubbing: none;
262
+ content-carousel-slide[is-active] + content-carousel-slide {
263
+ is-active: $snap == 2 or preserve;
264
+ }
265
+ content-carousel-slide:has(+ content-carousel-slide[is-active]) {
266
+ is-active: $snap == 0 or preserve;
267
+ }
268
+ content-carousel-slide[is-active] {
269
+ is-active: $snap == 1 or none;
270
+ }
271
+ }
272
+ }
273
+ }
274
+ </quark-sheet>
275
+ <content-carousel slide-animation="track">
276
+ <content-carousel-slide is-active>
277
+ <h2>One</h2>
278
+ </content-carousel-slide>
279
+ <content-carousel-slide>
280
+ <h2>Two</h2>
281
+ </content-carousel-slide>
282
+ <content-carousel-slide>
283
+ <h2>Three</h2>
284
+ </content-carousel-slide>
285
+ </content-carousel>
286
+ </gesture-handler>
287
+ ```
288
+
289
+
290
+ #### Pinch, rotate, drag
291
+
292
+ Two fingers (a trackpad or touch screen) for `--gesture-scale` and `--gesture-rotate`; one for `--gesture-dx` / `--gesture-dy`. Pure CSS mapping, no sheet. Each gesture starts from the resting values.
293
+
294
+
295
+ ```html
296
+ <gesture-handler gesture-types="pan pinch rotate" pointer-types="touch pen mouse">
297
+ <figure>Drag, pinch, twist</figure>
298
+ <style>
299
+ #demo-gesture-handler-pinch > :first-child figure {
300
+ translate: var(--gesture-dx, 0px) var(--gesture-dy, 0px);
301
+ scale: var(--gesture-scale, 1);
302
+ rotate: var(--gesture-rotate, 0deg);
303
+ }
304
+ </style>
305
+ </gesture-handler>
306
+ ```
307
+
308
+
309
+ #### Swipe to dismiss
310
+
311
+ `snap-points="-1 0 1"` with a literal `range-px`: a flick past `swipe-min-velocity` snaps the card off to the side, and the `gesture-handler-snap` block records the fact. A tap starts a new gesture (values reset) and clears it.
312
+
313
+
314
+ ```html
315
+ <gesture-handler gesture-types="pan-x swipe tap" pointer-types="touch pen mouse" range-px="240" progress-min="-1" progress-max="1"
316
+ snap-points="-1 0 1" overshoot-resistance="0.4">
317
+ <quark-sheet>
318
+ :scope {
319
+ @on gesture-handler-snap {
320
+ article { data-is-dismissed: event.detail.value != 0; }
321
+ }
322
+ @on gesture-handler-tap {
323
+ article { data-is-dismissed: none; }
324
+ }
325
+ }
326
+ </quark-sheet>
327
+ <article>
328
+ <h2>Swipe me away</h2>
329
+ <p>Flick left or right. Tap to bring it back.</p>
330
+ </article>
331
+ <style>
332
+ #demo-gesture-handler-swipe > :first-child article {
333
+ translate: calc(var(--gesture-progress, 0) * 240px);
334
+ rotate: calc(var(--gesture-progress, 0) * 8deg);
335
+ &[data-is-dismissed] { opacity: 0.25; }
336
+ }
337
+ </style>
338
+ </gesture-handler>
339
+ ```
@@ -0,0 +1,92 @@
1
+ # gesture-handler
2
+
3
+ Let users drag, swipe, pinch and flick your UI — sheets, carousels, cards and images follow the finger, in CSS, with no per-frame script.
4
+
5
+ <include-content data-demo="sheet"></include-content>
6
+
7
+ ## Features
8
+
9
+ - **Swipe / pan / pinch / rotate / tap / long-press** Each recognized gesture is a tag-prefixed event
10
+ - **Follow the finger in CSS** Every frame lands in `--gesture-*` custom properties: scrubbable bottom sheets, swipe-to-dismiss, pinch-to-zoom, pull-to-refresh, parallax
11
+ - **Snap & fling** `snap-points` with velocity projection, a CSS transition on release (`--gesture-snap-duration` / `--gesture-snap-ease`), `gesture-handler-snap` when it lands
12
+ - **Drivable** Bounds, `progress-offset` and `is-disabled` are attributes a Quark rule sets from the driven element's state
13
+ - **Scoped starts** `from-ref` for drag handles, `from-edge` for edge swipes
14
+ - **Scroll handoff** `handoff-ref` lets a sheet's own scrolling content take the drag over when it runs out of scroll — a native-feeling pull-to-close
15
+ - **Native scrolling kept** `touch-action` follows `gesture-types`, so the page still scrolls where you don't pan
16
+
17
+ ## Installation
18
+
19
+ <include-content is-active template-ref="/views/install-section/install-section.html"></include-content>
20
+
21
+ ## Usage
22
+
23
+ Wrap the surface the user touches. Pick the gestures with `gesture-types`; read the finger from `--gesture-*` in CSS (custom properties inherit, so any descendant can `var()` them); commit State on `gesture-handler-end` or `-snap` from a Quark `@on` block.
24
+
25
+ ```html
26
+ <gesture-handler gesture-types="pan-y swipe" progress-axis="up" range-ref=":scope > content-drawer" snap-points="0 1">
27
+ <quark-sheet>
28
+ :scope {
29
+ @on gesture-handler-start { content-drawer { is-scrubbing: ""; } }
30
+ @on gesture-handler-end { content-drawer { is-open: event.detail.snap == 1; is-scrubbing: none; } }
31
+ }
32
+ </quark-sheet>
33
+ <content-drawer>…</content-drawer>
34
+ </gesture-handler>
35
+ ```
36
+
37
+ `--gesture-progress` is the travel along `progress-axis` as a fraction of the range (`range-ref` measures the driven element; `range-px` is a literal), clamped to `progress-min`..`progress-max` with optional `overshoot-resistance`. Written alongside it every frame: `--gesture-dx` / `-dy`, `--gesture-x` / `-y`, `--gesture-scale`, `--gesture-rotate`, `--gesture-vx` / `-vy`, `--gesture-pointers`; once per gesture: `--gesture-range-px`, `--gesture-width` / `-height`. Everything else is derived from those in the element's own CSS — `--gesture-distance`, `--gesture-angle`, `--gesture-progress-px`, `--gesture-x-ratio` / `-y-ratio` — so `var()` them the same way.
38
+
39
+ Values persist after release until the next gesture starts. On release the default action writes `--gesture-progress` straight to `detail.snap` and the element's own `transition` settles it there over `--gesture-snap-duration` (`200ms`) with `--gesture-snap-ease` (`ease-out`), firing `gesture-handler-snap` when it lands; the transition is off while `is-active`, so the finger itself is never eased, and a new gesture that interrupts the settle simply cancels it (no `-snap`).
40
+
41
+ ### Driving Nucleus Kit elements
42
+
43
+ Elements that can be scrubbed expose a `--<tag>-…-progress` input and an `is-scrubbing` attribute; both default to the wrapping gesture-handler's `--gesture-progress`, so no mapping is needed:
44
+
45
+ | Element | Set up | While `is-scrubbing` |
46
+ | --- | --- | --- |
47
+ | `content-drawer` | `progress-axis` towards its open side, `range-ref` the drawer, `handoff-ref` the drawer too (it scrolls its own content) | Position follows `--content-drawer-open-progress` (`0` closed, `1` open), no transition |
48
+ | `content-carousel` | `slide-animation="track"`, `progress-min="-1" progress-max="1" snap-points="-1 0 1"` | The track follows `--content-carousel-progress` in slide widths |
49
+
50
+ The handoff is one Quark commit: the block that writes the final state (`is-open`, the active slide) also removes `is-scrubbing`, so the element switches from finger to State in the same paint. Commit on `-end` when a CSS transition should finish the motion (the drawer), on `-snap` when the element must be exactly at the snap point first (the carousel).
51
+
52
+ Anything else follows the same recipe: read `--gesture-*` in your own CSS, gate the mapping on a fact your sheet writes on start and clears on end.
53
+
54
+ Give a drag handle `touch-action: none` when using `from-ref`, so the browser does not scroll it away; without `from-ref` the element sets `touch-action` itself from `gesture-types`. Add `mouse` to `pointer-types` for desktop dragging.
55
+
56
+ ### Scroll handoff
57
+
58
+ `handoff-ref` names the scroll container(s) inside the surface whose *overscroll* starts a gesture (a `:scope`-relative selector; a comma list matches several). A pointer that goes down in one of them scrolls natively as usual. Only when its **first** move runs along `progress-axis`, the container is at its scroll limit that way, and `progress-offset` still has room to travel in that direction does the element cancel the native scroll for the rest of the touch and take the drag over — the same gesture, the same `--gesture-*` values and the same `-start` / `-end` / `-snap` events as a drag from a handle:
59
+
60
+ ```html
61
+ <gesture-handler gesture-types="pan-y swipe" progress-axis="up" snap-points="0 1"
62
+ from-ref=":scope > content-drawer > header" handoff-ref=":scope > content-drawer"
63
+ range-ref=":scope > content-drawer">
64
+ ```
65
+
66
+ An open sheet (`progress-offset: 1`) closes either from its header or by pulling its text down once the text is back at the top; pulling up, or pulling down mid-scroll, keeps scrolling. `handoff-ref` is additive — `from-ref` and `from-edge` starts are unchanged, and a `from-ref` handle inside a handoff container still starts on pointerdown. The element keeps `touch-action` out of the way while `handoff-ref` is set (the containers must be able to scroll), so give handles their own `touch-action: none`.
67
+
68
+ Mouse drags (`pointer-types="… mouse"`) take the same route with no native scroll to cancel: the first `pointermove` inside the container starts the gesture when the container is at its limit.
69
+
70
+ ### API Reference
71
+
72
+ <include-content is-active template-ref="/views/api-reference/api-reference.html"></include-content>
73
+
74
+ ### Examples
75
+
76
+ #### Swipeable carousel
77
+
78
+ `slide-animation="track"` turns `content-carousel` into a draggable track. `progress-axis="left"` makes a leftward drag pull the next slide in; the `:has()` rules narrow the bounds at the first and last slide so nothing wraps mid-drag; the `gesture-handler-snap` block swaps `is-active` and un-scrubs in one commit.
79
+
80
+ <include-content data-demo="carousel"></include-content>
81
+
82
+ #### Pinch, rotate, drag
83
+
84
+ Two fingers (a trackpad or touch screen) for `--gesture-scale` and `--gesture-rotate`; one for `--gesture-dx` / `--gesture-dy`. Pure CSS mapping, no sheet. Each gesture starts from the resting values.
85
+
86
+ <include-content data-demo="pinch"></include-content>
87
+
88
+ #### Swipe to dismiss
89
+
90
+ `snap-points="-1 0 1"` with a literal `range-px`: a flick past `swipe-min-velocity` snaps the card off to the side, and the `gesture-handler-snap` block records the fact. A tap starts a new gesture (values reset) and clears it.
91
+
92
+ <include-content data-demo="swipe"></include-content>