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.
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
This package is available in the
<script src="https://unpkg.com/@excom/kit-utils@0.3.0/dist/index.umd.min.js"></script>
<script src="https://unpkg.com/@excom/neutron@0.2.0/dist/index.umd.min.js"></script>
<script src="https://unpkg.com/@excom/gesture-handler@0.2.4/dist/index.umd.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@excom/gesture-handler@0.2.4/dist/index.css">npm install @excom/gesture-handler<!-- import path to `node_modules` will depend on your build setup -->
<script type="module" src="/node_modules/@excom/gesture-handler"></script>
<link rel="stylesheet" href="/node_modules/@excom/gesture-handler">import "@excom/gesture-handler";@import "@excom/gesture-handler/index.css";Peer dependencies (0)
Packages a consumer must install alongside this one. Workspace deps are bundled.
| Package | Version |
|---|---|
|
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-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"><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
Role —option is configurable, state
is managed by the element (read-only), or hybrid which is both.
Provision
Thisprovision property lives on the DOM node.
Read / watch it with Quark’s prop("provision"), or listen
for the neutron-provision event from app JS.
Events
Type
command event (HTML Command API): from a
<button command commandfor>, an <event-handler
command-name>, or a CommandEvent.
Recognized Elements
Child or descendant elements are recognized by and relevant to its functionality.Styles
@custom-selector synonyms so custom elements can share
semantic styles. Element aliases match the host tag / .tag-* class; state
aliases match attributes (nested under the element alias).
gesture-handler
Attributes (29)
Role —option is configurable, state
is managed by the element (read-only), or hybrid which is both.
arm-after
long-press for hold-then-drag.
armAfter
string
"long-press"nulldouble-tap-ms
double-tap.
doubleTapMs
number
300edge-px
from-edge start zone (px).
edgePx
number
40from-edge
edge-px of these edges — edge swipes (back nav, pulling a closed sheet up).
fromEdge
tokenlist
"left" | "right" | "top" | "bottom"nullfrom-ref
:scope-relative selector) — a drag handle, the sheet itself. Combined with from-edge, either qualifies.
fromRef
string
<CSS Selector>nullgesture-types
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.
gestureTypes
tokenlist
"pan" | "pan-x" | "pan-y" | "pinch" | "rotate" | "swipe" | "tap" | "double-tap" | "long-press""pan"handoff-ref
: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.
handoffRef
string
<CSS Selector>nulllock-axis
pan locks to its dominant axis once recognized (gesture-type becomes pan-x / pan-y).
lockAxis
boolean
falselong-press-ms
long-press; also tap time limit.
longPressMs
number
500max-pointers
2 when pinch / rotate listed, else 1.
maxPointers
number
nullovershoot-resistance
progress-min / progress-max: 0 clamps, 0.3 overshoots at a third of travel.
overshootResistance
number
0pointer-types
mouse for desktop drag.
pointerTypes
tokenlist
"touch" | "pen" | "mouse""touch pen"progress-axis
--gesture-progress grows. Unset = down for a pan-y-only element, else right.
progressAxis
string
"up" | "down" | "left" | "right"nullprogress-max
progress.
progressMax
number
1progress-min
progress.
progressMin
number
0progress-offset
1 while a sheet is open) so dragging it closed starts full.
progressOffset
number
0range-px
range-ref.
rangePx
number
nullrange-ref
progress-axis is the range of progress 0..1 (:scope-relative, read once per gesture) — sheet being dragged, slide being swiped.
rangeRef
string
<CSS Selector>nullshould-emit-move
gesture-handler-move every frame. Off by default; --gesture-* is enough for CSS.
shouldEmitMove
boolean
falsesnap-points
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).
snapPoints
tokenlist
<number>…nullswipe-directions
swipeDirections
tokenlist
"left" | "right" | "up" | "down"nullswipe-min-velocity
swipeMinVelocity
number
0.5threshold-px
thresholdPx
number
8is-disabled
isDisabled
boolean
falsegesture-direction
gestureDirection
string
"left" | "right" | "up" | "down"nullgesture-type
gestureType
string
"pan" | "pan-x" | "pan-y" | "pinch" | "rotate"nullis-active
isActive
boolean
falselast-gesture
lastGesture
string
"pan" | "pan-x" | "pan-y" | "pinch" | "rotate" | "swipe-left" | "swipe-right" | "swipe-up" | "swipe-down" | "tap" | "double-tap" | "long-press"nullpointer-count
pointerCount
number
nullProvision (1)
Thisprovision property lives on the DOM node.
Read / watch it with Quark’s prop("provision"), or listen
for the neutron-provision event from app JS.
provision
-start / -end / -cancel snapshot (type, travel, velocity, progress, snap, swipe, …). Not an attribute; per-frame values live in --gesture-*.
provision
GestureHandlerProvision
Events (9)
e.preventDefault() is not
synchronously called on the event.
Type
gesture-handler-cancel
pointercancel, usually native scroll), is-disabled set, or element left the document. detail = provision. No -end, no glide.
Type
GestureHandlerCancelEvent
gesture-handler-double-tap
double-tap-ms of the previous (after its gesture-handler-tap). detail = { x, y }. Pair consumed; a third tap starts over.
Type
GestureHandlerPointEvent
gesture-handler-end
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.
Type
GestureHandlerEndEvent
gesture-handler-long-press
long-press-ms. detail = { x, y }.
Type
GestureHandlerPointEvent
gesture-handler-move
should-emit-move. detail = provision. --gesture-* always updates, event or not.
Type
GestureHandlerMoveEvent
gesture-handler-snap
detail = { value, index } into snap-points. Commit here when consumer CSS follows --gesture-progress until the end.
Type
GestureHandlerSnapEvent
gesture-handler-start
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.
Type
GestureHandlerStartEvent
gesture-handler-swipe
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).
Type
GestureHandlerSwipeEvent
gesture-handler-tap
threshold-px, within long-press-ms. detail = { x, y } relative to the element.
Type
GestureHandlerPointEvent
command event (HTML Command API): from a
<button command commandfor>, an <event-handler
command-name>, or a CommandEvent.
Recognized Elements (0)
Child or descendant elements are recognized by gesture-handler and relevant to its functionality.Styles (22)
all: revert-layer.
Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
--gesture-angle<angle>atan2(var(--gesture-dy), var(--gesture-dx))--gesture-distance<length>hypot(var(--gesture-dx), var(--gesture-dy))--gesture-dx<length>0px--gesture-dy<length>0px--gesture-height<length>0px--gesture-pointers<integer>0--gesture-progress<number>0--gesture-progress-px<length>calc(var(--gesture-progress) * var(--gesture-range-px))--gesture-range-px<length>0px--gesture-rotate<angle>0deg--gesture-scale<number>1--gesture-snap-duration<time>200ms--gesture-snap-ease<easing-function>ease-out--gesture-vx<number>0--gesture-vy<number>0--gesture-width<length>0px--gesture-x<length>0px--gesture-x-ratio<number>tan(atan2(var(--gesture-x), var(--gesture-width)))--gesture-y<length>0px--gesture-y-ratio<number>tan(atan2(var(--gesture-y), var(--gesture-height)))@custom-selector synonyms so custom elements can share
semantic styles. Element aliases match the host tag / .tag-* class; state
aliases match attributes (nested under the element alias).
:--gesture-handlergesture-handler, .tag-gesture-handler:--gesture-handler--is-active[is-active], [data-active]Release notes (4)
0.2.2
- 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
- Pan handlers cancel the page's cross-axis scroll on the first touch move
0.2.0
- 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
0.1.1
- Ship only dist (and declared extras) in the npm tarball; drop build logs, tests and sources
Release notes
e.preventDefault() is not
synchronously called on the event.
all: revert-layer.
Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
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.
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.
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.