npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@aiquants/swipe-overlay

v1.3.0

Published

React hooks and context for swipe-to-dismiss overlay interactions

Readme

@aiquants/swipe-overlay

React hooks and context for swipe-to-dismiss overlay interactions.

Features

  • Swipe to dismiss: Smooth swipe gestures to close overlays
  • Touch & Mouse support: Works with both touch devices and mouse interactions
  • Configurable thresholds: Customize swipe thresholds and velocity
  • Escape to close: Overlays stacked on one document answer Escape front-most first (LIFO)
  • Modal dialog: useSwipeOverlayDialog draws the whole overlay as the platform's modal <dialog> and sends every way of closing (a swipe, a backdrop tap, Escape, the platform's close request) through one attemptClose that a guard can refuse
  • Exit presence: useOverlayPresence keeps a closing overlay mounted for SWIPE_OVERLAY_EXIT_MS so its exit plays before it unmounts
  • Touch-action contract: Exported constants declare the touch-action of the surface a finger swipes on, so the browser's own pan does not take the gesture over
  • TypeScript support: Fully typed for better developer experience

Installation

pnpm add @aiquants/swipe-overlay

Peer dependencies: react and react-dom at >=18.0.0.

Usage

import { SWIPE_OVERLAY_PANEL_TOUCH_ACTION, useSwipeToDismissOverlay } from '@aiquants/swipe-overlay'

const MyOverlay = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => {
  const { overlayRef, overlayStyle } = useSwipeToDismissOverlay({
    isOpen,
    onClose,
    isCloseBlocked: () => false,
  })

  return (
    <div
      ref={overlayRef}
      style={{ ...overlayStyle, touchAction: SWIPE_OVERLAY_PANEL_TOUCH_ACTION, transition: 'transform 0.3s ease-out' }}
    >
      Content
    </div>
  )
}

useSwipeToDismissOverlay(options) takes a SwipeToDismissOverlayOptions and returns a SwipeDismissHookResult.

| Option | Type | Default | Description | | --- | --- | --- | --- | | isOpen | boolean | required | Whether the overlay is open | | onClose | () => void | required | Closes the overlay; called through attemptClose only when isCloseBlocked returns false | | isCloseBlocked | () => boolean | required | Returns true while closing is refused (a guard such as an open menu inside) | | skipWhenTextSelected | boolean | false | Ignores a swipe while text is selected (the drag selects instead) | | swipeThreshold | number | 120 | Distance (px) past which a released drag closes | | flickVelocity | number | 0.5 | Speed (px/ms) over the last 100 ms past which a flick closes | | flickDistance | number | 40 | Distance (px) a flick must also travel |

| Result | Description | | --- | --- | | overlayRef | Callback ref of the panel element | | overlayStyle | { transform }: translateX(0px) while open, translateX(100%) while closed (it carries no touch-action) | | attemptClose | Closes unless isCloseBlocked refuses; returns whether it closed | | isSwiping | True while a horizontal swipe drags the panel | | element | The bound element, or null |

How a swipe is recognised:

  • A press of the primary pointer starts tracking unless it is a non-primary mouse button, lands on an element marked data-overlay-interactive="true" (or one of its descendants), lands on an element the browser pans sideways (see Touch-action contract below), or text is selected while skipWhenTextSelected is on.
  • A press of a non-primary pointer (isPrimary false: a second finger) ends any swipe in progress (it releases the pointer capture, puts the panel back and restores its inline transition) and is not tracked, so a pinch or a two-finger pan stays the browser's and never locks a swipe. Each pointer type has its own primary pointer, so a primary press of another type (a mouse pressed while a finger drags) ends the swipe in progress the same way before it is tracked.
  • The first move past a 4 px dead zone settles the direction: a rightward move whose horizontal distance is at least 1.3 times its vertical one becomes the swipe (the panel follows the finger, up to 520 px); anything else ends the tracking.
  • On release the overlay closes through attemptClose when the drag passed swipeThreshold, or when a flick passed both flickVelocity and flickDistance; otherwise the panel snaps back. When isCloseBlocked refuses the close, the panel snaps back too.
  • The panel's own inline transition is kept. When a swipe locks, the hook saves it before writing transition: none, and gives it back when the swipe ends without closing (a snap back, a pointercancel, another pointer), when the overlay closes in the middle of a drag (Escape, Back: the panel then leaves with your transition) and when the overlay opens again. A swipe that closes writes its exit transition (transform 0.3s ease-out, 0.15 s for a flick, none under reduced motion), and your transition comes back on the next open. Your panel's close transition may therefore be written inline or in CSS.

