@use "/shell" as *; @use "quark:list" as list; #release-notice { /* opens once: sheet re-runs restart a delay, so gate it on the fact it writes */ &:not([data-did-open]) { @delay 7000 { is-open: ""; data-did-open: ""; } } /* prevent clicks from bubbling to the sheet */ @on mouseup, click (stop-propagation); } provider-fetch[api-url*="package-metas/index.json"][is-success] { $package-indices: prop("provision").body; /* `excom.navGroup` moves a package out of its type's list, into that group */ $ungrouped: list.reject($package-indices.packages, "navGroup"); .package-links { [bind-elements] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "kit-element")); } [bind-element-bases] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "element-base")); } [bind-tools] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "tool")); } /* the standalone libraries: same rows, rendered flat (shell.css) */ [bind-libraries] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "library")); } [bind-library-group] ul:not(ul ul) { content: iterate(list.filter($package-indices.packages, "navGroup", "libraries")); } ul:not(ul ul) > li { $pkg: item.shortName; > spa-a { route-href: "/packages/#{item.shortName}"; } } /* one `content` rule per link: a second one would rewrite the first's text on every pass */ ul:not(ul ul, [bind-libraries] ul) > li > spa-a { content: item.shortName; } [bind-libraries] ul:not(ul ul) > li > spa-a { content: displayName(item.shortName); } /* packages whose docs span several pages: one collapsible group per section */ [bind-sections] { content: iterate(item.docSections); summary { content: item.title; } details > ul { content: iterate(item.docs); spa-a { route-href: "/packages/#{$pkg}/#{item.name}"; content: item.title; } } details:not(:has(spa-a[is-active])) { open: none; } } } /* page titles: the page, then the site. The docs home keeps the title in the document head; the other routes carry theirs in markup */ spa-route[is-active] { /* a site guide */ &[route-href$=":name"] { $title-guide: list.find($package-indices.docs, "name", $route.params.name); document-title: "#{$title-guide.title or $route.params.name} · Nucleus · docs"; } /* a package README */ &[route-href$=":packageName"] { document-title: "#{displayName($route.params.packageName)} · Nucleus · docs"; } /* one page of a package's docs */ &[route-href$=":docName"] { $title-package: list.find($package-indices.packages, "shortName", $route.params.packageName); document-title: "#{docTitle($title-package, $route.params.docName)} · #{displayName($route.params.packageName)} · Nucleus · docs"; } } /* the page's markdown file, for the footer link: the docs home and each guide the index lists, and a package's page whose index entry says `markdown` (build-docs-index sets it; the dev server's index has none, so no link there). siteDocHref and the path check say which URL is the page's own: the 404 and a package's doc pages have none */ > spa-manager[active-url] { $page-path: attr("active-url").split("#").at(0).split("?").at(0); $page-name: if($page-path == SITE_HOME: SITE_HOME_DOC; else: $page-path.split("/").at(-1)); $page-guide: list.find($package-indices.docs, "name", $page-name); $page-package: list.find($package-indices.packages, "shortName", $page-name); $page-markdown: if($page-guide and siteDocHref($page-guide.name) == $page-path: "/docs/#{$page-guide.name}.md"; $page-package.markdown and "#{SITE_BASE}/packages/#{$page-package.shortName}" == $page-path: "/#{$page-package.shortName}.md"); footer [bind-page-markdown] { href: $page-markdown; content: ternary($page-markdown, "Markdown version of this page"); } } } spa-route { $route: prop("provision"); /* paramless routes name their guide in markup (the docs home, `/`); /docs/:name gets it from params */ $route-doc-name: attr("data-doc-name"); } /* the desktop aside and the mobile sheet stamp the same nav template */ [data-site-nav] { details:has(spa-a[is-active]) { /* not kosher */ open: ""; } spa-a[is-active] { aria-current: ""; } spa-a:not([is-active]) { aria-current: none; } } /* mobile sheet: Escape / back gesture while open; a tapped link closes it */ #site-menu { &[is-open] dismiss-watcher { is-active: ""; } &:not([is-open]) dismiss-watcher { is-active: none; } @on click (target: "spa-a") { is-open: none; } } #site-menu-gesture { &:has(> #site-menu[is-open]) { progress-offset: 1; } &:not(:has(> #site-menu[is-open])) { progress-offset: 0; } @on gesture-handler-start { #site-menu { is-scrubbing: ""; } } @on gesture-handler-end { #site-menu { is-open: event.detail.snap == 1; is-scrubbing: none; } } } main { @on copy-source (handle: copySource); } #search-dialog[open] { > include-content { is-active: ""; /* for some reason this is necessary on first render */ @on include-content-did-render (handle: focusInput); } input[type="search"] { /* works on all subsequent opens */ autofocus: ""; } } #search-dialog:not([open]) input[type="search"] { autofocus: none; }

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-points with velocity projection, a CSS transition on release (--gesture-snap-duration / --gesture-snap-ease), gesture-handler-snap when it lands
  • Drivable Bounds, progress-offset and is-disabled are attributes a Quark rule sets from the driven element's state
  • Scoped starts from-ref for drag handles, from-edge for edge swipes
  • 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
  • Native scrolling kept touch-action follows gesture-types, so the page still scrolls where you don't pan

