Add a swipe primitive for drawer-style dialogs
Status
Specification revision R13. This revision merges 30 maintainer decisions taken after R11 (D19 through D48). No product-policy choice is OPEN. Implementation, type validation, and the acceptance tests remain to be done. Proposed symbol spellings are not released exports.
R11 decisions are superseded or amended in place rather than rewritten, so the record stays auditable:
- D08 superseded by D23/D24: a same-axis scroller resting at the opposite boundary no longer excludes a swipe start.
- D17 superseded by D20: no CSS property is reserved.
- D18 and D33 superseded by D47: the primitive writes no
touch-actionand owns no descendant styles. Pre-contact preparation, descendant discovery and the observation machinery are retired in full. - D01 amended by D39: the deferral of same-touch scroll takeover is withdrawn; snap sizes remain deferred.
- D03 through D07, D09 through D12, D14 and D16 amended as recorded below. D02, D13 and D15 are unchanged.
Further contract changes belong in this body. The companion design document holds the examples, the prototypes and the feedback controls; it is not a second specification.
Motivation
An edge-positioned dialog should follow a drag and dismiss on a qualifying release without making application authors rebuild focus, modal isolation, close cancellation, controlled state, or native scrolling behavior.
The earlier Sidebar/Drawer request #91 was closed; its follow-up describes a drawer as a styled Dialog. That remains the semantic foundation. This request adds reusable gesture behavior, not a new dialog role or a parallel Dialog implementation.
Goals
- React first: compose one reusable swipe primitive with an existing Dialog element.
- Keep one authoritative open state. Drag progress must not change modality or focus ownership.
- Allow eligible content on the bound surface to start a swipe, including content inside a scroller resting at the boundary opposite the closing direction.
- Support the same primitive without a store for recognition and automatic return.
- Let authors style dragging, return, and leave through CSS outputs with a defined lifecycle, including a backdrop that tracks the drag.
Non-goals and scope limits
- D01 defers snap sizes. No second numeric open state or native multi-stop sheet is part of v1. D01's deferral of same-touch scroll takeover is withdrawn: D39 adopts a bounded takeover, specified in requirement 5.
- The selected shape is not a complete Drawer component family, a store-required dismiss primitive, or separate recognizer and dismissal adapter exports.
- No required region wrappers, author discovery markers for eligible scroll containers, caller completion method, or public preparation-ready protocol.
- This specification adds no swipe-to-open, generic drag-and-drop, arbitrary remote target binding, spring controls, automatic Dialog/Popover switching, or per-frame callback API. Logical direction names are no longer excluded (D41). Continuous backdrop progress is no longer excluded (D19, D43).
- No simultaneous Solid parity promise. D35 records that the whole implementation lives in
@ariakit/react-components; D04's portability clause is aspirational and an implementer should not plan around it.
Usage example
These snippets use the draft name Swipe. Final names and exact types should follow repository precedent without changing the contract.
const dialog = Ariakit.useDialogStore();
<Swipe
store={dialog}
direction="block-end"
hideOnSwipe={() => !saving}
render={<Ariakit.Dialog store={dialog} className="drawer" />}
>
<Ariakit.DialogHeading>Details</Ariakit.DialogHeading>
<div className="drag-handle" />
<div className="drawer-body">
<Cards />
</div>
<Ariakit.DialogDismiss>Close</Ariakit.DialogDismiss>
</Swipe>The primitive and Dialog share the same store and DOM element. There is no new DrawerStore and no required region wrapper. direction="block-end" resolves to a physical direction from the surface's own computed writing mode; direction="down" is equally valid.
Without a store, the primitive recognizes only. A surviving surface returns to rest after notification.
<Swipe
direction={["left", "right"]}
onSwipe={(event) => setActiveCard((i) => i + (event.direction === "right" ? 1 : -1))}
render={<section aria-label="Card preview" />}
>
<Card />
<button onClick={() => setActiveCard((i) => i + 1)}>Next card</button>
</Swipe>Options and release event
type PhysicalDirection = "up" | "right" | "down" | "left";
type LogicalDirection = "inline-start" | "inline-end" | "block-start" | "block-end";
type SwipeDirection = PhysicalDirection | LogicalDirection;
interface SwipeOptions {
store?: DisclosureStore;
direction: SwipeDirection | readonly SwipeDirection[];
swipeable?: boolean; // @default true
elastic?: boolean | number; // @default true (0.35)
onSwipe?: (event: SwipeEvent) => void;
hideOnSwipe?: BooleanOrCallback<SwipeEvent>;
}
interface SwipeEvent extends Event { // dispatched on the bound element, cancelable
readonly direction: PhysicalDirection; // always resolved and physical
readonly distance: number; // CSS pixels toward the closing direction, never negative
readonly extent: number; // untransformed surface size on the swipe axis at the initial press
readonly velocity: number; // CSS pixels per millisecond on the swipe axis
readonly nativeEvent: PointerEvent;
}Recognition thresholds remain internal; velocity is published so a tightening predicate can stay fling-aware. Omitted hideOnSwipe means enabled in store-bound mode. Without a store the hide policy is never evaluated, and preventDefault() has no library-observable effect there.
Requirements
Surface binding, scrolling, and input ownership
Bind the visual target and input surface to one element. Eligible content on it can start a swipe without region wrappers.
At the initial press, use the DOM event path to reserve the sequence for the innermost swipe surface. An ineligible inner surface also blocks outer swipes. Store presence, callbacks, and hide permission cannot change ownership. Do not transfer the same sequence later.
Protect controls, editable content, and author-marked subtrees. Do not claim a sequence while a non-collapsed selection exists inside the surface. Once input is claimed, cancel the competing native default actions:
dragstartandselectstart.A same-axis native scroller does not exclude a swipe start while it rests at the boundary opposite the closing direction; away from that boundary it does. Cross-axis and zero-range containers alone do not exclude a start. This eligibility is decided entirely at gesture time by requirement 5, and is never expressed in CSS. Other protections still apply.
Same-touch takeover. This is the only mechanism by which the primitive competes with native scrolling. The primitive writes no
touch-action, on the bound surface or anywhere else, and owns no descendant styles.While
swipeableis notfalse, it registers one non-passivetouchmovelistener on the bound surface element, always with an explicit{ passive: false }, and never on the document, document element, body, or window. Everything it needs is captured intouchstartfor that sequence, not inpointerdown: standards do not define the relative order ofpointerdownandtouchstartfor one contact.touchstartcaptures the reserved sequence, the start point, the resolved physical direction set, and the chain of same-axis user-scrollable elements from the touch target up to and including the bound surface — a chain that is often empty.On the first
touchmovefor that sequence, and only then, it decides once and latches the result for the whole contact. There is no movement threshold. The decision is made on the very first move because once the user agent commits to scrolling, every latertouchmovereportscancelable: falseand the opportunity is gone.It declines when the sequence is not the reserved one, when more than one touch point is active, when
event.cancelableis false, when the dominant component of the movement is not on the swipe axis, when the sign is not a closing direction, when the two components are equal or both zero, or when the captured chain is non-empty and any member of it is not resting at the boundary opposite the closing direction. When the chain is empty — an ordinary, non-scrolling part of the surface — the boundary test does not apply and axis and direction alone decide. Otherwise it takes over: it callspreventDefault()and latches, and callspreventDefault()on every latertouchmoveof that sequence, guarded byevent.cancelable.A declined sequence never prevents anything and never becomes a swipe. A decline does not end the contact: recovery costs a lift and a fresh press. That is the accepted cost of deciding on the first move.
Declining on a second touch point is what preserves pinch and zoom. Because no
touch-actionis written, the user agent's own zoom and pan behaviour is never restricted by this primitive: a multi-finger gesture is declined on its first move and left entirely to the browser.The listener writes no styles and reads only the scroll offsets of the captured chain. Pointer Events remain the sole source of sequence identity, offsets, velocity, and the release event; the listener's only outputs are
preventDefault()and the latch. Do not correlatePointerEvent.pointerIdwithTouch.identifier. With nested bound surfaces the inner listener runs first and reserves the sequence, whether it then takes over or declines; the outer listener must observe that reservation and return without preventing.Use primary-pointer input. Touch and pen are recognized by default; mouse is opt-in. The mouse path needs no takeover, because a mouse drag cannot start a native touch scroll. Wait for directional intent; a cross-axis gesture does not dismiss. Extra contact,
pointercancel, lost capture, unmount, a direction or store change, orswipeablebecomingfalsecancels the active input sequence without a swipe close request.Suppress a trailing click only after input was claimed for dragging, not after an ordinary tap. A later dismissal veto does not make that claimed drag an ordinary tap. Keep controls operable.
Requirement 5 replaces the CSS approach R11 specified, and it is why the primitive needs no knowledge of the subtree. Both engines reset the used touch-action to auto at a user-scrollable descendant — Blink's own comment reads "Since panning is implemented by the scroller it is re-enabled for scrolling elements" — so CSS written on an ancestor never governed a touch beginning inside a nested scroller, which is exactly the case D23 and D24 are about. See Pointer Events §8.2 and CSS Overflow.
Resolving the direction
direction accepts physical names, logical names, or an array mixing them. Logical names resolve to physical ones from the bound element's own computed style, with no author flag:
writing-mode |
direction |
inline-start |
block-start |
|---|---|---|---|
horizontal-tb |
ltr |
left | top |
horizontal-tb |
rtl |
right | top |
vertical-rl |
ltr |
top | right |
vertical-rl |
rtl |
bottom | right |
vertical-lr |
ltr |
top | left |
vertical-lr |
rtl |
bottom | left |
sideways-rl |
ltr |
top | right |
sideways-rl |
rtl |
bottom | right |
sideways-lr |
ltr |
bottom | left |
sideways-lr |
rtl |
top | left |
inline-end and block-end are the opposites of the corresponding start values. sideways-lr is the one mode whose inline axis runs the opposite way from its vertical-lr neighbour; an implementation that treats the vertical modes uniformly is wrong in exactly those two rows.
Read direction and writing-mode with getComputedStyle on the bound element. That accounts for an inherited dir attribute, including dir="auto", and for a CSS direction declaration; a dir attribute walk and a :dir() match both miss the CSS case, and :dir() explicitly ignores stylistic state. Both are computed-value reads under CSSOM §9, so they force style recalculation and not layout. Resolve at mount, on a direction or surface change, and once per pointerdown; freeze the result for the sequence.
This is not a divergence from repository convention. Popover's top-start and bottom-end already resolve without a flag through Floating UI's isRTL, which is getComputedStyle(element).direction === "rtl", and the combobox utilities already read writingMode. Composite's explicit rtl option exists because createCompositeStore is a DOM-free store whose next() and previous() must answer before an element exists; this primitive is element-bound and has no such constraint. No rtl prop and no dir prop ship.
No inline or block axis values ship: direction already accepts an array, so ["inline-start", "inline-end"] expresses the same set with one vocabulary instead of two. Axis values remain addable later as a purely additive widening.
data-swipe-direction and SwipeEvent.direction always publish the resolved physical direction, never the authored logical string, because the offsets are signed physical pixels and author CSS keyed on a logical name would need opposite signs in the two writing directions.
Recognition, permission, and default dismissal
On a qualifying pointerup, synchronously:
- Complete internal recognition, then dispatch the cancelable release event on the bound element once, in both store modes, with
onSwipeattached as a{ once: true }listener. - If the event was canceled with
preventDefault(), skip the hide predicate and automatic hide. This does not undo recognition, cleanup, or author actions already performed. - Without an explicit store, skip hide permission and default dismissal entirely.
- With a store and an uncanceled default action,
hideOnSwipe={false}stops automatic hide. Otherwise evaluate the predicate once throughuseBooleanEventwith the release event. - If allowed, make at most one library-initiated
store.hide()request through the existing disclosure path. ExistingDialog.onClosecancellation and controlledopenauthority still apply.
Short drags, cancellations, and external closure produce no notification and do not call the predicate. hideOnSwipe={false} does not disable recognition; swipeable={false} is the only switch that does, and it applies in both store modes.
Offsets
The closing direction is followed one to one and is unbounded. Movement against the closing direction is resisted and bounded: resisted(v) = (k · e · v) / (k · v + e), where v is counter-direction displacement in CSS pixels, e is the extent measured at the initial press, and k is elastic (true → 0.35). The curve starts at slope k, never exceeds it, and asymptotes at the surface's own extent, so the surface can never be pulled more than its own size off its open position. elastic={false} or 0 collapses the same formula to an exact clamp.
distance on the release event is therefore never negative. The perpendicular offset is zero once the direction resolves, and zero before it resolves.
CSS presentation contract
Authors own presentation. The primitive supplies read-only signed CSS-pixel offsets, a normalized progress value, the resolved physical direction, and lifecycle state. No CSS property is reserved.
Outputs are written imperatively on the bound surface, following the --dialog-viewport-height and --popover-* precedent, and mirrored onto the Dialog backdrop element when one is present, resolved through the existing data-backdrop semantics rather than a raw attribute selector. Both are written and restored through dialog/utils/orchestrate.ts so author styles survive cleanup. Continuous backdrop progress is therefore inside this contract.
--swipe-offset-x,--swipe-offset-y— signed CSS pixels, one axis always zero.--swipe-progress— unitless,distance / extent, clamped to 0–1,0at rest, never negative.data-swipe-direction— the resolved physical direction.data-swipe— the lifecycle attribute, carrying one of three values:draggingwhile input is claimed and the surface follows the pointer;releasedfrom a qualifying release until the surface is gone or has returned, which is what keeps the release offsets applied through leave;returningwhile a return is in progress.
The attribute is present, with one of those values, from the moment input is claimed until leave completes or the surface returns to rest.
[data-swipe]is therefore the offset gate and[data-swipe="returning"]is where the author declares the return effect. There is no separatedata-return.
/* The leave lives on the base rule, where Ariakit's measurement finds it. */
.drawer {
transition: translate 240ms ease-in, opacity 240ms ease-in;
}
.drawer[data-leave] {
translate: 0 100%;
opacity: 0;
}
/* Offsets apply only while the lifecycle attribute is present, so the surface
carries no transform at rest and creates no containing block. */
.drawer[data-swipe] {
translate: var(--swipe-offset-x, 0px) var(--swipe-offset-y, 0px);
}
/* Follow the finger only while dragging. Never declare transition or animation
properties under the bare [data-swipe] gate: it is present when Ariakit
measures leave timing. */
.drawer[data-swipe="dragging"] {
transition: none;
}
/* The return: any finite effect on the surface, any property. */
.drawer[data-swipe="returning"] {
translate: 0px 0px;
transition: translate 180ms ease-out;
}
/* The backdrop receives the same outputs. */
[data-backdrop][data-swipe] {
opacity: calc(1 - var(--swipe-progress, 0));
}
@media (prefers-reduced-motion: reduce) {
.drawer[data-swipe="returning"] { transition: none; }
}Ordering clause (normative). No swipe-phase rule may reduce or remove the author's transition or animation at the moment Ariakit measures enter or leave timing. The lifecycle attribute must either be absent then, or must not affect transition-property, transition-duration, transition-delay, animation-name, animation-duration or animation-delay. Authors must not declare transition or animation properties under the bare offset gate. The enter case matters more than the leave case: a zero enter measurement sets the disclosure store's animated state to false, after which data-leave is never applied again for that mount. An author who sets a numeric animated bypasses measurement entirely, which makes it the natural negative control in the test. Do not advise transition-duration: 0s as a safer alternative to transition: none; a rule naming a property with a zero duration produces the same zero end time.
Return completion
The primitive reserves no property and never inspects a property name. A return finishes at the earlier of every watched effect settling or a derived ceiling elapsing.
A candidate effect is one returned by Element.getAnimations() on the bound surface — called without { subtree: true } — whose effect.target is that element, whose effect.pseudoElement is null, and whose getComputedTiming().endTime is finite. Property names are never consulted, so a CSS transition, a CSS animation and an Element.animate() effect qualify on equal terms. endTime is used rather than duration
Source: ariakit/ariakit