Modal dialog

useSwipeOverlayDialog draws the whole overlay as the platform's modal dialog: a <dialog> that holds a backdrop and a panel. It takes the swipe's options (SwipeToDismissOverlayOptions) plus initialFocus, and returns the props to spread on each part. Pair it with useOverlayPresence to show the exit.

import {
  SWIPE_OVERLAY_SCROLL_AREA_STYLE,
  useOverlayPresence,
  useSwipeOverlayDialog,
} from '@aiquants/swipe-overlay'
import type { ReactNode } from 'react'

type SideDialogProps = { isOpen: boolean; onClose: () => void; children: ReactNode }

const SideDialog = ({ isOpen, onClose, children }: SideDialogProps) => {
  const isPresent = useOverlayPresence(isOpen)
  const { dialogProps, backdropProps, panelProps } = useSwipeOverlayDialog({
    isOpen,
    onClose,
    isCloseBlocked: () => false,
    initialFocus: (dialog) => dialog.querySelector<HTMLElement>('h2'),
  })

  if (!isPresent) return null

  return (
    <dialog {...dialogProps} className="side-dialog">
      <div {...backdropProps} className="side-dialog-backdrop" />
      <div {...panelProps} className="side-dialog-panel">
        <h2 tabIndex={-1}>Title</h2>
        <div className="side-dialog-body" style={{ ...SWIPE_OVERLAY_SCROLL_AREA_STYLE, overflowY: 'auto' }}>
          {children}
        </div>
      </div>
    </dialog>
  )
}
.side-dialog {
  /* The closed dialog stays mounted while it leaves, and the UA stylesheet hides a closed dialog */
  display: block;
  /* A closed dialog leaves the top layer, so keep it in front of the page while it leaves */
  z-index: 1000;
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  overflow: hidden;
}

.side-dialog-backdrop {
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / 0.3);
}

.side-dialog-panel {
  position: absolute;
  top: 0;
  right: 0;
  display: flex;
  flex-direction: column;
  width: 80%;
  max-width: 400px;
  height: 100%;
  background: white;
  /* The close transition: at most SWIPE_OVERLAY_EXIT_MS (300 ms) */
  transition: transform 300ms ease-out;
}

.side-dialog-body {
  flex: 1;
  min-height: 0;
}

@media (prefers-reduced-motion: reduce) {
  .side-dialog-panel {
    transition: none;
  }
}

