@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:
useSwipeOverlayDialogdraws 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 oneattemptClosethat a guard can refuse - Exit presence:
useOverlayPresencekeeps a closing overlay mounted forSWIPE_OVERLAY_EXIT_MSso its exit plays before it unmounts - Touch-action contract: Exported constants declare the
touch-actionof 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-overlayPeer 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 whileskipWhenTextSelectedis on. - A press of a non-primary pointer (
isPrimaryfalse: a second finger) ends any swipe in progress (it releases the pointer capture, puts the panel back and restores its inlinetransition) 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
attemptClosewhen the drag passedswipeThreshold, or when a flick passed bothflickVelocityandflickDistance; otherwise the panel snaps back. WhenisCloseBlockedrefuses the close, the panel snaps back too. - The panel's own inline
transitionis kept. When a swipe locks, the hook saves it before writingtransition: none, and gives it back when the swipe ends without closing (a snap back, apointercancel, 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,noneunder 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 (aftershowModal(), in the same commit), for example a heading withtabIndex={-1};null, or leavinginitialFocusout, 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
isOpenturns 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 andinertfor as long as you keep it mounted to show its exit. - Every way of closing goes through
attemptClose, whichisCloseBlockedcan 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 throughattemptClose, 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.stylecarries the swipe'stransformand the surface'stouch-action, so spread it when you add styles of your own; give the backdrop nostyleof its own (backdropProps.styleis itstouch-action) and style it with a class. - Give the dialog a
displayof its own, as.side-dialogdoes 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 mostSWIPE_OVERLAY_EXIT_MSlong, andprefers-reduced-motion: reduceturns 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'stransformwhenisOpenturns false, a backdrop's fade) must not outlastSWIPE_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).useSwipeOverlayDialogapplies the panel's and the backdrop's value itself (panelProps.style,backdropProps.style).useSwipeToDismissOverlayreturns anoverlayStylewith onlytransform, so a panel and a backdrop you build by hand applySWIPE_OVERLAY_PANEL_TOUCH_ACTIONyourself.- Give every vertical scroll area inside the panel
SWIPE_OVERLAY_SCROLL_AREA_STYLE. A scroll container resets thetouch-actionrestriction 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 itstouchmoveevents long before the browser's pan would start.overscroll-behavior-y: containkeeps 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-xoroverflow-yisautoorscroll) has a computedtouch-actionthat allows a horizontal pan (auto,manipulation,pan-x,pan-leftorpan-right), and some element from the pressed one up to the panel (the panel excluded) scrolls horizontally (overflow-xofautoorscroll, andscrollWidth > 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 declaresSWIPE_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
touchmoveevents 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 owntouch-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 itstouchmove, 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 noclick: 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 atouchmovethe 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.isBlockedasisCloseBlockedand provide the state withSwipeCloseGuardContext.Provider. A child asks throughuseSwipeCloseGuard().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 runsonTapon the click that follows a tap, not on the release.onPointerDownremembers where the press went down;onPointerUparms the click when the release is less thanthresholdpx from the press and disarms it otherwise;onPointerCancelforgets the press and disarms;onClickalways callspreventDefault()and runsonTaponce, only when a tap armed it (a click no tap armed, such as a keyboard's synthesized click, does nothing).onKeyDownrunsonTapon 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
useSwipeToDismissOverlayregisters 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()returnsregister({ 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, andonEscapereceives theKeyboardEvent(returntrueto consume it,falseto leave it to the page). WithoutownerDocumentthe entry listens on the globaldocument.- An Escape that cancels an IME composition (
isComposing, or Safari'skeyCode229 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, whoseonKeyDownCapturemarks 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 isonKeyDownCapture, so you can spreaddialogPropsand add anonKeyDownof your own. - A swipe that closes the overlay animates out with an inline
transformtransition (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 inlinetransitioncomes 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 |