Installation

This package is available in the NucleusKit. Or it can be used by itself:

CDN Package Manager
<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
HTML Imports JS / CSS Imports
<!-- 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
View Dist Files

All exports:

.
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./gesture-handler
  • types: ./dist/gesture-handler.d.ts
  • import: ./dist/gesture-handler.js
  • default: ./dist/gesture-handler.js
./gesture-handler.js
  • types: ./dist/gesture-handler.d.ts
  • import: ./dist/gesture-handler.js
  • default: ./dist/gesture-handler.js
./gesture-handler.min
  • types: ./dist/gesture-handler.d.ts
  • import: ./dist/gesture-handler.min.js
  • default: ./dist/gesture-handler.min.js
./gesture-handler.min.js
  • types: ./dist/gesture-handler.d.ts
  • import: ./dist/gesture-handler.min.js
  • default: ./dist/gesture-handler.min.js
./gesture-handler.umd.min
  • types: ./dist/gesture-handler.d.ts
  • default: ./dist/gesture-handler.umd.min.js
./gesture-handler.umd.min.js
  • types: ./dist/gesture-handler.d.ts
  • default: ./dist/gesture-handler.umd.min.js
./index
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.bundle.min.css
  • default: ./dist/index.bundle.min.css
./index.css
  • default: ./dist/index.css
./index.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.min
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./index.min.css
  • default: ./dist/index.min.css
./index.min.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./index.umd.min
  • types: ./dist/index.d.ts
  • default: ./dist/index.umd.min.js
./index.umd.min.js
  • types: ./dist/index.d.ts
  • default: ./dist/index.umd.min.js

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

gesture-handler