| Result | Description | | --- | --- | | dialogProps | Props of the <dialog>: ref, tabIndex (-1, so the dialog itself can take the focus), onKeyDownCapture (marks the task of an Escape that reached the dialog), onCancel and onClose (the platform's cancel and close events) | | backdropProps | Props of the backdrop: ref (the hook takes its one-finger touch moves), aria-hidden (true), style (its touch-action) and the useTapClick handlers, so the click that follows a tap closes and a scroll does not | | panelProps | Props of the panel: ref and style (the swipe's transform and the surface's touch-action) | | attemptClose | Closes unless isCloseBlocked refuses; returns whether it closed | | isSwiping | True while a horizontal swipe drags the panel |

  • While isOpen, the dialog is open modally (showModal(): the top layer, the rest of the page inert) and the focus moves into it. initialFocus(dialog) returns the element to focus once the dialog has opened (after showModal(), in the same commit), for example a heading with tabIndex={-1}; null, or leaving initialFocus out, keeps the focus where the platform's dialog focusing steps put it (the first focusable descendant). It is called on every opening and may change on any render.
  • When isOpen turns false, the focus inside the dialog is dropped, the modal ends (close(), which returns the focus to where it was before) and the dialog stays closed and inert for as long as you keep it mounted to show its exit.
  • Every way of closing goes through attemptClose, which isCloseBlocked can refuse: a swipe of the panel, a tap on the backdrop (on the click that follows it), Escape (through the document's Escape stack) and the platform's close request (cancel: Escape on another document, Android's Back), whose default the dialog prevents. A close request that an IME's Escape or a refused Escape caused (as that key's default action, in the key's own task) is dropped. A close the platform forces (one a page may not cancel) still goes through attemptClose, and the dialog opens modally again when that is refused.
  • The swipe and Escape work once the panel carries panelProps.ref.
  • The dialog's look (its UA box, the backdrop's colour, the panel's surface and transitions) is yours. panelProps.style carries the swipe's transform and the surface's touch-action, so spread it when you add styles of your own; give the backdrop no style of its own (backdropProps.style is its touch-action) and style it with a class.
  • Give the dialog a display of its own, as .side-dialog does above. While the overlay leaves, the hook keeps the <dialog> mounted but closed (not open), and the UA stylesheet hides a closed dialog (dialog:not([open]) { display: none }), so without it the exit is never seen. The panel's close transition sits in the CSS, at most SWIPE_OVERLAY_EXIT_MS long, and prefers-reduced-motion: reduce turns it off; a swipe that closes writes its own exit transition (none under reduced motion), and the CSS transition applies again on the next open.

Keeping a closing overlay mounted

useOverlayPresence(isOpen) returns whether to keep an overlay mounted: true while it is open and for SWIPE_OVERLAY_EXIT_MS (300 ms) after it closes, so its exit (a swipe's slide-out, or your own close transition) plays before it unmounts.

  • The render right after the close still returns true: the hook only reads a recorded phase, and a layout effect records it (no render writes state).
  • Opening again while the overlay leaves keeps it mounted; the next close counts the exit anew. An overlay that mounts closed returns false. The timer is cleared on unmount.
  • A dragged panel slides out over SWIPE_OVERLAY_EXIT_MS (300 ms) and a flicked one over 150 ms, so a close transition of your own (the panel's transform when isOpen turns false, a backdrop's fade) must not outlast SWIPE_OVERLAY_EXIT_MS.

Touch-action contract

The browser settles how it handles a gesture from the touch-action at the point where the finger lands, so the surface a finger swipes on declares it up front. Calling preventDefault() on pointermove does not stop a native pan; the hook takes the touchmove events of a locked swipe instead (last bullet below).

| Surface | Constant | Value | | --- | --- | --- | | Panel and backdrop | SWIPE_OVERLAY_PANEL_TOUCH_ACTION | "pinch-zoom" | | Vertical scroll area inside the panel | SWIPE_OVERLAY_SCROLL_AREA_TOUCH_ACTION | "pan-y pinch-zoom" | | Vertical scroll area inside the panel (whole style) | SWIPE_OVERLAY_SCROLL_AREA_STYLE | frozen { touchAction: "pan-y pinch-zoom", overscrollBehaviorY: "contain" } |

  • "pinch-zoom" leaves every one-finger move to the page as pointer events: a horizontal move on the panel is the swipe, and a vertical one on its chrome (its header, its controls) or on the backdrop neither scrolls nor pulls the page behind the overlay (no pull-to-refresh).
  • useSwipeOverlayDialog applies the panel's and the backdrop's value itself (panelProps.style, backdropProps.style). useSwipeToDismissOverlay returns an overlayStyle with only transform, so a panel and a backdrop you build by hand apply SWIPE_OVERLAY_PANEL_TOUCH_ACTION yourself.
  • Give every vertical scroll area inside the panel SWIPE_OVERLAY_SCROLL_AREA_STYLE. A scroll container resets the touch-action restriction it inherits (an element's touch-action holds only up to its nearest scroll container), so without the declaration the browser may pan the area sideways: a leftward move (never a swipe) can scroll an outer element or start the browser's history swipe, and an area whose content is wider than it is left to the browser's pan, so no swipe starts there (next bullet). With the declaration, a sideways move on the area is only ever the swipe. Where a swipe does start, the browser's pan never takes it over: it locks at 4 px, and the panel takes its touchmove events long before the browser's pan would start. overscroll-behavior-y: contain keeps a vertical scroll that reaches the area's end from chaining to the page behind the overlay (which would scroll it, or pull to refresh on a phone).
  • A press on an element the browser pans sideways does not start a swipe. The hook applies the browser's own rule: every element from the pressed one up to and including its nearest scroll container (an element whose computed overflow-x or overflow-y is auto or scroll) has a computed touch-action that allows a horizontal pan (auto, manipulation, pan-x, pan-left or pan-right), and some element from the pressed one up to the panel (the panel excluded) scrolls horizontally (overflow-x of auto or scroll, and scrollWidth > clientWidth). Elements above the nearest scroll container cannot restrict the pan, so an undeclared vertical scroll area inside a horizontal scroller whose content overflows chains the pan to that scroller, and the swipe does not start there. A vertical scroll area that declares SWIPE_OVERLAY_SCROLL_AREA_STYLE (pan-y pinch-zoom) is never such an element, even when its content happens to be wider than it.
  • The page takes the one-finger touchmove events the browser cannot use (preventDefault() through non-passive listeners the hooks keep while the overlay is open): those of a press outside the panel's scroll areas (and outside its interactive parts) from the first move, since the panel's own touch-action (pinch-zoom only) holds there and the browser cannot pan it anyway; those of a press inside a scroll area once its swipe locks onto closing (the pointer's move arrives before its touchmove, so the move that locks the swipe is taken too); and every one on the backdrop (useSwipeOverlayDialog). Without it, the browser keeps treating the motion as its own gesture, and a tap right after it (in Chromium, within about a second) produces no click: the close button after a drag on the panel's header, the backdrop after a drag on it, the next item after a swipe. A move of two fingers (a pinch) and a touchmove the browser made uncancelable (a pan already began) are left alone.

Guards, taps and interactive elements

  • useSwipeGuardState() returns { isBlocked, requestBlock, reset }. Blocking is token based: several components can ask at once and it holds until every one has released; reset() clears them all. Pass () => guard.isBlocked as isCloseBlocked and provide the state with SwipeCloseGuardContext.Provider. A child asks through useSwipeCloseGuard().requestBlock(), which returns the function that releases its block; without a provider the guard never blocks.
  • Put data-overlay-interactive="true" on an element (a slider, a drag handle) so a press on it, or on a descendant, never starts a swipe.
  • useTransitionDisplay(isOpen, current) keeps the last non-null value while the overlay closes, so the content does not vanish during the exit.
  • useTapClick(onTap, threshold = 10) tells a tap from a scroll and runs onTap on the click that follows a tap, not on the release. onPointerDown remembers where the press went down; onPointerUp arms the click when the release is less than threshold px from the press and disarms it otherwise; onPointerCancel forgets the press and disarms; onClick always calls preventDefault() and runs onTap once, only when a tap armed it (a click no tap armed, such as a keyboard's synthesized click, does nothing). onKeyDown runs onTap on Enter or Space (" " or "Spacebar") when the element itself has the focus; a key on a descendant is left to it. Running at the release would close an overlay before the tap's click, and a touch click is hit-tested again after the release, so it would land on whatever the closed overlay uncovered (in Chromium, a list row under the backdrop). Spread the returned handlers (TapClickHandlers) on the element.

Escape and motion

  • useSwipeToDismissOverlay registers the overlay on its element's own document (element.ownerDocument) while it is open and attached, so an overlay drawn into an iframe or another window closes on that document's Escape key.
  • useOverlayEscapeStack() returns register({ id, onEscape, ownerDocument? }): each document keeps its own stack, only the front-most entry of the document the key was pressed in receives the key, and onEscape receives the KeyboardEvent (return true to consume it, false to leave it to the page). Without ownerDocument the entry listens on the global document.
  • An Escape that cancels an IME composition (isComposing, or Safari's keyCode 229 on the confirming keydown) reaches no entry and is neither prevented nor stopped, so a text field inside the overlay can cancel its composition.
  • With useSwipeOverlayDialog, an Escape that closes is consumed by the document's stack, so the platform raises no close request for it. An Escape the stack declines (an IME composition, or a close a guard refused) reaches the dialog, whose onKeyDownCapture marks the key's task in the capture phase; the dialog drops the platform's close request that key causes as its default action in that task, however long the page took to handle the key, and a close request in a later task (Android's Back right after it) still closes. Because the mark is set in the capture phase, a descendant that handles Escape itself and stops its propagation (a menu inside that closes and releases the guard it held) cannot hide it, so that Escape never closes the overlay too. The hook's handler is onKeyDownCapture, so you can spread dialogProps and add an onKeyDown of your own.
  • A swipe that closes the overlay animates out with an inline transform transition (300 ms for a drag, 150 ms for a flick), except when the element's window asks for reduced motion (prefers-reduced-motion: reduce): the overlay then leaves without a transition. The panel's own inline transition comes back on the next open.

Exports

| Kind | Export | Description | | --- | --- | --- | | Hook | useSwipeToDismissOverlay | Swipe-to-dismiss for one panel element | | | useSwipeOverlayDialog | The whole overlay as the platform's modal <dialog> | | | useOverlayPresence | Keeps a closing overlay mounted for SWIPE_OVERLAY_EXIT_MS | | | useOverlayEscapeStack | Per-document LIFO stack for the Escape key | | | useSwipeGuardState | Token-based blocking state: { isBlocked, requestBlock, reset } | | | useSwipeCloseGuard | Reads the guard from SwipeCloseGuardContext (never blocks without a provider) | | | useTransitionDisplay | Keeps the last non-null value while the overlay closes | | | useTapClick | Runs an action on the click that follows a tap (not on a scroll), plus Enter / Space on the element itself | | Context | SwipeCloseGuardContext | React context that carries the guard | | Constant | SWIPE_OVERLAY_EXIT_MS | 300: how long a closing overlay takes to leave (a flicked panel leaves in 150 ms) | | | SWIPE_OVERLAY_PANEL_TOUCH_ACTION | "pinch-zoom": the touch-action of the panel and the backdrop | | | SWIPE_OVERLAY_SCROLL_AREA_TOUCH_ACTION | "pan-y pinch-zoom": the touch-action of a vertical scroll area inside the panel | | | SWIPE_OVERLAY_SCROLL_AREA_STYLE | Frozen { touchAction, overscrollBehaviorY: "contain" } for a vertical scroll area | | Type | SwipeCloseGuardContextValue | The guard's shape: { isBlocked, requestBlock } | | | SwipeToDismissOverlayOptions | Options of useSwipeToDismissOverlay | | | SwipeDismissHookResult | What useSwipeToDismissOverlay returns | | | TapClickHandlers | The handlers useTapClick returns | | | SwipeOverlayDialogOptions | Options of useSwipeOverlayDialog: the swipe's options plus initialFocus | | | SwipeOverlayDialogProps | Props of the <dialog> | | | SwipeOverlayBackdropProps | Props of the backdrop | | | SwipeOverlayPanelProps | Props of the panel | | | SwipeOverlayDialog | What useSwipeOverlayDialog returns |