@excom/gesture-handler
v0.2.4
Published
<gesture-handler> custom element
Maintainers
Readme
gesture-handler
Let users drag, swipe, pinch and flick your UI — sheets, carousels, cards and images follow the finger, in CSS, with no per-frame script.
<gesture-handler gesture-types="pan-y swipe" pointer-types="touch pen mouse" from-ref=":scope > content-drawer > header"
handoff-ref=":scope > content-drawer" progress-axis="up" range-ref=":scope > content-drawer" snap-points="0 1">
<quark-sheet>
:scope {
/* dragging an open sheet starts from fully open */
&:has(> content-drawer[is-open]) {
progress-offset: 1;
}
&:not(:has(> content-drawer[is-open])) {
progress-offset: 0;
}
@on gesture-handler-start {
content-drawer { is-scrubbing: ""; }
}
@on gesture-handler-end {
content-drawer { is-open: event.detail.snap == 1; is-scrubbing: none; }
}
}
</quark-sheet>
<button type="button" command="--open" commandfor="demo-gesture-sheet">Open sheet</button>
<content-drawer id="demo-gesture-sheet" class="absolute">
<header><h2>Drag me</h2></header>
<p>Two ways to dismiss it. Drag the header down and let go, or flick it —
that is the <code>from-ref</code> grab handle.</p>
<p>Or pull the text itself down: <code>handoff-ref</code> lets the sheet's
own content scroll first, and only once it is back at the top does the
next pull down become the same drag. Pulling up, or pulling down while
the text is scrolled, keeps scrolling as usual.</p>
<p>Scroll on. The sheet only takes the drag over at the very top, which is
how a native sheet behaves.</p>
<p>Keep going.</p>
<p>Nearly there.</p>
<p>Now scroll back up and pull once more — the sheet follows your finger
and snaps to <code>0</code> or <code>1</code> on release.</p>
</content-drawer>
<style>
#demo-gesture-handler-sheet > :first-child {
/* short enough that the text scrolls: the handoff needs a scroller */
content-drawer { max-block-size: 11rem; }
/* the drag handle owns its touches; the content keeps scrolling */
content-drawer > header {
position: sticky;
top: 0;
touch-action: none;
cursor: grab;
background-color: inherit;
}
}
</style>
</gesture-handler>Features
- Swipe / pan / pinch / rotate / tap / long-press Each recognized gesture is a tag-prefixed event
- 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 - Snap & fling
snap-pointswith velocity projection, a CSS transition on release (--gesture-snap-duration/--gesture-snap-ease),gesture-handler-snapwhen it lands - Drivable Bounds,
progress-offsetandis-disabledare attributes a Quark rule sets from the driven element's state - Scoped starts
from-reffor drag handles,from-edgefor edge swipes - Scroll handoff
handoff-reflets a sheet's own scrolling content take the drag over when it runs out of scroll — a native-feeling pull-to-close - Native scrolling kept
touch-actionfollowsgesture-types, so the page still scrolls where you don't pan
Installation
@excom/gesture-handler v0.2.4
pnpm add @excom/gesture-handlernpm install @excom/gesture-handleryarn add @excom/gesture-handlerImport
import "@excom/gesture-handler";Usage
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.
<gesture-handler gesture-types="pan-y swipe" progress-axis="up" range-ref=":scope > content-drawer" snap-points="0 1">
<quark-sheet>
:scope {
@on gesture-handler-start { content-drawer { is-scrubbing: ""; } }
@on gesture-handler-end { content-drawer { is-open: event.detail.snap == 1; is-scrubbing: none; } }
}
</quark-sheet>
<content-drawer>…</content-drawer>
</gesture-handler>--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.
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).
Driving NucleusKit elements
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:
| Element | Set up | While is-scrubbing |
| --- | --- | --- |
| 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 |
| 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 |
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).
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.
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. For pan-x / pan-y, a touch whose first 3 px run along the handler's axis holds the page still for the rest of that touch (a non-passive touchmove listener does this; touch-action is unchanged), while a touch that starts across the axis scrolls the page as before. Add mouse to pointer-types for desktop dragging.
Scroll handoff
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, nothing between the pointer and the element can still scroll that way (the named containers and any scroll container nested in or around them, so a scrolled editor inside a sheet scrolls back first), 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:
<gesture-handler gesture-types="pan-y swipe" progress-axis="up" snap-points="0 1"
from-ref=":scope > content-drawer > header" handoff-ref=":scope > content-drawer"
range-ref=":scope > content-drawer">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.
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 nothing below the pointer can still scroll that way.
API Reference
Attributes
| Name | Surface | Type | Default | Values | Description |
| --- | --- | --- | --- | --- | --- |
| 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 (a touch starting along it holds the page still, one across it scrolls); pan both; pinch / rotate need two fingers; swipe velocity on release; tap / double-tap / long-press fire events. |
| 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. |
| 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). |
| edge-px | option | number | 40 | | Width of from-edge start zone (px). |
| 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; the gesture takes over only when the first move runs along progress-axis, every scroller between pointer and this element is at its limit that way, and progress-offset has room. Additive to from-ref / from-edge. |
| pointer-types | option | tokenlist | "touch pen" | "touch" | "pen" | "mouse" | Pointer types that can start a gesture. Add mouse for desktop drag. |
| max-pointers | option | number | | | Extra pointers beyond this many are ignored. Unset = 2 when pinch / rotate listed, else 1. |
| threshold-px | option | number | 8 | | Movement (px) before a pan is recognized. Taps / native scroll stay untouched below it. |
| lock-axis | option | boolean | | | A free pan locks to its dominant axis once recognized (gesture-type becomes pan-x / pan-y). |
| arm-after | option | string | | "long-press" | Pans only arm after this gesture — long-press for hold-then-drag. |
| long-press-ms | option | number | 500 | | Hold time (ms) for long-press; also tap time limit. |
| double-tap-ms | option | number | 300 | | Max gap (ms) between taps for double-tap. |
| swipe-min-velocity | option | number | 0.5 | | Release velocity (px/ms) that counts as a swipe. |
| swipe-directions | option | tokenlist | | "left" | "right" | "up" | "down" | Swipe directions to report. Unset = all four. |
| progress-axis | option | string | | "up" | "down" | "left" | "right" | Direction --gesture-progress grows. Unset = down for a pan-y-only element, else right. |
| 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. |
| range-px | option | number | | | Literal range (px) instead of range-ref. |
| progress-min | option | number | 0 | | Lower bound of progress. |
| progress-max | option | number | 1 | | Upper bound of progress. |
| 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. |
| overshoot-resistance | option | number | 0 | | Rubber-band past progress-min / progress-max: 0 clamps, 0.3 overshoots at a third of travel. |
| 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). |
| should-emit-move | option | boolean | | | Fire gesture-handler-move every frame. Off by default; --gesture-* is enough for CSS. |
| is-disabled | hybrid | boolean | | | Ignore new pointers; a gesture in progress is cancelled. State-driven veto. |
| 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. |
| gesture-type | state | string | | "pan" | "pan-x" | "pan-y" | "pinch" | "rotate" | Recognized gesture in progress, unset before recognition and after release. |
| pointer-count | state | number | | | Pointers currently down. |
| gesture-direction | state | string | | "left" | "right" | "up" | "down" | Dominant travel direction of gesture in progress. |
| 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. |
Provision
| Name | Type | Description |
| --- | --- | --- |
| 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-*. |
Fires
| Name | Type | Description |
| --- | --- | --- |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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). |
| 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. |
| 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. |
| 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 }. |
CSS Custom Properties
| Name | Syntax | Default | Description |
| --- | --- | --- | --- |
| --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. |
| --gesture-y | <length> | 0px | Pointer centroid inside the element (px), vertical. |
| --gesture-dx | <length> | 0px | Horizontal travel since the gesture started (px) — drag-to-move, swipe-to-dismiss. |
| --gesture-dy | <length> | 0px | Vertical travel since the gesture started (px). |
| --gesture-vx | <number> | 0 | Horizontal velocity over the last ~100 ms (px/ms). |
| --gesture-vy | <number> | 0 | Vertical velocity over the last ~100 ms (px/ms). |
| --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. |
| --gesture-range-px | <length> | 0px | The measured range (range-ref size along the axis, or range-px). Written once, when the gesture starts. |
| --gesture-width | <length> | 0px | The element's own width, measured when the gesture starts. |
| --gesture-height | <length> | 0px | The element's own height, measured when the gesture starts. |
| --gesture-scale | <number> | 1 | Two-finger pinch ratio (1 = unchanged) — pinch-to-zoom. |
| --gesture-rotate | <angle> | 0deg | Two-finger rotation — rotate-to-adjust. |
| --gesture-pointers | <integer> | 0 | Pointers currently down. |
| --gesture-distance | <length> | hypot(var(--gesture-dx), var(--gesture-dy)) | Length of the travel (px). Computed from --gesture-dx / --gesture-dy. |
| --gesture-angle | <angle> | atan2(var(--gesture-dy), var(--gesture-dx)) | Direction of the travel: 0deg right, 90deg down. Computed from --gesture-dx / --gesture-dy. |
| --gesture-progress-px | <length> | calc(var(--gesture-progress) * var(--gesture-range-px)) | --gesture-progress in px. Computed from --gesture-progress and --gesture-range-px. |
| --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. |
| --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. |
| --gesture-snap-duration | <time> | 200ms | How long --gesture-progress takes to settle on its snap point after release. |
| --gesture-snap-ease | <easing-function> | ease-out | Easing of that settle. |
CSS Aliases
| Alias | Kind | Matches | Description |
| --- | --- | --- | --- |
| :--gesture-handler | element | gesture-handler, .tag-gesture-handler | |
| :--gesture-handler--is-active | state | [is-active], [data-active] | |
Examples
Swipeable carousel
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.
<gesture-handler gesture-types="pan-x swipe" pointer-types="touch pen mouse" progress-axis="left"
range-ref=":scope > content-carousel" progress-min="-1" progress-max="1" snap-points="-1 0 1"
overshoot-resistance="0.2">
<quark-sheet>
:scope {
/* no wrap-around while dragging: clamp the ends */
&:has(content-carousel-slide[is-active]:first-of-type) { progress-min: 0; }
&:not(:has(content-carousel-slide[is-active]:first-of-type)) { progress-min: -1; }
&:has(content-carousel-slide[is-active]:last-of-type) { progress-max: 0; }
&:not(:has(content-carousel-slide[is-active]:last-of-type)) { progress-max: 1; }
@on gesture-handler-start {
content-carousel { is-scrubbing: ""; }
}
/* commit once the glide lands: the slide swap and the un-scrub paint together */
@on gesture-handler-snap {
/* snap-points "-1 0 1" → index 0 = previous, 1 = stay, 2 = next */
$snap: event.detail.index;
content-carousel {
is-scrubbing: none;
content-carousel-slide[is-active] + content-carousel-slide {
is-active: $snap == 2 or preserve;
}
content-carousel-slide:has(+ content-carousel-slide[is-active]) {
is-active: $snap == 0 or preserve;
}
content-carousel-slide[is-active] {
is-active: $snap == 1 or none;
}
}
}
}
</quark-sheet>
<content-carousel slide-animation="track">
<content-carousel-slide is-active>
<h2>One</h2>
</content-carousel-slide>
<content-carousel-slide>
<h2>Two</h2>
</content-carousel-slide>
<content-carousel-slide>
<h2>Three</h2>
</content-carousel-slide>
</content-carousel>
</gesture-handler>Pinch, rotate, drag
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.
<gesture-handler gesture-types="pan pinch rotate" pointer-types="touch pen mouse">
<figure>Drag, pinch, twist</figure>
<style>
#demo-gesture-handler-pinch > :first-child figure {
translate: var(--gesture-dx, 0px) var(--gesture-dy, 0px);
scale: var(--gesture-scale, 1);
rotate: var(--gesture-rotate, 0deg);
}
</style>
</gesture-handler>Swipe to dismiss
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.
<gesture-handler gesture-types="pan-x swipe tap" pointer-types="touch pen mouse" range-px="240" progress-min="-1" progress-max="1"
snap-points="-1 0 1" overshoot-resistance="0.4">
<quark-sheet>
:scope {
@on gesture-handler-snap {
article { data-is-dismissed: event.detail.value != 0; }
}
@on gesture-handler-tap {
article { data-is-dismissed: none; }
}
}
</quark-sheet>
<article>
<h2>Swipe me away</h2>
<p>Flick left or right. Tap to bring it back.</p>
</article>
<style>
#demo-gesture-handler-swipe > :first-child article {
translate: calc(var(--gesture-progress, 0) * 240px);
rotate: calc(var(--gesture-progress, 0) * 8deg);
&[data-is-dismissed] { opacity: 0.25; }
}
</style>
</gesture-handler>Release notes
0.2.2 (2026-10-07)
- Fix a type error in projects that load Node's timer types: the element's animation frame handle is typed as a number
0.2.1 (2026-10-01)
- Pan handlers cancel the page's cross-axis scroll on the first touch move
0.2.0 (2026-09-30)
- Hand a
handoff-refoverscroll to the gesture only when every scroller between the pointer and the element is at its limit, so content scrolled inside a nested scroller scrolls back before the sheet moves
Older releases: https://nucleus.excom.dev/packages/gesture-handler
Full documentation: https://nucleus.excom.dev/packages/gesture-handler