Attributes (29) Role — option is configurable, state is managed by the element (read-only), or hybrid which is both. Role Attribute Type Values Prop Default option arm-after
Pans only arm after this gesture — long-press for hold-then-drag. armAfter
string "long-press" null
option double-tap-ms
Max gap (ms) between taps for double-tap. doubleTapMs
number 300
option edge-px
Width of from-edge start zone (px). edgePx
number 40
option from-edge
Only start within edge-px of these edges — edge swipes (back nav, pulling a closed sheet up). fromEdge
tokenlist "left" | "right" | "top" | "bottom" null
option from-ref
Only start when pointerdown is inside this descendant (:scope-relative selector) — a drag handle, the sheet itself. Combined with from-edge, either qualifies. fromRef
string <CSS Selector> null
option gesture-types
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. gestureTypes
tokenlist "pan" | "pan-x" | "pan-y" | "pinch" | "rotate" | "swipe" | "tap" | "double-tap" | "long-press" "pan"
option handoff-ref
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. handoffRef
string <CSS Selector> null
option lock-axis
A free pan locks to its dominant axis once recognized (gesture-type becomes pan-x / pan-y). lockAxis
boolean false
option long-press-ms
Hold time (ms) for long-press; also tap time limit. longPressMs
number 500
option max-pointers
Extra pointers beyond this many are ignored. Unset = 2 when pinch / rotate listed, else 1. maxPointers
number null
option overshoot-resistance
Rubber-band past progress-min / progress-max: 0 clamps, 0.3 overshoots at a third of travel. overshootResistance
number 0
option pointer-types
Pointer types that can start a gesture. Add mouse for desktop drag. pointerTypes
tokenlist "touch" | "pen" | "mouse" "touch pen"
option progress-axis
Direction --gesture-progress grows. Unset = down for a pan-y-only element, else right. progressAxis
string "up" | "down" | "left" | "right" null
option progress-max
Upper bound of progress. progressMax
number 1
option progress-min
Lower bound of progress. progressMin
number 0
option progress-offset
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. progressOffset
number 0
option range-px
Literal range (px) instead of range-ref. rangePx
number null
option range-ref
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. rangeRef
string <CSS Selector> null
option should-emit-move
Fire gesture-handler-move every frame. Off by default; --gesture-* is enough for CSS. shouldEmitMove
boolean false
option snap-points
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). snapPoints
tokenlist <number>… null
option swipe-directions
Swipe directions to report. Unset = all four. swipeDirections
tokenlist "left" | "right" | "up" | "down" null
option swipe-min-velocity
Release velocity (px/ms) that counts as a swipe. swipeMinVelocity
number 0.5
option threshold-px
Movement (px) before a pan is recognized. Taps / native scroll stay untouched below it. thresholdPx
number 8
hybrid is-disabled
Ignore new pointers; a gesture in progress is cancelled. State-driven veto. isDisabled
boolean false
state gesture-direction
Dominant travel direction of gesture in progress. gestureDirection
string "left" | "right" | "up" | "down" null
state gesture-type
Recognized gesture in progress, unset before recognition and after release. gestureType
string "pan" | "pan-x" | "pan-y" | "pinch" | "rotate" null
state is-active
A pointer is down on the surface. Set from first pointer until release, so it also covers taps and pre-threshold phase. isActive
boolean false
state last-gesture
What last gesture turned out to be — style a "just swiped" state from it. lastGesture
string "pan" | "pan-x" | "pan-y" | "pinch" | "rotate" | "swipe-left" | "swipe-right" | "swipe-up" | "swipe-down" | "tap" | "double-tap" | "long-press" null
state pointer-count
Pointers currently down. pointerCount
number null
Provision (1) This provision property lives on the DOM node. Read / watch it with Quark’s prop("provision"), or listen for the neutron-provision event from app JS. Property Type provision
Last -start / -end / -cancel snapshot (type, travel, velocity, progress, snap, swipe, …). Not an attribute; per-frame values live in --gesture-*. provision
GestureHandlerProvision
Events (9)
Dispatches — events that gesture-handler fires and their default actions. Dispatch Default Action
A "default action" is subsequent logic executed by the element if e.preventDefault() is not synchronously called on the event.
Name gesture-handler-cancel
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.

Type GestureHandlerCancelEvent
Name gesture-handler-double-tap
Second tap within double-tap-ms of the previous (after its gesture-handler-tap). detail = { x, y }. Pair consumed; a third tap starts over.

Type GestureHandlerPointEvent
Name gesture-handler-end
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.

Type GestureHandlerEndEvent
Name gesture-handler-long-press
Pointer stayed down without moving for long-press-ms. detail = { x, y }.

Type GestureHandlerPointEvent
Name gesture-handler-move
Once per frame while a recognized gesture moves, only with should-emit-move. detail = provision. --gesture-* always updates, event or not.

Type GestureHandlerMoveEvent
Name gesture-handler-snap
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.

Type GestureHandlerSnapEvent
Name gesture-handler-start
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.

Type GestureHandlerStartEvent
Name gesture-handler-swipe
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).

Type GestureHandlerSwipeEvent
Name gesture-handler-tap
Down and up without moving past threshold-px, within long-press-ms. detail = { x, y } relative to the element.

Type GestureHandlerPointEvent
Listeners — events that gesture-handler listens for which will trigger subsequent actions. Listener Type Action
Commands — verbs gesture-handler accepts as a command event (HTML Command API): from a <button command commandfor>, an <event-handler command-name>, or a CommandEvent. Command Action
Recognized Elements (0) Child or descendant elements are recognized by gesture-handler and relevant to its functionality. Element Relationship Required
Styles (22)
Classes — optional classes that change the appearance of gesture-handler. Class Description
Variables — public CSS variables for theming gesture-handler. Opt-out:
If you wish to opt-out of these styles on a case-to-case basis, use property all: revert-layer. Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
Variable Syntax Default --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)))
Aliases — @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). Alias Kind Matches :--gesture-handler element gesture-handler, .tag-gesture-handler :--gesture-handler--is-active state [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-ref overscroll 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
View Source

Examples

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.

Beta. The Nucleus Stack is in beta for a few weeks until features are stabilized and optimized.

Thanks — we'll email you when it ships.

Something went wrong. Please try again.