diff --git a/.changeset/dialog-non-modal.md b/.changeset/dialog-non-modal.md
new file mode 100644
index 0000000000..0f83d867bb
--- /dev/null
+++ b/.changeset/dialog-non-modal.md
@@ -0,0 +1,7 @@
+---
+"@zag-js/dialog": patch
+---
+
+- Set `pointer-events: none` on positioner in non-modal mode so the page stays interactive
+- Add initial focus management for non-modal mode (mirrors popover behavior)
+- Fix `aria-modal` to reflect actual `modal` prop value instead of hardcoded `true`
diff --git a/.changeset/drawer-swipe-area.md b/.changeset/drawer-swipe-area.md
new file mode 100644
index 0000000000..e2bc05062f
--- /dev/null
+++ b/.changeset/drawer-swipe-area.md
@@ -0,0 +1,18 @@
+---
+"@zag-js/drawer": minor
+---
+
+- Add `description` anatomy part with `aria-describedby` support on the content element
+- Add `SwipeArea` part for swipe-to-open gestures from screen edges
+
+ ```tsx
+
{
setState: (key: string, value: any) => void
getState: (key: string) => any
keys: (keyof T)[]
- mergeProps: (props: P) => ComputedRef & P>
+ mergeProps: (props: Partial
) => ComputedRef & Partial>
}
export const useControls = (config: T): UseControlsReturn => {
@@ -36,7 +36,7 @@ export const useControls = (config: T): UseControlsRetu
setState,
getState,
keys: Object.keys(config) as (keyof ControlValue)[],
- mergeProps: (props: P): ComputedRef & P> => {
+ mergeProps: (props: Partial
): ComputedRef & P> => {
return computed(() => ({
...getTransformedControlValues(config, toRaw(toValue(state))),
...props,
diff --git a/examples/nuxt-ts/app/pages/drawer/action-sheet.vue b/examples/nuxt-ts/app/pages/drawer/action-sheet.vue
new file mode 100644
index 0000000000..2f520dc595
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/action-sheet.vue
@@ -0,0 +1,43 @@
+
+
+
+
+ Manage Profile
+
+
+
+
+
Profile Actions
+
+
+ Edit Profile
+
+
+ Change Avatar
+
+
+ Privacy Settings
+
+
+
+
+
+ Delete Account
+
+
+
+ Cancel
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/drawer/basic.vue b/examples/nuxt-ts/app/pages/drawer/basic.vue
index 9300dbc90d..aafdf99cd2 100644
--- a/examples/nuxt-ts/app/pages/drawer/basic.vue
+++ b/examples/nuxt-ts/app/pages/drawer/basic.vue
@@ -2,6 +2,7 @@
import * as drawer from "@zag-js/drawer"
import { drawerControls } from "@zag-js/shared"
import { normalizeProps, useMachine } from "@zag-js/vue"
+import Presence from "~/components/Presence.vue"
import styles from "../../../../shared/styles/drawer.module.css"
const controls = useControls(drawerControls)
@@ -19,9 +20,9 @@ const api = computed(() => drawer.connect(service, normalizeProps))
Open
-
+
-
+
@@ -30,12 +31,12 @@ const api = computed(() => drawer.connect(service, normalizeProps))
-
+
-
+
diff --git a/examples/nuxt-ts/app/pages/drawer/controlled.vue b/examples/nuxt-ts/app/pages/drawer/controlled.vue
new file mode 100644
index 0000000000..be12c87bb2
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/controlled.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ Always Open
+
+
+ Controlled
+
+
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/drawer/cross-axis-scroll.vue b/examples/nuxt-ts/app/pages/drawer/cross-axis-scroll.vue
new file mode 100644
index 0000000000..612b2e2238
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/cross-axis-scroll.vue
@@ -0,0 +1,63 @@
+
+
+
+
+ Open Drawer
+
+
+
+
+ Cross-Axis Scroll
+
+ Try scrolling the image carousel horizontally. It should scroll without triggering the drawer drag.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/drawer/default-active-snap-point.vue b/examples/nuxt-ts/app/pages/drawer/default-active-snap-point.vue
index 542af767e6..0c934dbd87 100644
--- a/examples/nuxt-ts/app/pages/drawer/default-active-snap-point.vue
+++ b/examples/nuxt-ts/app/pages/drawer/default-active-snap-point.vue
@@ -11,8 +11,8 @@ const service = useMachine(
drawer.machine,
controls.mergeProps({
id: useId(),
- snapPoints: [0.25, "250px", 1],
- defaultSnapPoint: 0.25,
+ snapPoints: ["20rem", 1],
+ defaultSnapPoint: "20rem",
}),
)
diff --git a/examples/nuxt-ts/app/pages/drawer/draggable-false.vue b/examples/nuxt-ts/app/pages/drawer/draggable-false.vue
new file mode 100644
index 0000000000..489d28558f
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/draggable-false.vue
@@ -0,0 +1,44 @@
+
+
+
+
+ Open
+
+
+
+
+ Drawer
+ No drag area
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/drawer/indent-background.vue b/examples/nuxt-ts/app/pages/drawer/indent-background.vue
new file mode 100644
index 0000000000..7c5eac0b67
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/indent-background.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
Drawer Indent Background
+
+ Open and drag the drawer. The background and app shell use stack snapshot props so styles stay coordinated.
+
+
Open Drawer
+
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/drawer/mobile-nav.vue b/examples/nuxt-ts/app/pages/drawer/mobile-nav.vue
new file mode 100644
index 0000000000..1aee3a14ca
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/mobile-nav.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Menu
+
+ Scroll the long list. Flick down from the top to dismiss.
+
+
+
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/drawer/nested.vue b/examples/nuxt-ts/app/pages/drawer/nested.vue
new file mode 100644
index 0000000000..c7da1bfc26
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/nested.vue
@@ -0,0 +1,146 @@
+
+
+
+
+ Open drawer stack
+
+
+
+
+
+
+
+
Account
+
+ Nested drawers can be styled to stack, while each drawer remains independently focus managed.
+
+
+
+
+ Security settings
+
+
Close
+
+
+
+
+
+
+
+
+
+
+
+
+
Security
+
Review sign-in activity and update your security preferences.
+
+
+ Passkeys enabled
+ 2FA via authenticator app
+ 3 signed-in devices
+
+
+
+
+ Advanced options
+
+
Close
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Advanced
+
This drawer is taller to demonstrate variable-height stacking.
+
+
+ Device name
+
+
+
+
+ Notes
+
+
+
+
+ Done
+
+
+
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/drawer/non-modal.vue b/examples/nuxt-ts/app/pages/drawer/non-modal.vue
new file mode 100644
index 0000000000..bcca819f1e
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/non-modal.vue
@@ -0,0 +1,54 @@
+
+
+
+
+ Open Drawer
+
+
+
+ This area stays interactive while the drawer is open. Try typing below:
+
+
+
+
+
+
+ Non-modal Drawer
+
+ No backdrop, no focus trap, no scroll lock. The page behind stays fully interactive. Close with the button,
+ drag to dismiss, or Escape.
+
+
+ Close
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/drawer/range-input.vue b/examples/nuxt-ts/app/pages/drawer/range-input.vue
new file mode 100644
index 0000000000..e7d47c6562
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/range-input.vue
@@ -0,0 +1,67 @@
+
+
+
+
+ Open
+
+
+
+
+ Drawer + native range
+
+ Drag the slider horizontally. The sheet should not move or steal the gesture while adjusting the range.
+
+
+
+ Volume (native <input type="range">)
+
+
+
+ {{ volume }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/drawer/snap-points.vue b/examples/nuxt-ts/app/pages/drawer/snap-points.vue
index 98e6c073cc..a5d0f34303 100644
--- a/examples/nuxt-ts/app/pages/drawer/snap-points.vue
+++ b/examples/nuxt-ts/app/pages/drawer/snap-points.vue
@@ -9,9 +9,9 @@ const controls = useControls(drawerControls)
const service = useMachine(
drawer.machine,
- controls.mergeProps({
+ controls.mergeProps({
id: useId(),
- snapPoints: [0.25, "250px", 1],
+ snapPoints: ["20rem", 1],
}),
)
diff --git a/examples/nuxt-ts/app/pages/drawer/swipe-area.vue b/examples/nuxt-ts/app/pages/drawer/swipe-area.vue
new file mode 100644
index 0000000000..0ebd45daca
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/drawer/swipe-area.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+ Drawer
+ Swipe up from the bottom edge to open this drawer.
+ Close
+
+
+
+
+
+
+
+
+
diff --git a/examples/shared/styles/drawer-action-sheet.module.css b/examples/shared/styles/drawer-action-sheet.module.css
new file mode 100644
index 0000000000..e79b2ff924
--- /dev/null
+++ b/examples/shared/styles/drawer-action-sheet.module.css
@@ -0,0 +1,200 @@
+.trigger {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 2.5rem;
+ padding: 0 0.875rem;
+ margin: 0;
+ outline: 0;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #f9fafb;
+ font-family: inherit;
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: 1.5rem;
+ color: #111827;
+ user-select: none;
+ cursor: pointer;
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: #f3f4f6;
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: -1px;
+ }
+}
+
+.backdrop {
+ --backdrop-opacity: 0.4;
+ position: fixed;
+ min-height: 100dvh;
+ inset: 0;
+ background-color: black;
+ opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress, 0)));
+ transition-duration: 450ms;
+ transition-property: opacity;
+ transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
+
+ &[data-state="open"] {
+ animation: fadeIn 450ms cubic-bezier(0.32, 0.72, 0, 1);
+ }
+
+ &[data-state="closed"] {
+ animation: fadeOut 450ms cubic-bezier(0.32, 0.72, 0, 1);
+ opacity: 0;
+ }
+
+ &[data-swiping] {
+ animation: none;
+ transition-duration: 0ms;
+ }
+}
+
+.positioner {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+}
+
+.popup {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 28rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: 0 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
+ outline: 0;
+ transform: translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0);
+ transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1);
+ will-change: transform;
+
+ &[data-state="open"] {
+ animation: slideUp 450ms cubic-bezier(0.32, 0.72, 0, 1);
+ }
+
+ &[data-state="closed"] {
+ animation: slideDown 450ms cubic-bezier(0.32, 0.72, 0, 1);
+ }
+
+ &[data-swiping] {
+ user-select: none;
+ transition-duration: 0ms;
+ }
+}
+
+.surface {
+ pointer-events: auto;
+ border-radius: 1rem;
+ outline: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+ color: #111827;
+ overflow: hidden;
+}
+
+.actions {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.action:not(:first-child) {
+ border-top: 1px solid #e5e7eb;
+}
+
+.actionButton {
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ padding: 1rem 1.25rem;
+ border: 0;
+ background-color: transparent;
+ font-family: inherit;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ text-align: center;
+ color: inherit;
+ user-select: none;
+ cursor: pointer;
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: #f3f4f6;
+ }
+ }
+
+ &:focus-visible {
+ outline: 0;
+ background-color: #f3f4f6;
+ }
+}
+
+.dangerSurface {
+ pointer-events: auto;
+ border-radius: 1rem;
+ outline: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+ overflow: hidden;
+}
+
+.dangerButton {
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ padding: 1rem 1.25rem;
+ border: 0;
+ background-color: transparent;
+ font-family: inherit;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ text-align: center;
+ color: #dc2626;
+ user-select: none;
+ cursor: pointer;
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: #fef2f2;
+ }
+ }
+
+ &:focus-visible {
+ outline: 0;
+ background-color: #fef2f2;
+ }
+}
+
+.title {
+ font-size: 0.8125rem;
+ line-height: 1.25rem;
+ text-align: center;
+ color: #6b7280;
+ padding: 0.75rem 1.25rem 0;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+}
+
+@keyframes fadeOut {
+ from { opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress, 0))); }
+ to { opacity: 0; }
+}
+
+@keyframes slideUp {
+ from { transform: translateY(calc(100% + 1rem + 2px)); }
+ to { transform: translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0); }
+}
+
+@keyframes slideDown {
+ from { transform: translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0); }
+ to { transform: translateY(calc(100% + 1rem + 2px)); }
+}
diff --git a/examples/shared/styles/drawer-indent.module.css b/examples/shared/styles/drawer-indent.module.css
index 2e9daf994f..ff31064702 100644
--- a/examples/shared/styles/drawer-indent.module.css
+++ b/examples/shared/styles/drawer-indent.module.css
@@ -120,6 +120,11 @@
top: auto;
bottom: auto;
+ &[data-swiping] {
+ user-select: none;
+ -webkit-user-select: none;
+ }
+
&[data-state="open"] {
animation-name: slideInDown;
}
diff --git a/examples/shared/styles/drawer-mobile-nav.module.css b/examples/shared/styles/drawer-mobile-nav.module.css
new file mode 100644
index 0000000000..def0e64765
--- /dev/null
+++ b/examples/shared/styles/drawer-mobile-nav.module.css
@@ -0,0 +1,279 @@
+.page {
+ min-height: 100vh;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+}
+
+.menuButton {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 2.5rem;
+ padding: 0 0.875rem;
+ margin: 0;
+ outline: 0;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #f9fafb;
+ font-family: inherit;
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: 1.5rem;
+ color: #111827;
+ user-select: none;
+ cursor: pointer;
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: #f3f4f6;
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: -1px;
+ }
+}
+
+.logo {
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: #111827;
+}
+
+.content {
+ padding: 24px 16px;
+ color: #6b7280;
+}
+
+.backdrop {
+ --backdrop-opacity: 1;
+ position: fixed;
+ inset: 0;
+ min-height: 100dvh;
+ transition-duration: 600ms;
+ transition-property: backdrop-filter, opacity;
+ transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
+ backdrop-filter: blur(1.5px);
+ background-image: linear-gradient(to bottom, rgb(0 0 0 / 5%) 0, rgb(0 0 0 / 10%) 50%);
+ opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress, 0)));
+
+ &[data-state="open"] {
+ animation: backdropIn 600ms cubic-bezier(0.32, 0.72, 0, 1);
+ }
+
+ &[data-state="closed"] {
+ animation: backdropOut 350ms cubic-bezier(0.375, 0.015, 0.545, 0.455);
+ backdrop-filter: blur(0);
+ opacity: 0;
+ }
+
+ &[data-swiping] {
+ animation: none;
+ transition-duration: 0ms;
+ }
+}
+
+.positioner {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+}
+
+.popup {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 42rem;
+ margin: 0 auto;
+ outline: 0;
+ transform: translateY(var(--drawer-translate-y, 0px));
+ transition: transform 600ms cubic-bezier(0.45, 1.005, 0, 1.005);
+ will-change: transform;
+
+ &[data-state="open"] {
+ animation: slideUp 600ms cubic-bezier(0.45, 1.005, 0, 1.005);
+ }
+
+ &[data-state="closed"] {
+ animation: slideDown 350ms cubic-bezier(0.375, 0.015, 0.545, 0.455);
+ }
+
+ &[data-swiping] {
+ user-select: none;
+ transition-duration: 0ms;
+ }
+}
+
+.panel {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ padding: 1rem 1.5rem 1.5rem;
+ border-top-left-radius: 1rem;
+ border-top-right-radius: 1rem;
+ outline: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+ box-shadow:
+ 0 10px 64px -10px rgb(36 40 52 / 20%),
+ 0 0.25px 0 1px #e5e7eb;
+ color: #111827;
+}
+
+.panelHeader {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ margin-bottom: 0.75rem;
+}
+
+.headerSpacer {
+ width: 2.25rem;
+ height: 2.25rem;
+}
+
+.handle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ justify-self: center;
+ padding: 12px 0;
+ cursor: grab;
+ touch-action: none;
+}
+
+.handleIndicator {
+ width: 3rem;
+ height: 0.25rem;
+ border-radius: 9999px;
+ background-color: #d1d5db;
+}
+
+.closeButton {
+ width: 2.25rem;
+ height: 2.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ border: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+ color: #111827;
+ cursor: pointer;
+ justify-self: end;
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: #f3f4f6;
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: -1px;
+ }
+}
+
+.title {
+ margin: 0 0 0.25rem;
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+ letter-spacing: -0.0025em;
+ font-weight: 500;
+}
+
+.description {
+ margin: 0 0 1.25rem;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ color: #6b7280;
+}
+
+.navList {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: grid;
+ gap: 0.25rem;
+}
+
+.longList {
+ list-style: none;
+ padding: 0;
+ margin: 1.5rem 0 0;
+ display: grid;
+ gap: 0.25rem;
+}
+
+.navItem {
+ display: flex;
+}
+
+.navLink {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border-radius: 0.75rem;
+ color: inherit;
+ text-decoration: none;
+ background-color: #f3f4f6;
+
+ &:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: -1px;
+ }
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: #e5e7eb;
+ }
+ }
+}
+
+.scrollArea {
+ padding-bottom: 2rem;
+ max-height: 70vh;
+ overflow-y: auto;
+}
+
+@keyframes backdropIn {
+ from {
+ backdrop-filter: blur(0);
+ opacity: 0;
+ }
+}
+
+@keyframes backdropOut {
+ from {
+ backdrop-filter: blur(1.5px);
+ opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress, 0)));
+ }
+ to {
+ backdrop-filter: blur(0);
+ opacity: 0;
+ }
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(100dvh);
+ }
+ to {
+ transform: translateY(var(--drawer-translate-y, 0px));
+ }
+}
+
+@keyframes slideDown {
+ from {
+ transform: translateY(var(--drawer-translate-y, 0px));
+ }
+ to {
+ transform: translateY(calc(max(100dvh, 100%) + 2px));
+ }
+}
diff --git a/examples/shared/styles/drawer.module.css b/examples/shared/styles/drawer.module.css
index e14f3572df..43029d0286 100644
--- a/examples/shared/styles/drawer.module.css
+++ b/examples/shared/styles/drawer.module.css
@@ -63,6 +63,12 @@
right: auto;
top: auto;
bottom: auto;
+ box-shadow: 0 -4px 32px rgb(0 0 0 / 10%);
+
+ &[data-swiping] {
+ user-select: none;
+ -webkit-user-select: none;
+ }
&[data-state="open"] {
animation-name: slideInDown;
@@ -77,6 +83,7 @@
border-top-right-radius: 0;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
+ box-shadow: 0 4px 32px rgb(0 0 0 / 10%);
&[data-state="open"] {
animation-name: slideInUp;
@@ -97,7 +104,9 @@
}
&[data-swipe-direction="left"] {
+ border-top-right-radius: 16px;
border-bottom-right-radius: 16px;
+ box-shadow: 4px 0 32px rgb(0 0 0 / 10%);
&[data-state="open"] {
animation-name: slideInLeft;
@@ -109,7 +118,9 @@
}
&[data-swipe-direction="right"] {
+ border-top-left-radius: 16px;
border-bottom-left-radius: 16px;
+ box-shadow: -4px 0 32px rgb(0 0 0 / 10%);
&[data-state="open"] {
animation-name: slideInRight;
@@ -147,6 +158,17 @@
cursor: pointer;
}
+.swipeArea {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 48px;
+ z-index: 10;
+ background: rgba(0, 120, 255, 0.3);
+ border-top: 2px solid rgba(0, 120, 255, 0.6);
+}
+
.scrollable {
overflow-y: auto;
}
diff --git a/examples/solid-ts/src/routes/drawer/action-sheet.tsx b/examples/solid-ts/src/routes/drawer/action-sheet.tsx
new file mode 100644
index 0000000000..9506797dae
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/action-sheet.tsx
@@ -0,0 +1,60 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createUniqueId } from "solid-js"
+import { Presence } from "../../components/presence"
+import styles from "../../../../shared/styles/drawer-action-sheet.module.css"
+
+export default function Page() {
+ const service = useMachine(drawer.machine, () => ({
+ id: createUniqueId(),
+ }))
+
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+
+ return (
+
+
+ Manage Profile
+
+
+
+
+
+
+ )
+}
diff --git a/examples/solid-ts/src/routes/drawer/basic.tsx b/examples/solid-ts/src/routes/drawer/basic.tsx
index 487970c196..32977f5aa9 100644
--- a/examples/solid-ts/src/routes/drawer/basic.tsx
+++ b/examples/solid-ts/src/routes/drawer/basic.tsx
@@ -2,6 +2,7 @@ import * as drawer from "@zag-js/drawer"
import { normalizeProps, useMachine } from "@zag-js/solid"
import { createMemo, createUniqueId, For } from "solid-js"
import { drawerControls } from "@zag-js/shared"
+import { Presence } from "../../components/presence"
import { StateVisualizer } from "../../components/state-visualizer"
import { Toolbar } from "../../components/toolbar"
import { useControls } from "../../hooks/use-controls"
@@ -25,9 +26,9 @@ export default function Page() {
Open
-
+
-
+
@@ -38,12 +39,12 @@ export default function Page() {
-
+
-
+
>
)
diff --git a/examples/solid-ts/src/routes/drawer/controlled.tsx b/examples/solid-ts/src/routes/drawer/controlled.tsx
new file mode 100644
index 0000000000..dc6140b3eb
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/controlled.tsx
@@ -0,0 +1,109 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
+import { Presence } from "../../components/presence"
+import styles from "../../../../shared/styles/drawer.module.css"
+
+function AlwaysOpenDrawer() {
+ const id = createUniqueId()
+ const service = useMachine(drawer.machine, () => ({
+ id,
+ open: true,
+ }))
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+
+ return (
+
+
Always Open (no onOpenChange)
+
+ This drawer has open: true without onOpenChange. Swiping, escape, and outside click
+ should have no effect.
+
+
+
+
+
+ Always Open
+
+ Try swiping down, pressing Escape, or clicking outside. This drawer should never close.
+
+
+
+
+ )
+}
+
+function ControlledDrawer() {
+ const id = createUniqueId()
+ const [open, setOpen] = createSignal(false)
+ const service = useMachine(drawer.machine, () => ({
+ id,
+ open: open(),
+ onOpenChange({ open: next }) {
+ setOpen(next)
+ },
+ }))
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+
+ return (
+
+
Controlled (open + onOpenChange)
+
Standard controlled mode. Open state is managed by Solid.
+
+ Open Controlled
+
+
+
+
+
+ Controlled Drawer
+
+ This drawer is fully controlled. Swipe, escape, and outside click all work.
+
+
+ Open: {String(open())}
+
+ Close
+
+
+
+ )
+}
+
+export default function Page() {
+ const [scenario, setScenario] = createSignal<"always-open" | "controlled">("controlled")
+
+ return (
+
+
+ setScenario("always-open")}
+ style={{ "font-weight": scenario() === "always-open" ? 700 : 400 }}
+ >
+ Always Open
+
+ setScenario("controlled")}
+ style={{ "font-weight": scenario() === "controlled" ? 700 : 400 }}
+ >
+ Controlled
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/examples/solid-ts/src/routes/drawer/cross-axis-scroll.tsx b/examples/solid-ts/src/routes/drawer/cross-axis-scroll.tsx
new file mode 100644
index 0000000000..d3e2b0d92f
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/cross-axis-scroll.tsx
@@ -0,0 +1,79 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createUniqueId, For } from "solid-js"
+import { Presence } from "../../components/presence"
+import { StateVisualizer } from "../../components/state-visualizer"
+import { Toolbar } from "../../components/toolbar"
+import styles from "../../../../shared/styles/drawer.module.css"
+
+export default function Page() {
+ const service = useMachine(drawer.machine, () => ({
+ id: createUniqueId(),
+ }))
+
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+
+ return (
+ <>
+
+
+ Open Drawer
+
+
+
+
+
+ Cross-Axis Scroll
+
+ Try scrolling the image carousel horizontally. It should scroll without triggering the drawer drag.
+
+
+
+
+ {(_item, i) => (
+
+ {i() + 1}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/solid-ts/src/routes/drawer/default-active-snap-point.tsx b/examples/solid-ts/src/routes/drawer/default-active-snap-point.tsx
index 83446adc27..915b8bce1b 100644
--- a/examples/solid-ts/src/routes/drawer/default-active-snap-point.tsx
+++ b/examples/solid-ts/src/routes/drawer/default-active-snap-point.tsx
@@ -2,6 +2,7 @@ import * as drawer from "@zag-js/drawer"
import { normalizeProps, useMachine } from "@zag-js/solid"
import { createMemo, createUniqueId, For } from "solid-js"
import { drawerControls } from "@zag-js/shared"
+import { Presence } from "../../components/presence"
import { StateVisualizer } from "../../components/state-visualizer"
import { Toolbar } from "../../components/toolbar"
import { useControls } from "../../hooks/use-controls"
@@ -14,8 +15,8 @@ export default function Page() {
drawer.machine,
controls.mergeProps({
id: createUniqueId(),
- snapPoints: [0.25, "250px", 1],
- defaultSnapPoint: 0.25,
+ snapPoints: ["20rem", 1],
+ defaultSnapPoint: "20rem",
}),
)
@@ -27,9 +28,9 @@ export default function Page() {
Open
-
+
-
+
@@ -40,7 +41,7 @@ export default function Page() {
-
+
diff --git a/examples/solid-ts/src/routes/drawer/draggable-false.tsx b/examples/solid-ts/src/routes/drawer/draggable-false.tsx
new file mode 100644
index 0000000000..ad0024c63d
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/draggable-false.tsx
@@ -0,0 +1,51 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createUniqueId, For } from "solid-js"
+import { drawerControls } from "@zag-js/shared"
+import { Presence } from "../../components/presence"
+import { StateVisualizer } from "../../components/state-visualizer"
+import { Toolbar } from "../../components/toolbar"
+import { useControls } from "../../hooks/use-controls"
+import styles from "../../../../shared/styles/drawer.module.css"
+
+export default function Page() {
+ const controls = useControls(drawerControls)
+
+ const service = useMachine(
+ drawer.machine,
+ controls.mergeProps({
+ id: createUniqueId(),
+ }),
+ )
+
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+
+ return (
+ <>
+
+
+ Open
+
+
+
+
+
+ Drawer
+
+ No drag area
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/solid-ts/src/routes/drawer/indent-background.tsx b/examples/solid-ts/src/routes/drawer/indent-background.tsx
new file mode 100644
index 0000000000..06894f747e
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/indent-background.tsx
@@ -0,0 +1,57 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createSignal, createUniqueId, For, onMount } from "solid-js"
+import { Presence } from "../../components/presence"
+import styles from "../../../../shared/styles/drawer-indent.module.css"
+
+const stack = drawer.createStack()
+
+export default function Page() {
+ const id = createUniqueId()
+ const [snapshot, setSnapshot] = createSignal(stack.getSnapshot())
+
+ onMount(() => stack.subscribe(() => setSnapshot(stack.getSnapshot())))
+
+ const service = useMachine(drawer.machine, () => ({
+ id,
+ stack,
+ }))
+
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+ const stackApi = createMemo(() => drawer.connectStack(snapshot(), normalizeProps))
+
+ return (
+
+
+
+
+
Drawer Indent Background
+
+ Open and drag the drawer. The background and app shell use stack snapshot props so styles stay coordinated.
+
+
+ Open Drawer
+
+
+
+
+
+
+ )
+}
diff --git a/examples/solid-ts/src/routes/drawer/mobile-nav.tsx b/examples/solid-ts/src/routes/drawer/mobile-nav.tsx
new file mode 100644
index 0000000000..cdcc82bf54
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/mobile-nav.tsx
@@ -0,0 +1,98 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createUniqueId, For } from "solid-js"
+import { Presence } from "../../components/presence"
+import styles from "../../../../shared/styles/drawer-mobile-nav.module.css"
+
+const NAV_ITEMS = [
+ { href: "#", label: "Overview" },
+ { href: "#", label: "Components" },
+ { href: "#", label: "Utilities" },
+ { href: "#", label: "Releases" },
+]
+
+const LONG_LIST = Array.from({ length: 50 }, (_, i) => ({
+ href: "#",
+ label: `Item ${i + 1}`,
+}))
+
+export default function Page() {
+ const service = useMachine(drawer.machine, () => ({
+ id: createUniqueId(),
+ }))
+
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/examples/solid-ts/src/routes/drawer/nested.tsx b/examples/solid-ts/src/routes/drawer/nested.tsx
new file mode 100644
index 0000000000..bed5adee28
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/nested.tsx
@@ -0,0 +1,162 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createSignal, createUniqueId } from "solid-js"
+import { Portal } from "solid-js/web"
+import { Presence } from "../../components/presence"
+import styles from "../../../../shared/styles/drawer-nested.module.css"
+
+export default function Page() {
+ const baseId = createUniqueId()
+ const [firstOpen, setFirstOpen] = createSignal(false)
+ const [secondOpen, setSecondOpen] = createSignal(false)
+ const [thirdOpen, setThirdOpen] = createSignal(false)
+
+ const firstService = useMachine(drawer.machine, () => ({
+ id: `${baseId}-root`,
+ open: firstOpen(),
+ onOpenChange({ open }) {
+ setFirstOpen(open)
+ if (!open) {
+ setSecondOpen(false)
+ setThirdOpen(false)
+ }
+ },
+ }))
+
+ const secondService = useMachine(drawer.machine, () => ({
+ id: `${baseId}-second`,
+ open: secondOpen(),
+ onOpenChange({ open }) {
+ setSecondOpen(open)
+ if (!open) setThirdOpen(false)
+ },
+ }))
+
+ const thirdService = useMachine(drawer.machine, () => ({
+ id: `${baseId}-third`,
+ open: thirdOpen(),
+ onOpenChange({ open }) {
+ setThirdOpen(open)
+ },
+ }))
+
+ const firstApi = createMemo(() => drawer.connect(firstService, normalizeProps))
+ const secondApi = createMemo(() => drawer.connect(secondService, normalizeProps))
+ const thirdApi = createMemo(() => drawer.connect(thirdService, normalizeProps))
+
+ return (
+
+
+ Open drawer stack
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/examples/solid-ts/src/routes/drawer/non-modal.tsx b/examples/solid-ts/src/routes/drawer/non-modal.tsx
new file mode 100644
index 0000000000..63f9d0e144
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/non-modal.tsx
@@ -0,0 +1,57 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createUniqueId } from "solid-js"
+import { Presence } from "../../components/presence"
+import styles from "../../../../shared/styles/drawer.module.css"
+
+export default function Page() {
+ const service = useMachine(drawer.machine, () => ({
+ id: createUniqueId(),
+ modal: false,
+ closeOnInteractOutside: false,
+ swipeDirection: "right" as const,
+ }))
+
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+
+ return (
+
+
+ Open Drawer
+
+
+
+
+ This area stays interactive while the drawer is open. Try typing below:
+
+
+
+
+
+
+ Non-modal Drawer
+
+ No backdrop, no focus trap, no scroll lock. The page behind stays fully interactive. Close with the button,
+ drag to dismiss, or Escape.
+
+
+ Close
+
+
+
+
+ )
+}
diff --git a/examples/solid-ts/src/routes/drawer/range-input.tsx b/examples/solid-ts/src/routes/drawer/range-input.tsx
new file mode 100644
index 0000000000..e0e1312377
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/range-input.tsx
@@ -0,0 +1,82 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createSignal, createUniqueId, For } from "solid-js"
+import { drawerControls } from "@zag-js/shared"
+import { Presence } from "../../components/presence"
+import { StateVisualizer } from "../../components/state-visualizer"
+import { Toolbar } from "../../components/toolbar"
+import { useControls } from "../../hooks/use-controls"
+import styles from "../../../../shared/styles/drawer.module.css"
+
+export default function Page() {
+ const controls = useControls(drawerControls)
+ const [volume, setVolume] = createSignal(50)
+
+ const service = useMachine(
+ drawer.machine,
+ controls.mergeProps({
+ id: createUniqueId(),
+ }),
+ )
+
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+
+ return (
+ <>
+
+
+ Open
+
+
+
+
+
+ Drawer + native range
+
+ Drag the slider horizontally. The sheet should not move or steal the gesture while adjusting the range.
+
+
+
+ Volume (native <input type="range">)
+
+ setVolume(Number(e.currentTarget.value))}
+ data-testid="drawer-native-range"
+ style={{ width: "100%", "touch-action": "auto" }}
+ />
+
+ {volume()}
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/solid-ts/src/routes/drawer/snap-points.tsx b/examples/solid-ts/src/routes/drawer/snap-points.tsx
index 8f661f3ffb..24f023aa89 100644
--- a/examples/solid-ts/src/routes/drawer/snap-points.tsx
+++ b/examples/solid-ts/src/routes/drawer/snap-points.tsx
@@ -2,6 +2,7 @@ import * as drawer from "@zag-js/drawer"
import { normalizeProps, useMachine } from "@zag-js/solid"
import { createMemo, createUniqueId, For } from "solid-js"
import { drawerControls } from "@zag-js/shared"
+import { Presence } from "../../components/presence"
import { StateVisualizer } from "../../components/state-visualizer"
import { Toolbar } from "../../components/toolbar"
import { useControls } from "../../hooks/use-controls"
@@ -14,7 +15,7 @@ export default function Page() {
drawer.machine,
controls.mergeProps({
id: createUniqueId(),
- snapPoints: [0.25, "250px", 1],
+ snapPoints: ["20rem", 1],
}),
)
@@ -26,9 +27,9 @@ export default function Page() {
Open
-
+
-
+
@@ -39,7 +40,7 @@ export default function Page() {
-
+
diff --git a/examples/solid-ts/src/routes/drawer/swipe-area.tsx b/examples/solid-ts/src/routes/drawer/swipe-area.tsx
new file mode 100644
index 0000000000..de4d43a903
--- /dev/null
+++ b/examples/solid-ts/src/routes/drawer/swipe-area.tsx
@@ -0,0 +1,41 @@
+import * as drawer from "@zag-js/drawer"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { createMemo, createUniqueId, For } from "solid-js"
+import { Presence } from "../../components/presence"
+import { StateVisualizer } from "../../components/state-visualizer"
+import { Toolbar } from "../../components/toolbar"
+import styles from "../../../../shared/styles/drawer.module.css"
+
+export default function Page() {
+ const service = useMachine(drawer.machine, () => ({
+ id: createUniqueId(),
+ }))
+
+ const api = createMemo(() => drawer.connect(service, normalizeProps))
+
+ return (
+ <>
+
+
+
+
+
+
+ Drawer
+ Swipe up from the bottom edge to open this drawer.
+ Close
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/svelte-ts/src/routes/drawer/action-sheet/+page.svelte b/examples/svelte-ts/src/routes/drawer/action-sheet/+page.svelte
new file mode 100644
index 0000000000..ae4de39b5d
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/action-sheet/+page.svelte
@@ -0,0 +1,45 @@
+
+
+
+ Manage Profile
+
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/drawer/controlled/+page.svelte b/examples/svelte-ts/src/routes/drawer/controlled/+page.svelte
new file mode 100644
index 0000000000..7d4b9ecc3e
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/controlled/+page.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+ (scenario = "always-open")}
+ style:font-weight={scenario === "always-open" ? 700 : 400}
+ >
+ Always Open
+
+ (scenario = "controlled")}
+ style:font-weight={scenario === "controlled" ? 700 : 400}
+ >
+ Controlled
+
+
+
+ {#if scenario === "always-open"}
+
+ {:else}
+
+ {/if}
+
diff --git a/examples/svelte-ts/src/routes/drawer/controlled/DrawerControlledAlwaysOpen.svelte b/examples/svelte-ts/src/routes/drawer/controlled/DrawerControlledAlwaysOpen.svelte
new file mode 100644
index 0000000000..a0501ce8c2
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/controlled/DrawerControlledAlwaysOpen.svelte
@@ -0,0 +1,41 @@
+
+
+
+
Always Open (no onOpenChange)
+
+ This drawer has open: true without onOpenChange. Swiping, escape, and outside click should
+ have no effect.
+
+
+
+
+
+ Always Open
+
+ Try swiping down, pressing Escape, or clicking outside. This drawer should never close.
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/drawer/controlled/DrawerControlledManaged.svelte b/examples/svelte-ts/src/routes/drawer/controlled/DrawerControlledManaged.svelte
new file mode 100644
index 0000000000..8502a249d4
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/controlled/DrawerControlledManaged.svelte
@@ -0,0 +1,50 @@
+
+
+
+
Controlled (open + onOpenChange)
+
Standard controlled mode. Open state is managed by Svelte.
+
Open Controlled
+
+
+
+
+ Controlled Drawer
+
+ This drawer is fully controlled. Swipe, escape, and outside click all work.
+
+
+ Open: {String(open)}
+
+ Close
+
+
+
diff --git a/examples/svelte-ts/src/routes/drawer/cross-axis-scroll/+page.svelte b/examples/svelte-ts/src/routes/drawer/cross-axis-scroll/+page.svelte
new file mode 100644
index 0000000000..1bf6049c1f
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/cross-axis-scroll/+page.svelte
@@ -0,0 +1,53 @@
+
+
+
+ Open Drawer
+
+
+
+
+ Cross-Axis Scroll
+
+ Try scrolling the image carousel horizontally. It should scroll without triggering the drawer drag.
+
+
+
+ {#each Array.from({ length: 10 }) as _, i}
+
+ {i + 1}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/drawer/default-active-snap-point/+page.svelte b/examples/svelte-ts/src/routes/drawer/default-active-snap-point/+page.svelte
index b7d30c2575..844c7ca9b3 100644
--- a/examples/svelte-ts/src/routes/drawer/default-active-snap-point/+page.svelte
+++ b/examples/svelte-ts/src/routes/drawer/default-active-snap-point/+page.svelte
@@ -15,8 +15,8 @@
drawer.machine,
controls.mergeProps({
id,
- snapPoints: [0.25, "250px", 1],
- defaultSnapPoint: 0.25,
+ snapPoints: ["20rem", 1],
+ defaultSnapPoint: "20rem",
}),
)
diff --git a/examples/svelte-ts/src/routes/drawer/indent-background/+page.svelte b/examples/svelte-ts/src/routes/drawer/indent-background/+page.svelte
new file mode 100644
index 0000000000..c0105e9477
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/indent-background/+page.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
Drawer Indent Background
+
+ Open and drag the drawer. The background and app shell use stack snapshot props so styles stay coordinated.
+
+
Open Drawer
+
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/drawer/mobile-nav/+page.svelte b/examples/svelte-ts/src/routes/drawer/mobile-nav/+page.svelte
new file mode 100644
index 0000000000..0689ab69e8
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/mobile-nav/+page.svelte
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/drawer/nested/+page.svelte b/examples/svelte-ts/src/routes/drawer/nested/+page.svelte
new file mode 100644
index 0000000000..efb85d7e2f
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/nested/+page.svelte
@@ -0,0 +1,137 @@
+
+
+
+ Open drawer stack
+
+
+
+
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/drawer/non-modal/+page.svelte b/examples/svelte-ts/src/routes/drawer/non-modal/+page.svelte
new file mode 100644
index 0000000000..f117072318
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/non-modal/+page.svelte
@@ -0,0 +1,45 @@
+
+
+
+ Open Drawer
+
+
+
+ This area stays interactive while the drawer is open. Try typing below:
+
+
+
+
+
+
+ Non-modal Drawer
+
+ No backdrop, no focus trap, no scroll lock. The page behind stays fully interactive. Close with the button, drag
+ to dismiss, or Escape.
+
+
+ Close
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/drawer/range-input/+page.svelte b/examples/svelte-ts/src/routes/drawer/range-input/+page.svelte
new file mode 100644
index 0000000000..045eb2e4af
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/range-input/+page.svelte
@@ -0,0 +1,67 @@
+
+
+
+ Open
+
+
+
+
+ Drawer + native range
+
+ Drag the slider horizontally. The sheet should not move or steal the gesture while adjusting the range.
+
+
+
+ Volume (native <input type="range">)
+
+
+
+ {volume}
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/drawer/snap-points/+page.svelte b/examples/svelte-ts/src/routes/drawer/snap-points/+page.svelte
index a1ea36e253..b20a24dd52 100644
--- a/examples/svelte-ts/src/routes/drawer/snap-points/+page.svelte
+++ b/examples/svelte-ts/src/routes/drawer/snap-points/+page.svelte
@@ -15,7 +15,7 @@
drawer.machine,
controls.mergeProps({
id,
- snapPoints: [0.25, "250px", 1],
+ snapPoints: ["20rem", 1],
}),
)
diff --git a/examples/svelte-ts/src/routes/drawer/swipe-area/+page.svelte b/examples/svelte-ts/src/routes/drawer/swipe-area/+page.svelte
new file mode 100644
index 0000000000..f329018f0c
--- /dev/null
+++ b/examples/svelte-ts/src/routes/drawer/swipe-area/+page.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+ Drawer
+ Swipe up from the bottom edge to open this drawer.
+ Close
+
+
+
+
+
+
+
+
diff --git a/packages/machines/dialog/src/dialog.connect.ts b/packages/machines/dialog/src/dialog.connect.ts
index 8067224348..2f3e8209a6 100644
--- a/packages/machines/dialog/src/dialog.connect.ts
+++ b/packages/machines/dialog/src/dialog.connect.ts
@@ -53,7 +53,7 @@ export function connect(
dir: prop("dir"),
id: dom.getPositionerId(scope),
style: {
- pointerEvents: open ? undefined : "none",
+ pointerEvents: !open || !prop("modal") ? "none" : undefined,
},
})
},
@@ -68,10 +68,13 @@ export function connect(
id: dom.getContentId(scope),
tabIndex: -1,
"data-state": open ? "open" : "closed",
- "aria-modal": true,
+ "aria-modal": prop("modal"),
"aria-label": ariaLabel || undefined,
"aria-labelledby": ariaLabel || !rendered.title ? undefined : dom.getTitleId(scope),
"aria-describedby": rendered.description ? dom.getDescriptionId(scope) : undefined,
+ style: {
+ pointerEvents: prop("modal") ? undefined : "auto",
+ },
})
},
diff --git a/packages/machines/dialog/src/dialog.machine.ts b/packages/machines/dialog/src/dialog.machine.ts
index 2938dfe21f..4bf16aac8d 100644
--- a/packages/machines/dialog/src/dialog.machine.ts
+++ b/packages/machines/dialog/src/dialog.machine.ts
@@ -1,7 +1,7 @@
import { ariaHidden } from "@zag-js/aria-hidden"
import { createMachine } from "@zag-js/core"
import { trackDismissableElement } from "@zag-js/dismissable"
-import { getComputedStyle, raf } from "@zag-js/dom-query"
+import { getComputedStyle, getInitialFocus, raf } from "@zag-js/dom-query"
import { trapFocus } from "@zag-js/focus-trap"
import { preventBodyScroll } from "@zag-js/remove-scroll"
import * as dom from "./dialog.dom"
@@ -46,7 +46,7 @@ export const machine = createMachine({
states: {
open: {
- entry: ["checkRenderedElements", "syncZIndex"],
+ entry: ["checkRenderedElements", "syncZIndex", "setInitialFocus"],
effects: ["trackDismissableElement", "trapFocus", "preventScroll", "hideContentBelow"],
on: {
"CONTROLLED.CLOSE": {
@@ -164,6 +164,17 @@ export const machine = createMachine({
},
actions: {
+ setInitialFocus({ prop, scope }) {
+ if (prop("trapFocus")) return
+ raf(() => {
+ const element = getInitialFocus({
+ root: dom.getContentEl(scope),
+ getInitialEl: prop("initialFocusEl"),
+ })
+ element?.focus({ preventScroll: true })
+ })
+ },
+
checkRenderedElements({ context, scope }) {
raf(() => {
context.set("rendered", {
diff --git a/packages/machines/dialog/src/dialog.types.ts b/packages/machines/dialog/src/dialog.types.ts
index dbcc2a2b0b..20e149b779 100644
--- a/packages/machines/dialog/src/dialog.types.ts
+++ b/packages/machines/dialog/src/dialog.types.ts
@@ -110,7 +110,13 @@ export interface DialogSchema {
}
guard: "isOpenControlled"
effect: "trackDismissableElement" | "preventScroll" | "trapFocus" | "hideContentBelow"
- action: "checkRenderedElements" | "syncZIndex" | "invokeOnClose" | "invokeOnOpen" | "toggleVisibility"
+ action:
+ | "checkRenderedElements"
+ | "syncZIndex"
+ | "setInitialFocus"
+ | "invokeOnClose"
+ | "invokeOnOpen"
+ | "toggleVisibility"
event: {
type: "CONTROLLED.OPEN" | "CONTROLLED.CLOSE" | "OPEN" | "CLOSE" | "TOGGLE"
}
diff --git a/packages/machines/drawer/src/README.md b/packages/machines/drawer/src/README.md
new file mode 100644
index 0000000000..37c75b8286
--- /dev/null
+++ b/packages/machines/drawer/src/README.md
@@ -0,0 +1,36 @@
+# Drawer machine — how to read this package
+
+## Start here
+
+1. **`drawer.types.ts`** — `SwipeDirection`, snap point shapes, service/API types.
+2. **`drawer.machine.ts`** — States (`open`, `closing`, `closed`, `swiping-open`, `swipe-area-dragging`), transitions,
+ guards/actions, and **effects** (DOM subscriptions).
+3. **`drawer.connect.ts`** — Props passed to the DOM (CSS vars, `data-*`, pointer handlers). Presentation math for
+ translate/movement lives next to `getContentProps` / swipe area.
+
+## Gesture & input (where bugs often land)
+
+| Concern | Where to look |
+| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
+| Pointer → machine events (`POINTER_MOVE`, etc.) | `drawer.machine.ts` → effect **`trackPointerMove`** (document-level pointer + **touch**; touch `preventDefault` vs nested scroll). |
+| Swipe-open from closed (swipe area) | Same file → **`trackSwipeOpenPointerMove`**; guard **`hasOpeningSwipeIntent`** uses **`utils/swipe.ts`**. |
+| Drag thresholds, velocity, snap resolution | **`utils/drag-manager.ts`**. |
+| Deferred mouse/pen “don’t arm drag on first pixel” | **`utils/deferred-pointer.ts`**; wired from **`drawer.connect.ts`** (`deferPointerDown` / `cancelDeferPointerDown`). |
+| Scroll competition / nested scroller | **`utils/get-scroll-info.ts`** (`findClosestScrollableAncestorOnSwipeAxis`, `shouldPreventTouchDefaultForDrawerPull`, `getScrollInfo`). |
+| Ignore drag on inputs, selection, `data-no-drag` | **`utils/is-drag-exempt-target.ts`** (`isDragExemptElement`, `shouldIgnorePointerDownForDrag`, `isTextSelectionInDrawer`). |
+
+## DOM & layout
+
+- **`drawer.dom.ts`** — Part IDs, element getters, **`isPointerWithinContentOrSwipeArea`**.
+- **`drawer.anatomy.ts`** — Part names/attributes for adapters.
+
+## Stack / nested drawers
+
+- **`drawer.stack.ts`**, **`drawer.registry.ts`** — Height and swipe progress for stacked sheets.
+
+## Public entry
+
+- **`index.ts`** re-exports machine, connect, props, types, stack helpers.
+
+When fixing a regression, confirm whether it’s **event routing** (machine effects), **headless props** (connect), or
+**pure gesture math** (utils) before editing.
diff --git a/packages/machines/drawer/src/drawer.anatomy.ts b/packages/machines/drawer/src/drawer.anatomy.ts
index cafe0a229b..5587013856 100644
--- a/packages/machines/drawer/src/drawer.anatomy.ts
+++ b/packages/machines/drawer/src/drawer.anatomy.ts
@@ -4,11 +4,13 @@ export const anatomy = createAnatomy("drawer").parts(
"positioner",
"content",
"title",
+ "description",
"trigger",
"backdrop",
"grabber",
"grabberIndicator",
"closeTrigger",
+ "swipeArea",
)
export const parts = anatomy.build()
diff --git a/packages/machines/drawer/src/drawer.connect.ts b/packages/machines/drawer/src/drawer.connect.ts
index 4f9fa463ce..de892d7380 100644
--- a/packages/machines/drawer/src/drawer.connect.ts
+++ b/packages/machines/drawer/src/drawer.connect.ts
@@ -1,42 +1,84 @@
-import { getEventPoint, getEventTarget, isLeftClick } from "@zag-js/dom-query"
+import { getEventPoint, isLeftClick } from "@zag-js/dom-query"
import type { JSX, NormalizeProps, PropTypes } from "@zag-js/types"
import { toPx } from "@zag-js/utils"
import { parts } from "./drawer.anatomy"
import * as dom from "./drawer.dom"
import type { DrawerApi, DrawerService } from "./drawer.types"
-
-const isVerticalDirection = (direction: DrawerApi["swipeDirection"]) => direction === "down" || direction === "up"
-
-const isNegativeDirection = (direction: DrawerApi["swipeDirection"]) => direction === "up" || direction === "left"
+import { cancelDeferPointerDown, deferPointerDown } from "./utils/deferred-pointer"
+import { isTextSelectionInDrawer, shouldIgnorePointerDownForDrag } from "./utils/is-drag-exempt-target"
+import { isNegativeSwipeDirection, isVerticalSwipeDirection, oppositeSwipeDirection } from "./utils/swipe"
export function connect(service: DrawerService, normalize: NormalizeProps): DrawerApi {
const { state, send, context, scope, prop } = service
const open = state.hasTag("open")
const closed = state.matches("closed")
+ const swipingOpen = state.matches("swiping-open")
+
const dragOffset = context.get("dragOffset")
const dragging = dragOffset !== null
const snapPoint = context.get("snapPoint")
- const resolvedActiveSnapPoint = context.get("resolvedActiveSnapPoint")
const swipeDirection = prop("swipeDirection")
const contentSize = context.get("contentSize")
const swipeStrength = context.get("swipeStrength")
+
+ const resolvedActiveSnapPoint = context.get("resolvedActiveSnapPoint")
const snapPointOffset = resolvedActiveSnapPoint?.offset ?? 0
- const currentOffset = dragOffset ?? snapPointOffset
- const swipeMovement = dragging ? currentOffset - snapPointOffset : 0
- const signedCurrentOffset = isNegativeDirection(swipeDirection) ? -currentOffset : currentOffset
- const signedSnapPointOffset = isNegativeDirection(swipeDirection) ? -snapPointOffset : snapPointOffset
- const signedMovement = isNegativeDirection(swipeDirection) ? -swipeMovement : swipeMovement
+
+ const swipeOpenFallbackOffset = swipingOpen && dragOffset === null ? (contentSize ?? 9999) : 0
+ const currentOffset = dragOffset ?? (snapPointOffset || swipeOpenFallbackOffset)
+ const signedSnapPointOffset = isNegativeSwipeDirection(swipeDirection) ? -snapPointOffset : snapPointOffset
+
+ const isActivelySwiping = dragging || swipingOpen
+ const swipeMovement = dragging || swipingOpen ? currentOffset - snapPointOffset : 0
+ const signedMovement = isNegativeSwipeDirection(swipeDirection) ? -swipeMovement : swipeMovement
const swipeProgress =
- dragging && contentSize && contentSize > 0 ? Math.max(0, Math.min(1, Math.abs(signedMovement) / contentSize)) : 0
- const translateX = isVerticalDirection(swipeDirection) ? 0 : signedCurrentOffset
- const translateY = isVerticalDirection(swipeDirection) ? signedCurrentOffset : 0
-
- function onPointerDown(event: JSX.PointerEvent) {
- if (!isLeftClick(event)) return
- const target = getEventTarget(event)
- if (target?.hasAttribute("data-no-drag") || target?.closest("[data-no-drag]")) return
+ isActivelySwiping && contentSize && contentSize > 0
+ ? Math.max(0, Math.min(1, Math.abs(signedMovement) / contentSize))
+ : swipingOpen
+ ? 1 // fully closed (transparent backdrop) until contentSize is measured
+ : 0
+
+ const signedCurrentOffset = isNegativeSwipeDirection(swipeDirection) ? -currentOffset : currentOffset
+ const translateX = isVerticalSwipeDirection(swipeDirection) ? 0 : signedCurrentOffset
+ const translateY = isVerticalSwipeDirection(swipeDirection) ? signedCurrentOffset : 0
+
+ /**
+ * Sheet body: mouse/pen defer POINTER_DOWN until movement reads as a sheet-axis drag so
+ * click–drag to select text does not arm the drawer on the first pixel of movement.
+ */
+ function onContentPointerDown(event: JSX.PointerEvent) {
+ if (shouldIgnorePointerDownForDrag(event)) return
+ const content = dom.getContentEl(scope)
+ if (isTextSelectionInDrawer(scope.getDoc(), content)) return
+ if (state.matches("closing")) return
+
+ const point = getEventPoint(event)
+ const defer = event.pointerType === "mouse" || event.pointerType === "pen"
+ if (defer) {
+ deferPointerDown({
+ scope,
+ onCommit(point) {
+ send({ type: "POINTER_DOWN", point })
+ },
+ canCommitPointerDown() {
+ return state.hasTag("open") && !state.matches("closing")
+ },
+ swipeDirection,
+ pointerId: event.pointerId,
+ startPoint: point,
+ })
+ return
+ }
+
+ send({ type: "POINTER_DOWN", point })
+ }
+
+ /** Grabber: immediate POINTER_DOWN; cancel any deferred content gesture; ignore text-selection gate. */
+ function onGrabberPointerDown(event: JSX.PointerEvent) {
+ if (shouldIgnorePointerDownForDrag(event)) return
+ cancelDeferPointerDown(scope)
if (state.matches("closing")) return
send({ type: "POINTER_DOWN", point: getEventPoint(event) })
}
@@ -60,17 +102,13 @@ export function connect(service: DrawerService, normalize:
},
getOpenPercentage() {
- if (!open) return 0
-
- if (!contentSize) return 0
-
+ if (!open || !contentSize) return 0
return Math.max(0, Math.min(1, 1 - currentOffset / contentSize))
},
getSnapPointIndex() {
- const snapPoints = prop("snapPoints")
if (snapPoint === null) return -1
- return snapPoints.indexOf(snapPoint)
+ return prop("snapPoints").indexOf(snapPoint)
},
getContentSize() {
@@ -85,12 +123,16 @@ export function connect(service: DrawerService, normalize:
hidden: closed,
"data-state": open ? "open" : "closed",
"data-swipe-direction": swipeDirection,
+ style: {
+ pointerEvents: prop("modal") ? undefined : "none",
+ },
})
},
getContentProps(props = { draggable: true }) {
- const movementX = isVerticalDirection(swipeDirection) ? 0 : signedMovement
- const movementY = isVerticalDirection(swipeDirection) ? signedMovement : 0
+ const movementX = isVerticalSwipeDirection(swipeDirection) ? 0 : signedMovement
+ const movementY = isVerticalSwipeDirection(swipeDirection) ? signedMovement : 0
+ const rendered = context.get("rendered")
return normalize.element({
...parts.content.attrs,
@@ -99,21 +141,28 @@ export function connect(service: DrawerService, normalize:
tabIndex: -1,
role: prop("role"),
"aria-modal": prop("modal"),
- "aria-labelledby": dom.getTitleId(scope),
+ "aria-labelledby": rendered.title ? dom.getTitleId(scope) : undefined,
+ "aria-describedby": rendered.description ? dom.getDescriptionId(scope) : undefined,
hidden: !open,
"data-state": open ? "open" : "closed",
"data-expanded": resolvedActiveSnapPoint?.offset === 0 ? "" : undefined,
"data-swipe-direction": swipeDirection,
- "data-swiping": dragging ? "" : undefined,
+ "data-swiping": dragging || swipingOpen ? "" : undefined,
"data-dragging": dragging ? "" : undefined,
style: {
+ pointerEvents: prop("modal") ? undefined : "auto",
+ visibility: swipingOpen && dragOffset === null ? "hidden" : undefined,
transform: "translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0)",
- transitionDuration: dragging ? "0s" : undefined,
+ transitionDuration: dragging || swipingOpen ? "0s" : undefined,
"--drawer-translate": toPx(translateY),
"--drawer-translate-x": toPx(translateX),
"--drawer-translate-y": toPx(translateY),
- "--drawer-snap-point-offset-x": isVerticalDirection(swipeDirection) ? "0px" : toPx(signedSnapPointOffset),
- "--drawer-snap-point-offset-y": isVerticalDirection(swipeDirection) ? toPx(signedSnapPointOffset) : "0px",
+ "--drawer-snap-point-offset-x": isVerticalSwipeDirection(swipeDirection)
+ ? "0px"
+ : toPx(signedSnapPointOffset),
+ "--drawer-snap-point-offset-y": isVerticalSwipeDirection(swipeDirection)
+ ? toPx(signedSnapPointOffset)
+ : "0px",
"--drawer-swipe-movement-x": toPx(movementX),
"--drawer-swipe-movement-y": toPx(movementY),
"--drawer-swipe-strength": `${swipeStrength}`,
@@ -121,7 +170,7 @@ export function connect(service: DrawerService, normalize:
},
onPointerDown(event) {
if (!props.draggable) return
- onPointerDown(event)
+ onContentPointerDown(event)
},
})
},
@@ -134,6 +183,14 @@ export function connect(service: DrawerService, normalize:
})
},
+ getDescriptionProps() {
+ return normalize.element({
+ ...parts.description.attrs,
+ id: dom.getDescriptionId(scope),
+ dir: prop("dir"),
+ })
+ },
+
getTriggerProps() {
return normalize.button({
...parts.trigger.attrs,
@@ -149,9 +206,9 @@ export function connect(service: DrawerService, normalize:
return normalize.element({
...parts.backdrop.attrs,
id: dom.getBackdropId(scope),
- hidden: !open,
+ hidden: !open || (swipingOpen && dragOffset === null),
"data-state": open ? "open" : "closed",
- "data-swiping": dragging ? "" : undefined,
+ "data-swiping": dragging || swipingOpen ? "" : undefined,
style: {
willChange: "opacity",
"--drawer-swipe-progress": `${swipeProgress}`,
@@ -165,7 +222,7 @@ export function connect(service: DrawerService, normalize:
...parts.grabber.attrs,
id: dom.getGrabberId(scope),
onPointerDown(event) {
- onPointerDown(event)
+ onGrabberPointerDown(event)
},
style: {
touchAction: "none",
@@ -189,5 +246,40 @@ export function connect(service: DrawerService, normalize:
},
})
},
+
+ getSwipeAreaProps(props = {}) {
+ const disabled = props.disabled ?? false
+ const openDirection = props.swipeDirection ?? oppositeSwipeDirection[swipeDirection]
+
+ return normalize.element({
+ ...parts.swipeArea.attrs,
+ id: dom.getSwipeAreaId(scope),
+ role: "presentation",
+ "aria-hidden": true,
+ "data-state": open ? "open" : "closed",
+ "data-swiping": swipingOpen ? "" : undefined,
+ "data-swipe-direction": openDirection,
+ "data-disabled": disabled ? "" : undefined,
+ style: {
+ touchAction: isVerticalSwipeDirection(openDirection) ? "pan-x" : "pan-y",
+ pointerEvents: disabled || (open && !swipingOpen) ? "none" : undefined,
+ },
+ onPointerDown(event) {
+ if (disabled) return
+ if (!isLeftClick(event)) return
+ if (event.pointerType === "touch") return
+ if (open && !swipingOpen) return
+ send({ type: "SWIPE_AREA.START", point: getEventPoint(event) })
+ if (event.cancelable) event.preventDefault()
+ },
+ onTouchStart(event) {
+ if (disabled) return
+ if (open && !swipingOpen) return
+ const touch = event.touches[0]
+ if (!touch) return
+ send({ type: "SWIPE_AREA.START", point: { x: touch.clientX, y: touch.clientY } })
+ },
+ })
+ },
}
}
diff --git a/packages/machines/drawer/src/drawer.dom.ts b/packages/machines/drawer/src/drawer.dom.ts
index 952c3055c8..b0ce78b911 100644
--- a/packages/machines/drawer/src/drawer.dom.ts
+++ b/packages/machines/drawer/src/drawer.dom.ts
@@ -1,24 +1,39 @@
import type { Scope } from "@zag-js/core"
-import { queryAll } from "@zag-js/dom-query"
+import { isHTMLElement, queryAll } from "@zag-js/dom-query"
export const getContentId = (ctx: Scope) => ctx.ids?.content ?? `drawer:${ctx.id}:content`
export const getPositionerId = (ctx: Scope) => ctx.ids?.positioner ?? `drawer:${ctx.id}:positioner`
export const getTitleId = (ctx: Scope) => ctx.ids?.title ?? `drawer:${ctx.id}:title`
+export const getDescriptionId = (ctx: Scope) => ctx.ids?.description ?? `drawer:${ctx.id}:description`
export const getTriggerId = (ctx: Scope) => ctx.ids?.trigger ?? `drawer:${ctx.id}:trigger`
export const getBackdropId = (ctx: Scope) => ctx.ids?.backdrop ?? `drawer:${ctx.id}:backdrop`
export const getHeaderId = (ctx: Scope) => ctx.ids?.header ?? `drawer:${ctx.id}:header`
export const getGrabberId = (ctx: Scope) => ctx.ids?.grabber ?? `drawer:${ctx.id}:grabber`
export const getGrabberIndicatorId = (ctx: Scope) => ctx.ids?.grabberIndicator ?? `drawer:${ctx.id}:grabber-indicator`
export const getCloseTriggerId = (ctx: Scope) => ctx.ids?.closeTrigger ?? `drawer:${ctx.id}:close-trigger`
+export const getSwipeAreaId = (ctx: Scope) => ctx.ids?.swipeArea ?? `drawer:${ctx.id}:swipe-area`
export const getContentEl = (ctx: Scope) => ctx.getById(getContentId(ctx))
export const getPositionerEl = (ctx: Scope) => ctx.getById(getPositionerId(ctx))
+export const getTitleEl = (ctx: Scope) => ctx.getById(getTitleId(ctx))
+export const getDescriptionEl = (ctx: Scope) => ctx.getById(getDescriptionId(ctx))
export const getTriggerEl = (ctx: Scope) => ctx.getById(getTriggerId(ctx))
export const getBackdropEl = (ctx: Scope) => ctx.getById(getBackdropId(ctx))
export const getHeaderEl = (ctx: Scope) => ctx.getById(getHeaderId(ctx))
export const getGrabberEl = (ctx: Scope) => ctx.getById(getGrabberId(ctx))
export const getGrabberIndicatorEl = (ctx: Scope) => ctx.getById(getGrabberIndicatorId(ctx))
export const getCloseTriggerEl = (ctx: Scope) => ctx.getById(getCloseTriggerId(ctx))
+export const getSwipeAreaEl = (ctx: Scope) => ctx.getById(getSwipeAreaId(ctx))
+
+/** Whether the event target lies inside the drawer content or swipe-area subtree. */
+export function isPointerWithinContentOrSwipeArea(
+ target: EventTarget | null,
+ content: HTMLElement | null,
+ swipeArea: HTMLElement | null,
+): boolean {
+ if (!isHTMLElement(target)) return false
+ return Boolean((content && content.contains(target)) || (swipeArea && swipeArea.contains(target)))
+}
export const getScrollEls = (scope: Scope) => {
const els: Record<"x" | "y", HTMLElement[]> = { x: [], y: [] }
diff --git a/packages/machines/drawer/src/drawer.machine.ts b/packages/machines/drawer/src/drawer.machine.ts
index a1ef9c3c1a..72f26bcd94 100644
--- a/packages/machines/drawer/src/drawer.machine.ts
+++ b/packages/machines/drawer/src/drawer.machine.ts
@@ -1,49 +1,36 @@
import { ariaHidden } from "@zag-js/aria-hidden"
-import { createMachine } from "@zag-js/core"
+import { createGuards, createMachine } from "@zag-js/core"
import { trackDismissableElement } from "@zag-js/dismissable"
-import { addDomEvent, getEventPoint, getEventTarget, resizeObserverBorderBox } from "@zag-js/dom-query"
+import {
+ addDomEvent,
+ getComputedStyle,
+ getEventPoint,
+ getEventTarget,
+ getInitialFocus,
+ isHTMLElement,
+ raf,
+ resizeObserverBorderBox,
+} from "@zag-js/dom-query"
import { trapFocus } from "@zag-js/focus-trap"
import { preventBodyScroll } from "@zag-js/remove-scroll"
import * as dom from "./drawer.dom"
import { drawerRegistry } from "./drawer.registry"
-import type { DrawerSchema, ResolvedSnapPoint, SwipeDirection } from "./drawer.types"
+import type { DrawerSchema, ResolvedSnapPoint } from "./drawer.types"
import { DragManager } from "./utils/drag-manager"
-import { resolveSnapPoint } from "./utils/resolve-snap-point"
-
-const isVerticalDirection = (direction: SwipeDirection) => direction === "down" || direction === "up"
-
-function dedupeSnapPoints(points: ResolvedSnapPoint[]) {
- if (points.length <= 1) return points
-
- const deduped: ResolvedSnapPoint[] = []
- const seenHeights: number[] = []
-
- for (let index = points.length - 1; index >= 0; index -= 1) {
- const point = points[index]
- const isDuplicate = seenHeights.some((height) => Math.abs(height - point.height) <= 1)
- if (isDuplicate) continue
-
- seenHeights.push(point.height)
- deduped.push(point)
- }
-
- deduped.reverse()
- return deduped
-}
-
-function getDirectionSize(rect: DOMRect, direction: SwipeDirection) {
- return isVerticalDirection(direction) ? rect.height : rect.width
-}
-
-function clamp(value: number, min: number, max: number) {
- return Math.min(max, Math.max(min, value))
-}
-
-function resolveSwipeProgress(contentSize: number | null, dragOffset: number | null, snapPointOffset: number) {
- if (!contentSize || contentSize <= 0) return 0
- const currentOffset = dragOffset ?? snapPointOffset
- return clamp(1 - currentOffset / contentSize, 0, 1)
-}
+import {
+ findClosestScrollableAncestorOnSwipeAxis,
+ shouldPreventTouchDefaultForDrawerPull,
+} from "./utils/get-scroll-info"
+import { isDragExemptElement } from "./utils/is-drag-exempt-target"
+import { dedupeSnapPoints, resolveSnapPoint } from "./utils/snap-point"
+import {
+ getSwipeDirectionSize,
+ hasOpeningSwipeIntent,
+ isVerticalSwipeDirection,
+ resolveSwipeProgress,
+} from "./utils/swipe"
+
+const { and } = createGuards()
export const machine = createMachine({
props({ props, scope }) {
@@ -65,8 +52,8 @@ export const machine = createMachine({
defaultSnapPoint: props.defaultSnapPoint ?? snapPoints[0] ?? null,
swipeDirection: "down",
snapToSequentialPoints: false,
- swipeVelocityThreshold: 700,
- closeThreshold: 0.25,
+ swipeVelocityThreshold: 500,
+ closeThreshold: 0.5,
preventDragOnScroll: true,
...props,
}
@@ -99,6 +86,9 @@ export const machine = createMachine({
swipeStrength: bindable(() => ({
defaultValue: 1,
})),
+ rendered: bindable<{ title: boolean; description: boolean }>(() => ({
+ defaultValue: { title: true, description: true },
+ })),
}
},
@@ -117,13 +107,7 @@ export const machine = createMachine({
if (contentSize === null) return []
const points = prop("snapPoints")
- .map((snapPoint) =>
- resolveSnapPoint(snapPoint, {
- popupSize: contentSize,
- viewportSize,
- rootFontSize,
- }),
- )
+ .map((snapPoint) => resolveSnapPoint(snapPoint, { contentSize, viewportSize, rootFontSize }))
.filter((point): point is ResolvedSnapPoint => point !== null)
return dedupeSnapPoints(points)
@@ -157,11 +141,7 @@ export const machine = createMachine({
return
}
- const resolvedActiveSnapPoint = resolveSnapPoint(snapPoint, {
- popupSize: contentSize,
- viewportSize,
- rootFontSize,
- })
+ const resolvedActiveSnapPoint = resolveSnapPoint(snapPoint, { contentSize, viewportSize, rootFontSize })
if (resolvedActiveSnapPoint) {
context.set("resolvedActiveSnapPoint", resolvedActiveSnapPoint)
@@ -209,12 +189,14 @@ export const machine = createMachine({
states: {
open: {
tags: ["open"],
+ entry: ["checkRenderedElements", "setInitialFocus", "deferClearDragOffset"],
effects: [
"trackDismissableElement",
"preventScroll",
"trapFocus",
"hideContentBelow",
"trackPointerMove",
+ "trackGestureInterruption",
"trackSizeMeasurements",
"trackNestedDrawerMetrics",
"trackDrawerStack",
@@ -222,7 +204,7 @@ export const machine = createMachine({
on: {
"CONTROLLED.CLOSE": {
target: "closing",
- actions: ["resetSwipeStrength"],
+ actions: ["clearSwipeOpenAnimation", "resetSwipeStrength"],
},
POINTER_DOWN: {
actions: ["setPointerStart"],
@@ -238,33 +220,54 @@ export const machine = createMachine({
},
],
POINTER_UP: [
+ {
+ guard: and("shouldCloseOnSwipe", "isOpenControlled"),
+ actions: [
+ "clearSwipeOpenAnimation",
+ "clearRegistrySwiping",
+ "clearPointerStart",
+ "clearDragOffset",
+ "invokeOnClose",
+ ],
+ },
{
guard: "shouldCloseOnSwipe",
target: "closing",
- actions: ["clearRegistrySwiping", "setDismissSwipeStrength"],
+ actions: ["clearSwipeOpenAnimation", "clearRegistrySwiping", "setDismissSwipeStrength"],
},
{
guard: "isDragging",
actions: [
"clearRegistrySwiping",
+ "suppressBackdropAnimation",
"setSnapSwipeStrength",
"setClosestSnapPoint",
"clearPointerStart",
"clearDragOffset",
+ "clearVelocityTracking",
],
},
{
- actions: ["clearRegistrySwiping", "clearPointerStart", "clearDragOffset"],
+ actions: ["clearRegistrySwiping", "clearPointerStart", "clearDragOffset", "clearVelocityTracking"],
+ },
+ ],
+ POINTER_CANCEL: [
+ {
+ guard: "isDragging",
+ actions: ["clearRegistrySwiping", "clearPointerStart", "clearDragOffset", "clearVelocityTracking"],
+ },
+ {
+ actions: ["clearRegistrySwiping", "clearPointerStart", "clearVelocityTracking"],
},
],
CLOSE: [
{
guard: "isOpenControlled",
- actions: ["invokeOnClose"],
+ actions: ["clearSwipeOpenAnimation", "invokeOnClose"],
},
{
target: "closing",
- actions: ["resetSwipeStrength", "invokeOnClose"],
+ actions: ["clearSwipeOpenAnimation", "resetSwipeStrength", "invokeOnClose"],
},
],
},
@@ -288,6 +291,61 @@ export const machine = createMachine({
},
},
+ "swipe-area-dragging": {
+ tags: ["closed"],
+ effects: ["trackSwipeOpenPointerMove", "trackGestureInterruption"],
+ on: {
+ POINTER_MOVE: {
+ guard: "hasSwipeIntent",
+ target: "swiping-open",
+ },
+ POINTER_UP: {
+ target: "closed",
+ actions: ["clearPointerStart", "clearVelocityTracking"],
+ },
+ POINTER_CANCEL: {
+ target: "closed",
+ actions: ["clearPointerStart", "clearVelocityTracking"],
+ },
+ },
+ },
+
+ "swiping-open": {
+ tags: ["open"],
+ effects: ["trackSwipeOpenPointerMove", "trackSizeMeasurements", "trackGestureInterruption"],
+ on: {
+ POINTER_MOVE: {
+ actions: ["setSwipeOpenOffset"],
+ },
+ POINTER_UP: [
+ {
+ guard: and("shouldOpenOnSwipe", "isOpenControlled"),
+ actions: ["clearPointerStart", "invokeOnOpen"],
+ },
+ {
+ guard: "shouldOpenOnSwipe",
+ target: "open",
+ actions: ["clearPointerStart", "invokeOnOpen"],
+ },
+ {
+ target: "closed",
+ actions: ["clearPointerStart", "clearDragOffset", "clearSizeMeasurements"],
+ },
+ ],
+ POINTER_CANCEL: {
+ target: "closed",
+ actions: ["clearPointerStart", "clearDragOffset", "clearSizeMeasurements", "clearVelocityTracking"],
+ },
+ "CONTROLLED.OPEN": {
+ target: "open",
+ },
+ CLOSE: {
+ target: "closed",
+ actions: ["clearPointerStart", "clearDragOffset", "clearSizeMeasurements"],
+ },
+ },
+ },
+
closed: {
tags: ["closed"],
on: {
@@ -304,6 +362,10 @@ export const machine = createMachine({
actions: ["invokeOnOpen"],
},
],
+ "SWIPE_AREA.START": {
+ target: "swipe-area-dragging",
+ actions: ["setPointerStart"],
+ },
},
},
},
@@ -317,7 +379,8 @@ export const machine = createMachine({
},
shouldStartDragging({ prop, refs, event, scope }) {
- if (!(event.target instanceof HTMLElement)) return false
+ if (!isHTMLElement(event.target)) return false
+ if (isDragExemptElement(event.target)) return false
const dragManager = refs.get("dragManager")
return dragManager.shouldStartDragging(
@@ -330,10 +393,27 @@ export const machine = createMachine({
},
shouldCloseOnSwipe({ prop, context, computed, refs }) {
+ // In sequential mode, let findClosestSnapPoint handle dismiss decisions
+ if (prop("snapToSequentialPoints")) return false
const dragManager = refs.get("dragManager")
return dragManager.shouldDismiss(
context.get("contentSize"),
computed("resolvedSnapPoints"),
+ context.get("resolvedActiveSnapPoint")?.offset ?? 0,
+ )
+ },
+
+ hasSwipeIntent({ refs, prop, event }) {
+ const dragManager = refs.get("dragManager")
+ const start = dragManager.getPointerStart()
+ if (!start || !event.point) return false
+ return hasOpeningSwipeIntent(start, event.point, prop("swipeDirection"))
+ },
+
+ shouldOpenOnSwipe({ context, refs, prop }) {
+ const dragManager = refs.get("dragManager")
+ return dragManager.shouldOpen(
+ context.get("contentSize"),
prop("swipeVelocityThreshold"),
prop("closeThreshold"),
)
@@ -341,6 +421,59 @@ export const machine = createMachine({
},
actions: {
+ setInitialFocus({ prop, scope }) {
+ // In modal mode, trapFocus handles initial focus
+ if (prop("trapFocus")) return
+ raf(() => {
+ const element = getInitialFocus({
+ root: dom.getContentEl(scope),
+ getInitialEl: prop("initialFocusEl"),
+ })
+ element?.focus({ preventScroll: true })
+ })
+ },
+
+ checkRenderedElements({ context, scope }) {
+ raf(() => {
+ context.set("rendered", {
+ title: !!dom.getTitleEl(scope),
+ description: !!dom.getDescriptionEl(scope),
+ })
+ })
+ },
+
+ deferClearDragOffset({ context, refs, scope }) {
+ const dragOffset = context.get("dragOffset")
+ if (dragOffset === null) return
+
+ const contentEl = dom.getContentEl(scope)
+ const backdropEl = dom.getBackdropEl(scope)
+
+ // Suppress CSS entry animations so they don't replay from initial state.
+ // The CSS transition will smoothly animate from release position to fully open.
+ if (contentEl) contentEl.style.setProperty("animation", "none", "important")
+ if (backdropEl) backdropEl.style.setProperty("animation", "none", "important")
+
+ raf(() => {
+ refs.get("dragManager").clearDragOffset()
+ context.set("dragOffset", null)
+ })
+ },
+
+ suppressBackdropAnimation({ scope }) {
+ const backdropEl = dom.getBackdropEl(scope)
+ if (!backdropEl) return
+ // Keep override until drawer closes (clearSwipeOpenAnimation handles cleanup)
+ backdropEl.style.setProperty("animation", "none", "important")
+ },
+
+ clearSwipeOpenAnimation({ scope }) {
+ const contentEl = dom.getContentEl(scope)
+ const backdropEl = dom.getBackdropEl(scope)
+ if (contentEl) contentEl.style.removeProperty("animation")
+ if (backdropEl) backdropEl.style.removeProperty("animation")
+ },
+
invokeOnOpen({ prop }) {
prop("onOpenChange")?.({ open: true })
},
@@ -367,7 +500,15 @@ export const machine = createMachine({
context.set("dragOffset", dragManager.getDragOffset())
},
- setClosestSnapPoint({ computed, context, refs, prop }) {
+ setSwipeOpenOffset({ context, event, refs, prop }) {
+ const dragManager = refs.get("dragManager")
+ const contentSize = context.get("contentSize")
+ if (!contentSize) return
+ dragManager.setSwipeOpenOffset(event.point, contentSize, prop("swipeDirection"))
+ context.set("dragOffset", dragManager.getDragOffset())
+ },
+
+ setClosestSnapPoint({ computed, context, refs, prop, send }) {
const snapPoints = computed("resolvedSnapPoints")
const contentSize = context.get("contentSize")
const viewportSize = context.get("viewportSize")
@@ -380,15 +521,18 @@ export const machine = createMachine({
snapPoints,
context.get("resolvedActiveSnapPoint"),
prop("snapToSequentialPoints"),
+ contentSize,
)
+ // null means dismiss (sequential mode determined closing is closer)
+ if (closestSnapPoint === null) {
+ send({ type: "CLOSE" })
+ return
+ }
+
context.set("snapPoint", closestSnapPoint)
- const resolved = resolveSnapPoint(closestSnapPoint, {
- popupSize: contentSize,
- viewportSize,
- rootFontSize,
- })
+ const resolved = resolveSnapPoint(closestSnapPoint, { contentSize, viewportSize, rootFontSize })
context.set("resolvedActiveSnapPoint", resolved)
},
@@ -422,26 +566,30 @@ export const machine = createMachine({
setSnapSwipeStrength({ context, refs, computed, prop }) {
const dragManager = refs.get("dragManager")
const snapPoints = computed("resolvedSnapPoints")
+ const contentSize = context.get("contentSize")
const closestSnapPoint = dragManager.findClosestSnapPoint(
snapPoints,
context.get("resolvedActiveSnapPoint"),
prop("snapToSequentialPoints"),
+ contentSize ?? 0,
)
- const contentSize = context.get("contentSize")
+ if (closestSnapPoint === null) return
const viewportSize = context.get("viewportSize")
const rootFontSize = context.get("rootFontSize")
const resolved = resolveSnapPoint(closestSnapPoint, {
- popupSize: contentSize ?? 0,
+ contentSize: contentSize ?? 0,
viewportSize,
rootFontSize,
})
- context.set("swipeStrength", dragManager.computeSwipeStrength(resolved?.offset ?? 0))
+ const restOffset = context.get("resolvedActiveSnapPoint")?.offset ?? 0
+ context.set("swipeStrength", dragManager.computeSwipeStrength(resolved?.offset ?? 0, restOffset))
},
setDismissSwipeStrength({ context, refs }) {
const dragManager = refs.get("dragManager")
const contentSize = context.get("contentSize")
- context.set("swipeStrength", dragManager.computeSwipeStrength(contentSize ?? 0))
+ const restOffset = context.get("resolvedActiveSnapPoint")?.offset ?? 0
+ context.set("swipeStrength", dragManager.computeSwipeStrength(contentSize ?? 0, restOffset))
},
resetSwipeStrength({ context }) {
@@ -555,12 +703,40 @@ export const machine = createMachine({
return ariaHidden(getElements, { defer: true })
},
+ trackGestureInterruption({ scope, send }) {
+ const doc = scope.getDoc()
+
+ function onVisibilityChange() {
+ if (doc.visibilityState === "hidden") {
+ send({ type: "POINTER_CANCEL" })
+ }
+ }
+
+ function onLostPointerCapture(event: PointerEvent) {
+ const target = getEventTarget(event)
+ if (!dom.isPointerWithinContentOrSwipeArea(target, dom.getContentEl(scope), dom.getSwipeAreaEl(scope))) return
+ send({ type: "POINTER_CANCEL" })
+ }
+
+ const cleanups = [
+ addDomEvent(doc, "visibilitychange", onVisibilityChange),
+ addDomEvent(doc, "lostpointercapture", onLostPointerCapture, true),
+ ]
+
+ return () => {
+ cleanups.forEach((cleanup) => cleanup())
+ }
+ },
+
trackPointerMove({ scope, send, prop }) {
let lastAxis = 0
const swipeDirection = prop("swipeDirection")
- const isVertical = isVerticalDirection(swipeDirection)
+ const isVertical = isVerticalSwipeDirection(swipeDirection)
function onPointerMove(event: PointerEvent) {
+ // Touch is handled by touchmove/touchend only. Feeding both pointer and touch
+ // events duplicates POINTER_MOVE on many browsers and skews velocity / snap.
+ if (event.pointerType === "touch") return
const point = getEventPoint(event)
const target = getEventTarget(event)
send({ type: "POINTER_MOVE", point, target, swipeDirection })
@@ -572,6 +748,11 @@ export const machine = createMachine({
send({ type: "POINTER_UP", point })
}
+ function onPointerCancel(event: PointerEvent) {
+ if (event.pointerType === "touch") return
+ send({ type: "POINTER_CANCEL" })
+ }
+
function onTouchStart(event: TouchEvent) {
if (!event.touches[0]) return
lastAxis = isVertical ? event.touches[0].clientY : event.touches[0].clientX
@@ -580,7 +761,8 @@ export const machine = createMachine({
function onTouchMove(event: TouchEvent) {
if (!event.touches[0]) return
const point = getEventPoint(event)
- const target = event.target as HTMLElement
+ // Match pointer path: composedPath[0] behaves correctly with shadow DOM / retargeting.
+ const target = getEventTarget(event) ?? (event.target as HTMLElement)
if (!prop("preventDragOnScroll")) {
send({ type: "POINTER_MOVE", point, target, swipeDirection })
@@ -588,28 +770,27 @@ export const machine = createMachine({
}
const contentEl = dom.getContentEl(scope)
- if (!contentEl) return
-
- let el: HTMLElement | null = target
- while (
- el &&
- el !== contentEl &&
- (isVertical ? el.scrollHeight <= el.clientHeight : el.scrollWidth <= el.clientWidth)
- ) {
- el = el.parentElement
- }
-
- if (el && el !== contentEl) {
- const scrollPos = isVertical ? el.scrollTop : el.scrollLeft
- const axis = isVertical ? event.touches[0].clientY : event.touches[0].clientX
-
- const atStart = scrollPos <= 0
- const movingTowardStart = axis > lastAxis
- if (atStart && movingTowardStart) {
- event.preventDefault()
+ // Never drop touch moves when content isn't resolved yet — pointermove has no such gate,
+ // and missing moves on mobile breaks drag + sequential snap release.
+ if (contentEl && !isDragExemptElement(target)) {
+ const scrollParent = findClosestScrollableAncestorOnSwipeAxis(target, contentEl, swipeDirection)
+ if (scrollParent) {
+ const axis = isVertical ? event.touches[0].clientY : event.touches[0].clientX
+ if (
+ shouldPreventTouchDefaultForDrawerPull({
+ scrollParent,
+ swipeDirection,
+ lastMainAxis: lastAxis,
+ currentMainAxis: axis,
+ }) &&
+ event.cancelable
+ ) {
+ event.preventDefault()
+ }
+ lastAxis = axis
+ } else {
+ lastAxis = isVertical ? event.touches[0].clientY : event.touches[0].clientX
}
-
- lastAxis = axis
}
send({ type: "POINTER_MOVE", point, target, swipeDirection })
@@ -621,14 +802,20 @@ export const machine = createMachine({
send({ type: "POINTER_UP", point })
}
+ function onTouchCancel() {
+ send({ type: "POINTER_CANCEL" })
+ }
+
const doc = scope.getDoc()
const cleanups = [
addDomEvent(doc, "pointermove", onPointerMove),
addDomEvent(doc, "pointerup", onPointerUp),
- addDomEvent(doc, "touchstart", onTouchStart, { passive: false }),
- addDomEvent(doc, "touchmove", onTouchMove, { passive: false }),
- addDomEvent(doc, "touchend", onTouchEnd),
+ addDomEvent(doc, "pointercancel", onPointerCancel),
+ addDomEvent(doc, "touchstart", onTouchStart, { capture: true, passive: false }),
+ addDomEvent(doc, "touchmove", onTouchMove, { capture: true, passive: false }),
+ addDomEvent(doc, "touchend", onTouchEnd, { capture: true }),
+ addDomEvent(doc, "touchcancel", onTouchCancel, { capture: true }),
]
return () => {
@@ -646,10 +833,10 @@ export const machine = createMachine({
const updateSize = () => {
const direction = prop("swipeDirection")
const rect = contentEl.getBoundingClientRect()
- const viewportSize = isVerticalDirection(direction) ? html.clientHeight : html.clientWidth
+ const viewportSize = isVerticalSwipeDirection(direction) ? html.clientHeight : html.clientWidth
const rootFontSize = Number.parseFloat(getComputedStyle(html).fontSize)
- context.set("contentSize", getDirectionSize(rect, direction))
+ context.set("contentSize", getSwipeDirectionSize(rect, direction))
context.set("viewportSize", viewportSize)
if (Number.isFinite(rootFontSize)) {
context.set("rootFontSize", rootFontSize)
@@ -715,6 +902,52 @@ export const machine = createMachine({
}
},
+ trackSwipeOpenPointerMove({ scope, send }) {
+ const doc = scope.getDoc()
+
+ function onPointerMove(event: PointerEvent) {
+ if (event.pointerType === "touch") return
+ send({ type: "POINTER_MOVE", point: getEventPoint(event) })
+ }
+
+ function onPointerUp(event: PointerEvent) {
+ if (event.pointerType === "touch") return
+ send({ type: "POINTER_UP", point: getEventPoint(event) })
+ }
+
+ function onPointerCancelSwipeOpen(event: PointerEvent) {
+ if (event.pointerType === "touch") return
+ send({ type: "POINTER_CANCEL" })
+ }
+
+ function onTouchMove(event: TouchEvent) {
+ if (!event.touches[0]) return
+ send({ type: "POINTER_MOVE", point: getEventPoint(event) })
+ }
+
+ function onTouchEnd(event: TouchEvent) {
+ if (event.touches.length !== 0) return
+ send({ type: "POINTER_UP", point: getEventPoint(event) })
+ }
+
+ function onTouchCancelSwipeOpen() {
+ send({ type: "POINTER_CANCEL" })
+ }
+
+ const cleanups = [
+ addDomEvent(doc, "pointermove", onPointerMove),
+ addDomEvent(doc, "pointerup", onPointerUp),
+ addDomEvent(doc, "pointercancel", onPointerCancelSwipeOpen),
+ addDomEvent(doc, "touchmove", onTouchMove, { passive: false }),
+ addDomEvent(doc, "touchend", onTouchEnd),
+ addDomEvent(doc, "touchcancel", onTouchCancelSwipeOpen, { capture: true }),
+ ]
+
+ return () => {
+ cleanups.forEach((cleanup) => cleanup())
+ }
+ },
+
trackExitAnimation({ send, scope }) {
const contentEl = dom.getContentEl(scope)
if (!contentEl) return
diff --git a/packages/machines/drawer/src/drawer.types.ts b/packages/machines/drawer/src/drawer.types.ts
index a8af509275..fbad65712d 100644
--- a/packages/machines/drawer/src/drawer.types.ts
+++ b/packages/machines/drawer/src/drawer.types.ts
@@ -47,11 +47,13 @@ export type ElementIds = Partial<{
positioner: string
content: string
title: string
+ description: string
header: string
trigger: string
grabber: string
grabberIndicator: string
closeTrigger: string
+ swipeArea: string
}>
export interface DrawerProps extends DirectionProperty, CommonProperties, DismissableElementHandlers {
@@ -184,7 +186,7 @@ type PropsWithDefault =
export interface DrawerSchema {
props: RequiredBy
- state: "open" | "closed" | "closing"
+ state: "open" | "closed" | "closing" | "swipe-area-dragging" | "swiping-open"
tag: "open" | "closed"
context: {
dragOffset: number | null
@@ -194,6 +196,7 @@ export interface DrawerSchema {
viewportSize: number
rootFontSize: number
swipeStrength: number
+ rendered: { title: boolean; description: boolean }
}
refs: {
dragManager: DragManager
@@ -220,6 +223,19 @@ export interface ContentProps {
draggable?: boolean | undefined
}
+export interface SwipeAreaProps {
+ /**
+ * Whether the swipe area is disabled.
+ * @default false
+ */
+ disabled?: boolean | undefined
+ /**
+ * The swipe direction that opens the drawer.
+ * Defaults to the opposite of the drawer's `swipeDirection`.
+ */
+ swipeDirection?: SwipeDirection | undefined
+}
+
export interface DrawerApi {
/**
* Whether the drawer is open.
@@ -270,9 +286,11 @@ export interface DrawerApi {
getPositionerProps: () => T["element"]
getContentProps: (props?: ContentProps) => T["element"]
getTitleProps: () => T["element"]
+ getDescriptionProps: () => T["element"]
getTriggerProps: () => T["element"]
getBackdropProps: () => T["element"]
getGrabberProps: () => T["element"]
getGrabberIndicatorProps: () => T["element"]
getCloseTriggerProps: () => T["element"]
+ getSwipeAreaProps: (props?: SwipeAreaProps) => T["element"]
}
diff --git a/packages/machines/drawer/src/index.ts b/packages/machines/drawer/src/index.ts
index 31d1133828..604bf6afcb 100644
--- a/packages/machines/drawer/src/index.ts
+++ b/packages/machines/drawer/src/index.ts
@@ -18,5 +18,6 @@ export type {
DrawerService as Service,
SnapPoint,
SnapPointChangeDetails,
+ SwipeAreaProps,
SwipeDirection,
} from "./drawer.types"
diff --git a/packages/machines/drawer/src/utils/deferred-pointer.ts b/packages/machines/drawer/src/utils/deferred-pointer.ts
new file mode 100644
index 0000000000..6199e7f2ac
--- /dev/null
+++ b/packages/machines/drawer/src/utils/deferred-pointer.ts
@@ -0,0 +1,87 @@
+import type { Scope } from "@zag-js/core"
+import { addDomEvent } from "@zag-js/dom-query"
+import type { Point } from "@zag-js/types"
+import type { SwipeDirection } from "../drawer.types"
+import { isVerticalSwipeDirection } from "./swipe"
+
+const DEFERRED_DRAG_MIN_MAIN_AXIS_PX = 6
+/** Require main-axis movement to clearly dominate cross-axis (horizontal text drag stays off sheet drag). */
+const DEFERRED_DRAG_MAIN_OVER_CROSS_RATIO = 1.35
+
+interface PendingPointer {
+ pointerId: number
+ startPoint: Point
+ cleanups: Array<() => void>
+}
+
+const pendingByScope = new WeakMap()
+
+function clearPending(scope: Scope) {
+ const p = pendingByScope.get(scope)
+ if (!p) return
+ p.cleanups.forEach((fn) => fn())
+ pendingByScope.delete(scope)
+}
+
+/**
+ * Cancel any in-progress deferred content pointer-down (e.g. new gesture starting).
+ */
+export function cancelDeferPointerDown(scope: Scope) {
+ clearPending(scope)
+}
+
+export interface DeferPointerDownOptions {
+ scope: Scope
+ /** Called when movement reads as a sheet-axis drag; wire to `send({ type: "POINTER_DOWN", point })` at the call site. */
+ onCommit: (point: Point) => void
+ /** Drawer must still be open and not in closing animation before arming drag. */
+ canCommitPointerDown: () => boolean
+ swipeDirection: SwipeDirection
+ pointerId: number
+ startPoint: Point
+}
+
+/**
+ * For mouse/pen on sheet content: wait until movement is clearly a sheet-axis drag before calling `onCommit`,
+ * so click-drag to select text does not arm the drawer on the first pixel of movement.
+ */
+export function deferPointerDown(options: DeferPointerDownOptions): void {
+ const { scope, onCommit, canCommitPointerDown, swipeDirection, pointerId, startPoint } = options
+
+ clearPending(scope)
+
+ const win = scope.getWin()
+ const vertical = isVerticalSwipeDirection(swipeDirection)
+
+ function onMove(event: PointerEvent) {
+ if (event.pointerId !== pointerId) return
+
+ const dx = event.clientX - startPoint.x
+ const dy = event.clientY - startPoint.y
+ const mainDelta = vertical ? dy : dx
+ const crossDelta = vertical ? dx : dy
+ const absMain = Math.abs(mainDelta)
+ const absCross = Math.abs(crossDelta)
+
+ if (absMain >= DEFERRED_DRAG_MIN_MAIN_AXIS_PX && absMain >= absCross * DEFERRED_DRAG_MAIN_OVER_CROSS_RATIO) {
+ if (canCommitPointerDown()) {
+ onCommit(startPoint)
+ }
+ clearPending(scope)
+ }
+ }
+
+ function onEnd(event: PointerEvent) {
+ if (event.pointerId !== pointerId) return
+ clearPending(scope)
+ }
+
+ const cleanups = [
+ addDomEvent(win, "pointermove", onMove, { capture: true }),
+ addDomEvent(win, "pointerup", onEnd, { capture: true }),
+ addDomEvent(win, "pointercancel", onEnd, { capture: true }),
+ addDomEvent(win, "lostpointercapture", onEnd, { capture: true }),
+ ]
+
+ pendingByScope.set(scope, { pointerId, startPoint, cleanups })
+}
diff --git a/packages/machines/drawer/src/utils/drag-manager.ts b/packages/machines/drawer/src/utils/drag-manager.ts
index 071cd567c2..9df760ea57 100644
--- a/packages/machines/drawer/src/utils/drag-manager.ts
+++ b/packages/machines/drawer/src/utils/drag-manager.ts
@@ -1,21 +1,46 @@
import type { Point } from "@zag-js/types"
import type { ResolvedSnapPoint, SwipeDirection } from "../drawer.types"
-import { findClosestSnapPoint } from "./find-closest-snap-point"
import { getScrollInfo } from "./get-scroll-info"
+import { adjustReleaseVelocityAgainstDisplacement, adjustReleaseVelocityForOpenSwipe } from "./release-velocity"
+import { findClosestSnapPoint } from "./snap-point"
+import { isVerticalSwipeDirection } from "./swipe"
const DRAG_START_THRESHOLD = 0.3
-const SEQUENTIAL_THRESHOLD = 14
-const SNAP_VELOCITY_THRESHOLD = 500 // px/s
-const SNAP_VELOCITY_MULTIPLIER = 0.3 // seconds
+/** Treat slightly diagonal moves as cross-axis–biased so nested horizontal scroll wins more often. */
+const CROSS_AXIS_BIAS = 0.58
+const SCROLL_SLACK_GATE = 0.5
+const SEQUENTIAL_THRESHOLD = 24
+const SNAP_VELOCITY_THRESHOLD = 400 // px/s
+const SNAP_VELOCITY_MULTIPLIER = 0.4 // seconds
const MAX_SNAP_VELOCITY = 4000 // px/s
+const VELOCITY_WINDOW_MS = 100
+const MAX_RELEASE_VELOCITY_AGE_MS = 80
+const MIN_GESTURE_DURATION_MS = 50
+const MIN_VELOCITY_SAMPLES = 2
+
+const SWIPE_STRENGTH_MAX_DURATION_MS = 360
+const SWIPE_STRENGTH_MIN_SCALAR = 0.1
+const SWIPE_STRENGTH_MAX_SCALAR = 1
+
+interface VelocitySample {
+ axis: number
+ time: number
+}
+
export class DragManager {
private pointerStart: Point | null = null
private dragOffset: number | null = null
- private lastPoint: Point | null = null
- private lastTimestamp: number | null = null
private velocity: number | null = null
+ // Sliding window for velocity calculation
+ private samples: VelocitySample[] = []
+
+ // Gesture-level tracking for fallback velocity
+ private gestureStartAxis: number | null = null
+ private gestureStartTime: number | null = null
+ private gestureSign: number = 1
+
setPointerStart(point: Point) {
this.pointerStart = point
}
@@ -36,29 +61,82 @@ export class DragManager {
return direction === "up" || direction === "left" ? -1 : 1
}
- setDragOffsetForDirection(point: Point, resolvedActiveSnapPointOffset: number, direction: SwipeDirection) {
- if (!this.pointerStart) return
+ private trackVelocity(axisValue: number, sign: number) {
+ const now = performance.now()
- const currentTimestamp = new Date().getTime()
- const sign = this.getDirectionSign(direction)
- const axisValue = this.getAxisValue(point, direction)
+ // Track gesture start for fallback velocity
+ if (this.gestureStartAxis === null) {
+ this.gestureStartAxis = axisValue
+ this.gestureStartTime = now
+ this.gestureSign = sign
+ }
- if (this.lastPoint) {
- const lastAxisValue = this.getAxisValue(this.lastPoint, direction)
- const delta = (axisValue - lastAxisValue) * sign
+ this.samples.push({ axis: axisValue, time: now })
- if (this.lastTimestamp) {
- const dt = currentTimestamp - this.lastTimestamp
- if (dt > 0) {
- const calculatedVelocity = (delta / dt) * 1000
- // Handle edge cases: NaN or Infinity should be treated as 0
- this.velocity = Number.isFinite(calculatedVelocity) ? calculatedVelocity : 0
- }
+ // Prune samples older than the window
+ const cutoff = now - VELOCITY_WINDOW_MS
+ while (this.samples.length > 0 && this.samples[0].time < cutoff) {
+ this.samples.shift()
+ }
+
+ if (this.samples.length < MIN_VELOCITY_SAMPLES) {
+ this.velocity = 0
+ return
+ }
+
+ // Compute velocity from oldest to newest sample in the window
+ const oldest = this.samples[0]
+ const newest = this.samples[this.samples.length - 1]
+ const dt = newest.time - oldest.time
+
+ if (dt <= 0) {
+ this.velocity = 0
+ return
+ }
+
+ const delta = (newest.axis - oldest.axis) * sign
+ const v = (delta / dt) * 1000 // px/s
+ this.velocity = Number.isFinite(v) ? v : 0
+ }
+
+ /**
+ * Get the best available velocity for release decisions.
+ * Prefers the sliding window velocity if the last sample is fresh.
+ * Falls back to the overall gesture velocity if samples are stale
+ * (e.g., user paused briefly before releasing).
+ */
+ getReleaseVelocity(): number {
+ const now = performance.now()
+
+ // Check if the sliding window velocity is fresh
+ if (this.samples.length >= MIN_VELOCITY_SAMPLES) {
+ const newest = this.samples[this.samples.length - 1]
+ if (now - newest.time <= MAX_RELEASE_VELOCITY_AGE_MS) {
+ return this.velocity ?? 0
}
}
- this.lastPoint = point
- this.lastTimestamp = currentTimestamp
+ // Fallback: overall gesture velocity
+ if (this.gestureStartAxis !== null && this.gestureStartTime !== null) {
+ const lastSample = this.samples[this.samples.length - 1]
+ if (lastSample) {
+ const dt = Math.max(lastSample.time - this.gestureStartTime, MIN_GESTURE_DURATION_MS)
+ const delta = (lastSample.axis - this.gestureStartAxis) * this.gestureSign
+ const v = (delta / dt) * 1000
+ return Number.isFinite(v) ? v : 0
+ }
+ }
+
+ return this.velocity ?? 0
+ }
+
+ setDragOffsetForDirection(point: Point, resolvedActiveSnapPointOffset: number, direction: SwipeDirection) {
+ if (!this.pointerStart) return
+
+ const sign = this.getDirectionSign(direction)
+ const axisValue = this.getAxisValue(point, direction)
+
+ this.trackVelocity(axisValue, sign)
const pointerStartAxis = this.getAxisValue(this.pointerStart, direction)
let delta = (pointerStartAxis - axisValue) * sign - resolvedActiveSnapPointOffset
@@ -79,14 +157,11 @@ export class DragManager {
this.dragOffset = null
}
- getVelocity(): number | null {
- return this.velocity
- }
-
clearVelocityTracking() {
- this.lastPoint = null
- this.lastTimestamp = null
+ this.samples = []
this.velocity = null
+ this.gestureStartAxis = null
+ this.gestureStartTime = null
}
clear() {
@@ -112,9 +187,27 @@ export class DragManager {
if (Math.abs(delta) < DRAG_START_THRESHOLD) return false
+ // If movement leans cross-axis, preserve native scrolling on that axis
+ const isVertical = isVerticalSwipeDirection(direction)
+ const crossDelta = isVertical ? Math.abs(point.x - this.pointerStart.x) : Math.abs(point.y - this.pointerStart.y)
+
+ if (crossDelta > Math.abs(delta) * CROSS_AXIS_BIAS) {
+ const crossDirection: SwipeDirection = isVertical ? "right" : "down"
+ const crossScroll = getScrollInfo(target, container, crossDirection)
+ if (
+ crossScroll.availableForwardScroll > SCROLL_SLACK_GATE ||
+ crossScroll.availableBackwardScroll > SCROLL_SLACK_GATE
+ ) {
+ return false
+ }
+ }
+
const { availableForwardScroll, availableBackwardScroll } = getScrollInfo(target, container, direction)
- if ((delta > 0 && Math.abs(availableForwardScroll) > 1) || (delta < 0 && Math.abs(availableBackwardScroll) > 0)) {
+ if (
+ (delta > 0 && availableForwardScroll > SCROLL_SLACK_GATE) ||
+ (delta < 0 && availableBackwardScroll > SCROLL_SLACK_GATE)
+ ) {
return false
}
}
@@ -126,45 +219,92 @@ export class DragManager {
snapPoints: ResolvedSnapPoint[],
snapPoint: ResolvedSnapPoint | null,
snapToSequentialPoints: boolean,
- ): number | string {
+ contentSize: number,
+ ): number | string | null {
if (this.dragOffset === null) {
return snapPoints[0]?.value ?? 1
}
if (snapToSequentialPoints && snapPoint) {
- const currentIndex = snapPoints.findIndex((item) => Object.is(item.value, snapPoint.value))
- if (currentIndex >= 0) {
- const delta = this.dragOffset - snapPoint.offset
+ // Sort by offset so index navigation matches drag direction
+ // (offset 0 = fully open at index 0, highest offset = most closed at end)
+ const ordered = [...snapPoints].sort((a, b) => a.offset - b.offset)
+
+ // Find current index by closest offset (not value) since deduplication
+ // may have replaced the original snap point value
+ let currentIndex = 0
+ let closestDist = Math.abs(snapPoint.offset - ordered[0].offset)
+ for (let i = 1; i < ordered.length; i++) {
+ const dist = Math.abs(snapPoint.offset - ordered[i].offset)
+ if (dist < closestDist) {
+ closestDist = dist
+ currentIndex = i
+ }
+ }
+
+ {
+ const currentPoint = ordered[currentIndex]
+ const delta = this.dragOffset - currentPoint.offset
const dragDirection = Math.sign(delta)
- const velocityDirection = this.velocity !== null ? Math.sign(this.velocity) : 0
+ const velocityAdjusted = adjustReleaseVelocityAgainstDisplacement(this.getReleaseVelocity(), delta)
+ const velocityDirection = Math.sign(velocityAdjusted)
+
+ let targetSnapPoint = currentPoint
+ let effectiveTargetOffset = this.dragOffset
- // Velocity-based skip: fast swipe in drag direction jumps to adjacent point
+ // Velocity-based advancement to adjacent snap point
const shouldAdvance =
dragDirection !== 0 &&
velocityDirection === dragDirection &&
- Math.abs(this.velocity ?? 0) >= SNAP_VELOCITY_THRESHOLD
+ Math.abs(velocityAdjusted) >= SNAP_VELOCITY_THRESHOLD
if (shouldAdvance) {
- const nextIndex = Math.min(Math.max(currentIndex + dragDirection, 0), snapPoints.length - 1)
- if (nextIndex !== currentIndex) {
- return snapPoints[nextIndex].value
+ const adjacentIndex = Math.min(Math.max(currentIndex + dragDirection, 0), ordered.length - 1)
+ if (adjacentIndex !== currentIndex) {
+ const adjacentPoint = ordered[adjacentIndex]
+ // Always step to the adjacent snap when velocity commits to that direction.
+ // The old "only if not yet passed" check stranded flicks that overshot the
+ // intermediate offset (common on touch), snapping back to the previous stop.
+ targetSnapPoint = adjacentPoint
+ effectiveTargetOffset = adjacentPoint.offset
+ } else if (dragDirection > 0) {
+ // Past the last snap point with velocity → dismiss
+ return null
+ }
+ } else if (delta > SEQUENTIAL_THRESHOLD) {
+ const nextPoint = ordered[Math.min(currentIndex + 1, ordered.length - 1)]
+ if (nextPoint) {
+ targetSnapPoint = nextPoint
+ effectiveTargetOffset = nextPoint.offset
+ }
+ } else if (delta < -SEQUENTIAL_THRESHOLD) {
+ const prevPoint = ordered[Math.max(currentIndex - 1, 0)]
+ if (prevPoint) {
+ targetSnapPoint = prevPoint
+ effectiveTargetOffset = prevPoint.offset
}
}
- if (delta > SEQUENTIAL_THRESHOLD) {
- return snapPoints[Math.min(currentIndex + 1, snapPoints.length - 1)]?.value ?? snapPoint.value
+ // Compare close distance vs snap distance
+ const closeDistance = Math.abs(effectiveTargetOffset - contentSize)
+ const snapDistance = Math.abs(effectiveTargetOffset - targetSnapPoint.offset)
+ if (closeDistance < snapDistance) {
+ return null // dismiss
}
- if (delta < -SEQUENTIAL_THRESHOLD) {
- return snapPoints[Math.max(currentIndex - 1, 0)]?.value ?? snapPoint.value
- }
- return snapPoint.value
+
+ return targetSnapPoint.value
}
}
// Non-sequential: apply velocity offset for momentum-based skipping
+ const snapRestOffset = snapPoint?.offset ?? 0
+ const velocity = adjustReleaseVelocityAgainstDisplacement(
+ this.getReleaseVelocity(),
+ this.dragOffset - snapRestOffset,
+ )
let targetOffset = this.dragOffset
- if (this.velocity !== null && Math.abs(this.velocity) >= SNAP_VELOCITY_THRESHOLD) {
- const clamped = Math.min(MAX_SNAP_VELOCITY, Math.max(-MAX_SNAP_VELOCITY, this.velocity))
+ if (Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD) {
+ const clamped = Math.min(MAX_SNAP_VELOCITY, Math.max(-MAX_SNAP_VELOCITY, velocity))
targetOffset += clamped * SNAP_VELOCITY_MULTIPLIER
targetOffset = Math.max(0, targetOffset)
}
@@ -173,49 +313,86 @@ export class DragManager {
return closest.value
}
- computeSwipeStrength(targetOffset: number): number {
- const MAX_DURATION_MS = 360
- const MIN_SCALAR = 0.1
- const MAX_SCALAR = 1
+ setSwipeOpenOffset(point: Point, contentSize: number, direction: SwipeDirection) {
+ if (!this.pointerStart) return
+
+ const sign = this.getDirectionSign(direction)
+ const axisValue = this.getAxisValue(point, direction)
+
+ this.trackVelocity(axisValue, sign)
+
+ // Opening displacement: how far user has swiped in the opening direction
+ const pointerStartAxis = this.getAxisValue(this.pointerStart, direction)
+ const openDisplacement = (pointerStartAxis - axisValue) * sign
+
+ let dragOffset = contentSize - Math.max(0, openDisplacement)
+
+ // Rubber-band: sqrt damping when dragged past fully open
+ if (dragOffset < 0) {
+ dragOffset = -Math.sqrt(Math.abs(dragOffset))
+ }
+
+ this.dragOffset = dragOffset
+ }
+
+ shouldOpen(contentSize: number | null, swipeVelocityThreshold: number, openThreshold: number): boolean {
+ if (this.dragOffset === null || contentSize === null) return false
+
+ const visibleSize = contentSize - this.dragOffset
+ const visibleRatio = visibleSize / contentSize
+ const velocity = adjustReleaseVelocityForOpenSwipe(this.getReleaseVelocity(), visibleRatio, swipeVelocityThreshold)
+
+ // Fast swipe in opening direction (negative velocity = opening)
+ const isFastSwipe = velocity < 0 && Math.abs(velocity) >= swipeVelocityThreshold
+
+ // Dragged past threshold
+ const hasEnoughDisplacement = visibleSize >= contentSize * openThreshold
+
+ return isFastSwipe || hasEnoughDisplacement
+ }
- if (this.dragOffset === null || this.velocity === null) return MAX_SCALAR
+ computeSwipeStrength(targetOffset: number, resolvedSnapOffset: number | null = null): number {
+ if (this.dragOffset === null) return SWIPE_STRENGTH_MAX_SCALAR
+ let velocity = this.getReleaseVelocity()
+ if (resolvedSnapOffset != null) {
+ velocity = adjustReleaseVelocityAgainstDisplacement(velocity, this.dragOffset - resolvedSnapOffset)
+ }
const distance = Math.abs(this.dragOffset - targetOffset)
- const absVelocity = Math.abs(this.velocity)
+ const absVelocity = Math.abs(velocity)
- if (absVelocity <= 0 || distance <= 0) return MAX_SCALAR
+ if (absVelocity <= 0 || distance <= 0) return SWIPE_STRENGTH_MAX_SCALAR
const estimatedTimeMs = (distance / absVelocity) * 1000
- const normalized = Math.min(1, Math.max(0, estimatedTimeMs / MAX_DURATION_MS))
- return MIN_SCALAR + normalized * (MAX_SCALAR - MIN_SCALAR)
+ const normalized = Math.min(1, Math.max(0, estimatedTimeMs / SWIPE_STRENGTH_MAX_DURATION_MS))
+ return SWIPE_STRENGTH_MIN_SCALAR + normalized * (SWIPE_STRENGTH_MAX_SCALAR - SWIPE_STRENGTH_MIN_SCALAR)
}
- shouldDismiss(
- contentSize: number | null,
- snapPoints: ResolvedSnapPoint[],
- swipeVelocityThreshold: number,
- closeThreshold: number,
- ): boolean {
- if (this.dragOffset === null || this.velocity === null || contentSize === null) return false
+ shouldDismiss(contentSize: number | null, snapPoints: ResolvedSnapPoint[], resolvedSnapOffset: number): boolean {
+ if (this.dragOffset === null || contentSize === null) return false
+ const velocity = adjustReleaseVelocityAgainstDisplacement(
+ this.getReleaseVelocity(),
+ this.dragOffset - resolvedSnapOffset,
+ )
const visibleSize = contentSize - this.dragOffset
- const smallestSnapPoint = snapPoints.reduce((acc, curr) => (curr.offset > acc.offset ? curr : acc))
-
- const isFastSwipe = this.velocity > 0 && this.velocity >= swipeVelocityThreshold
- const closeThresholdInPixels = contentSize * (1 - closeThreshold)
- const smallestSnapPointVisibleSize = contentSize - smallestSnapPoint.offset
- const isBelowSmallestSnapPoint = visibleSize < smallestSnapPointVisibleSize
- const isBelowCloseThreshold = visibleSize < closeThresholdInPixels
+ if (visibleSize <= 0) return true
- // With multiple snap points, prefer snapping during regular drags,
- // but still allow a fast swipe down to dismiss from below the lowest snap point.
- if (snapPoints.length > 1) {
- return visibleSize <= 0 || (isFastSwipe && isBelowSmallestSnapPoint)
+ // Apply velocity to target offset (same as findClosestSnapPoint)
+ let targetOffset = this.dragOffset
+ if (Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD) {
+ const clamped = Math.min(MAX_SNAP_VELOCITY, Math.max(-MAX_SNAP_VELOCITY, velocity))
+ targetOffset += clamped * SNAP_VELOCITY_MULTIPLIER
+ targetOffset = Math.max(0, targetOffset)
}
- const hasEnoughDragToDismiss = (isBelowCloseThreshold && isBelowSmallestSnapPoint) || visibleSize === 0
+ // Compare: is the velocity-adjusted target closer to "fully closed"
+ // than to any snap point? If so, dismiss.
+ const closeDistance = Math.abs(targetOffset - contentSize)
+ const closest = findClosestSnapPoint(targetOffset, snapPoints)
+ const snapDistance = Math.abs(targetOffset - closest.offset)
- return (isFastSwipe && isBelowSmallestSnapPoint) || hasEnoughDragToDismiss
+ return closeDistance < snapDistance
}
}
diff --git a/packages/machines/drawer/src/utils/find-closest-snap-point.ts b/packages/machines/drawer/src/utils/find-closest-snap-point.ts
deleted file mode 100644
index 6a0f1fe6eb..0000000000
--- a/packages/machines/drawer/src/utils/find-closest-snap-point.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { ResolvedSnapPoint } from "../drawer.types"
-
-export function findClosestSnapPoint(offset: number, snapPoints: ResolvedSnapPoint[]): ResolvedSnapPoint {
- return snapPoints.reduce((acc, curr) => {
- const closestDiff = Math.abs(offset - acc.offset)
- const currentDiff = Math.abs(offset - curr.offset)
- return currentDiff < closestDiff ? curr : acc
- })
-}
diff --git a/packages/machines/drawer/src/utils/get-scroll-info.ts b/packages/machines/drawer/src/utils/get-scroll-info.ts
index 803fa5d19b..0d58b1c5b8 100644
--- a/packages/machines/drawer/src/utils/get-scroll-info.ts
+++ b/packages/machines/drawer/src/utils/get-scroll-info.ts
@@ -1,41 +1,78 @@
+import { getComputedStyle } from "@zag-js/dom-query"
import type { SwipeDirection } from "../drawer.types"
+import { isVerticalSwipeDirection } from "./swipe"
-function isVerticalScrollContainer(element: HTMLElement) {
- const styles = getComputedStyle(element)
- const overflow = styles.overflowY
+const SCROLL_SLACK_EPSILON = 1
- return overflow === "auto" || overflow === "scroll"
+function overflowAllowsScroll(overflow: string): boolean {
+ return overflow === "auto" || overflow === "scroll" || overflow === "overlay"
}
-function isHorizontalScrollContainer(element: HTMLElement) {
- const styles = getComputedStyle(element)
- const overflow = styles.overflowX
- return overflow === "auto" || overflow === "scroll"
+/**
+ * Whether the element can be scrolled by the user on the Y axis (overflow + overflow size).
+ */
+export function canScrollAlongY(el: HTMLElement): boolean {
+ const style = getComputedStyle(el)
+ if (!overflowAllowsScroll(style.overflowY)) return false
+ return el.scrollHeight > el.clientHeight + SCROLL_SLACK_EPSILON
}
-const isVerticalDirection = (direction: SwipeDirection) => direction === "up" || direction === "down"
+/**
+ * Whether the element can be scrolled by the user on the X axis.
+ */
+export function canScrollAlongX(el: HTMLElement): boolean {
+ const style = getComputedStyle(el)
+ if (!overflowAllowsScroll(style.overflowX)) return false
+ return el.scrollWidth > el.clientWidth + SCROLL_SLACK_EPSILON
+}
+
+export function canScrollOnSwipeAxis(el: HTMLElement, direction: SwipeDirection): boolean {
+ return isVerticalSwipeDirection(direction) ? canScrollAlongY(el) : canScrollAlongX(el)
+}
+
+/**
+ * Closest scrollable ancestor on the swipe axis when walking up from `target` toward `container`
+ * (does not include `container` itself).
+ */
+export function findClosestScrollableAncestorOnSwipeAxis(
+ target: HTMLElement,
+ container: HTMLElement | null,
+ direction: SwipeDirection,
+): HTMLElement | null {
+ if (!container) return null
+ let el: HTMLElement | null = target
+ while (el && el !== container) {
+ if (canScrollOnSwipeAxis(el, direction)) return el
+ el = el.parentElement
+ }
+ return null
+}
-export function getScrollInfo(target: HTMLElement, container: HTMLElement, direction: SwipeDirection) {
- let element = target
+/**
+ * Accumulated forward/backward scroll slack from `target` up to `container` on the given axis.
+ */
+export function getScrollInfo(target: HTMLElement, container: HTMLElement | null, direction: SwipeDirection) {
let availableForwardScroll = 0
let availableBackwardScroll = 0
- const vertical = isVerticalDirection(direction)
-
- while (element) {
- const clientSize = vertical ? element.clientHeight : element.clientWidth
- const scrollPos = vertical ? element.scrollTop : element.scrollLeft
- const scrollSize = vertical ? element.scrollHeight : element.scrollWidth
+ if (!container) {
+ return { availableForwardScroll, availableBackwardScroll }
+ }
- const scrolled = scrollSize - scrollPos - clientSize
- const isScrollable = vertical ? isVerticalScrollContainer(element) : isHorizontalScrollContainer(element)
+ const vertical = isVerticalSwipeDirection(direction)
+ let element: HTMLElement | null = target
- if ((scrollPos !== 0 || scrolled !== 0) && isScrollable) {
+ while (element) {
+ if (vertical ? canScrollAlongY(element) : canScrollAlongX(element)) {
+ const clientSize = vertical ? element.clientHeight : element.clientWidth
+ const scrollPos = vertical ? element.scrollTop : element.scrollLeft
+ const scrollSize = vertical ? element.scrollHeight : element.scrollWidth
+ const scrolled = scrollSize - scrollPos - clientSize
availableForwardScroll += scrolled
availableBackwardScroll += scrollPos
}
- if (element === container || element === document.documentElement) break
- element = element.parentNode as HTMLElement
+ if (element === container || element === element.ownerDocument.documentElement) break
+ element = element.parentElement
}
return {
@@ -43,3 +80,48 @@ export function getScrollInfo(target: HTMLElement, container: HTMLElement, direc
availableBackwardScroll,
}
}
+
+/**
+ * Whether a capturing `touchmove` should call `preventDefault` so the drawer can take over
+ * when a nested scroller is exhausted on the edge that aligns with dismiss direction.
+ */
+export function shouldPreventTouchDefaultForDrawerPull(options: {
+ scrollParent: HTMLElement
+ swipeDirection: SwipeDirection
+ lastMainAxis: number
+ currentMainAxis: number
+}): boolean {
+ const { scrollParent, swipeDirection, lastMainAxis, currentMainAxis } = options
+ const vertical = isVerticalSwipeDirection(swipeDirection)
+ const movingPositive = currentMainAxis > lastMainAxis
+
+ if (vertical) {
+ const scrollPos = scrollParent.scrollTop
+ const maxScroll = Math.max(0, scrollParent.scrollHeight - scrollParent.clientHeight)
+
+ if (swipeDirection === "down") {
+ const atStart = scrollPos <= SCROLL_SLACK_EPSILON
+ return atStart && movingPositive
+ }
+
+ if (swipeDirection === "up") {
+ const atEnd = scrollPos >= maxScroll - SCROLL_SLACK_EPSILON
+ return atEnd && !movingPositive
+ }
+ } else {
+ const scrollPos = scrollParent.scrollLeft
+ const maxScroll = Math.max(0, scrollParent.scrollWidth - scrollParent.clientWidth)
+
+ if (swipeDirection === "right") {
+ const atStart = scrollPos <= SCROLL_SLACK_EPSILON
+ return atStart && movingPositive
+ }
+
+ if (swipeDirection === "left") {
+ const atEnd = scrollPos >= maxScroll - SCROLL_SLACK_EPSILON
+ return atEnd && !movingPositive
+ }
+ }
+
+ return false
+}
diff --git a/packages/machines/drawer/src/utils/is-drag-exempt-target.ts b/packages/machines/drawer/src/utils/is-drag-exempt-target.ts
new file mode 100644
index 0000000000..2dd6789546
--- /dev/null
+++ b/packages/machines/drawer/src/utils/is-drag-exempt-target.ts
@@ -0,0 +1,76 @@
+import {
+ contains,
+ getEventTarget,
+ isEditableElement,
+ isHTMLElement,
+ isInputElement,
+ isLeftClick,
+} from "@zag-js/dom-query"
+import type { JSX } from "@zag-js/types"
+
+const NO_DRAG_DATA_ATTR = "data-no-drag"
+const NO_DRAG_SELECTOR = `[${NO_DRAG_DATA_ATTR}]`
+
+/**
+ * Elements that need to keep browser-native pointer/touch behavior and must not
+ * participate in drawer dragging (e.g. native range sliders, text fields, selection).
+ */
+export function isDragExemptElement(el: EventTarget | null | undefined): boolean {
+ if (!isHTMLElement(el)) return false
+ if (el.closest(NO_DRAG_SELECTOR)) return true
+
+ let node: HTMLElement | null = el
+ while (node) {
+ if (isEditableElement(node)) return true
+ node = node.parentElement
+ }
+
+ const input = el.closest("input")
+ if (isInputElement(input)) {
+ const type = input.type
+ if (type === "range" || type === "file") return true
+ }
+ return false
+}
+
+/**
+ * Non-collapsed selection whose range or caret endpoints live inside the drawer content
+ * (including shadow roots hosted under the content node).
+ */
+export function isTextSelectionInDrawer(doc: Document, contentEl: HTMLElement | null): boolean {
+ if (!contentEl) return false
+ const sel = doc.getSelection()
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return false
+ try {
+ const range = sel.getRangeAt(0)
+ if (contains(contentEl, range.commonAncestorContainer)) return true
+ if (contains(contentEl, sel.anchorNode)) return true
+ if (contains(contentEl, sel.focusNode)) return true
+ if (typeof range.intersectsNode === "function" && range.intersectsNode(contentEl)) {
+ return true
+ }
+ } catch {
+ return false
+ }
+ return false
+}
+
+export function isDragExemptFromComposedPath(event: {
+ target: EventTarget | null
+ composedPath?: () => EventTarget[]
+}): boolean {
+ const path = typeof event.composedPath === "function" ? event.composedPath() : []
+ for (const node of path) {
+ if (isDragExemptElement(node)) return true
+ }
+ return isDragExemptElement(event.target)
+}
+
+/** True when this pointer down should not arm drawer drag (wrong button, no-drag, or exempt target). */
+export function shouldIgnorePointerDownForDrag(event: JSX.PointerEvent): boolean {
+ if (!isLeftClick(event)) return true
+ const target = getEventTarget(event)
+ if (target?.hasAttribute(NO_DRAG_DATA_ATTR) || target?.closest(NO_DRAG_SELECTOR)) return true
+ if (isDragExemptFromComposedPath(event)) return true
+ return false
+}
diff --git a/packages/machines/drawer/src/utils/release-velocity.ts b/packages/machines/drawer/src/utils/release-velocity.ts
new file mode 100644
index 0000000000..4339ff13bb
--- /dev/null
+++ b/packages/machines/drawer/src/utils/release-velocity.ts
@@ -0,0 +1,52 @@
+/** Min displacement (px) from rest snap before we trust it over a brief opposing velocity tick. */
+const RELEASE_DISPLACEMENT_TRUST_PX = 24
+const OPEN_SWIPE_HIDDEN_VISIBLE_RATIO = 0.22
+const OPEN_SWIPE_HIDDEN_VELOCITY_MULTIPLIER = 1.25
+const OPEN_SWIPE_REVEALED_VISIBLE_RATIO = 0.5
+const OPEN_SWIPE_REVEALED_OPPOSING_MAX_ABS_VELOCITY = 650
+
+/**
+ * When the user clearly moved the sheet one way, ignore a short opposing velocity sample
+ * (coalesced / rubber-band touch end, etc.).
+ */
+export function adjustReleaseVelocityAgainstDisplacement(velocity: number, displacementFromSnap: number): number {
+ const dSign = Math.sign(displacementFromSnap)
+ const vSign = Math.sign(velocity)
+ if (
+ dSign !== 0 &&
+ Math.abs(displacementFromSnap) >= RELEASE_DISPLACEMENT_TRUST_PX &&
+ vSign !== 0 &&
+ vSign !== dSign
+ ) {
+ return 0
+ }
+ return velocity
+}
+
+/**
+ * Swipe-open gesture: damp spurious velocity when the sheet is still almost fully off-screen
+ * or nearly committed open.
+ */
+export function adjustReleaseVelocityForOpenSwipe(
+ velocity: number,
+ visibleRatio: number,
+ swipeVelocityThreshold: number,
+): number {
+ // Still mostly hidden: ignore moderate “toward open” velocity spikes
+ if (
+ visibleRatio < OPEN_SWIPE_HIDDEN_VISIBLE_RATIO &&
+ velocity < 0 &&
+ Math.abs(velocity) < swipeVelocityThreshold * OPEN_SWIPE_HIDDEN_VELOCITY_MULTIPLIER
+ ) {
+ return 0
+ }
+ // Largely revealed: small opposing velocity is usually noise
+ if (
+ visibleRatio > OPEN_SWIPE_REVEALED_VISIBLE_RATIO &&
+ velocity > 0 &&
+ Math.abs(velocity) < OPEN_SWIPE_REVEALED_OPPOSING_MAX_ABS_VELOCITY
+ ) {
+ return 0
+ }
+ return velocity
+}
diff --git a/packages/machines/drawer/src/utils/resolve-snap-point.ts b/packages/machines/drawer/src/utils/resolve-snap-point.ts
deleted file mode 100644
index 1272531ca1..0000000000
--- a/packages/machines/drawer/src/utils/resolve-snap-point.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { ResolvedSnapPoint, SnapPoint } from "../drawer.types"
-
-function clamp(value: number, min: number, max: number) {
- return Math.min(Math.max(value, min), max)
-}
-
-export interface ResolveSnapPointOptions {
- popupSize: number
- viewportSize: number
- rootFontSize: number
-}
-
-function resolveSnapPointValue(snapPoint: SnapPoint, viewportSize: number, rootFontSize: number): number | null {
- if (!Number.isFinite(viewportSize) || viewportSize <= 0) return null
-
- if (typeof snapPoint === "number") {
- if (!Number.isFinite(snapPoint)) return null
- if (snapPoint <= 1) return clamp(snapPoint, 0, 1) * viewportSize
- return snapPoint
- }
-
- const trimmed = snapPoint.trim()
-
- if (trimmed.endsWith("px")) {
- const value = Number.parseFloat(trimmed)
- return Number.isFinite(value) ? value : null
- }
-
- if (trimmed.endsWith("rem")) {
- const value = Number.parseFloat(trimmed)
- return Number.isFinite(value) ? value * rootFontSize : null
- }
-
- return null
-}
-
-export function resolveSnapPoint(snapPoint: SnapPoint, options: ResolveSnapPointOptions): ResolvedSnapPoint | null {
- const { popupSize, viewportSize, rootFontSize } = options
- const maxSize = Math.min(popupSize, viewportSize)
- if (!Number.isFinite(maxSize) || maxSize <= 0) return null
-
- const resolvedSize = resolveSnapPointValue(snapPoint, viewportSize, rootFontSize)
- if (resolvedSize === null || !Number.isFinite(resolvedSize)) return null
-
- const height = clamp(resolvedSize, 0, maxSize)
- return {
- value: snapPoint,
- height,
- offset: Math.max(0, popupSize - height),
- }
-}
diff --git a/packages/machines/drawer/src/utils/snap-point.ts b/packages/machines/drawer/src/utils/snap-point.ts
new file mode 100644
index 0000000000..599761f8c1
--- /dev/null
+++ b/packages/machines/drawer/src/utils/snap-point.ts
@@ -0,0 +1,80 @@
+import { clampValue } from "@zag-js/utils"
+import type { ResolvedSnapPoint, SnapPoint } from "../drawer.types"
+
+export interface ResolveSnapPointOptions {
+ contentSize: number
+ viewportSize: number
+ rootFontSize: number
+}
+
+function resolveSnapPointValue(snapPoint: SnapPoint, viewportSize: number, rootFontSize: number): number | null {
+ if (!Number.isFinite(viewportSize) || viewportSize <= 0) return null
+
+ if (typeof snapPoint === "number") {
+ if (!Number.isFinite(snapPoint)) return null
+ if (snapPoint <= 1) return clampValue(snapPoint, 0, 1) * viewportSize
+ return snapPoint
+ }
+
+ const trimmed = snapPoint.trim()
+
+ if (trimmed.endsWith("px")) {
+ const value = Number.parseFloat(trimmed)
+ return Number.isFinite(value) ? value : null
+ }
+
+ if (trimmed.endsWith("rem")) {
+ const value = Number.parseFloat(trimmed)
+ return Number.isFinite(value) ? value * rootFontSize : null
+ }
+
+ return null
+}
+
+export function resolveSnapPoint(snapPoint: SnapPoint, options: ResolveSnapPointOptions): ResolvedSnapPoint | null {
+ const { contentSize, viewportSize, rootFontSize } = options
+ const maxSize = Math.min(contentSize, viewportSize)
+ if (!Number.isFinite(maxSize) || maxSize <= 0) return null
+
+ const resolvedSize = resolveSnapPointValue(snapPoint, viewportSize, rootFontSize)
+ if (resolvedSize === null || !Number.isFinite(resolvedSize)) return null
+
+ const height = clampValue(resolvedSize, 0, maxSize)
+ return {
+ value: snapPoint,
+ height,
+ offset: Math.max(0, contentSize - height),
+ }
+}
+
+const HEIGHT_DEDUP_EPSILON_PX = 1
+
+/**
+ * Collapse snap points that resolve to the same height (within 1px), keeping the first occurrence in list order.
+ */
+export function dedupeSnapPoints(points: ResolvedSnapPoint[]): ResolvedSnapPoint[] {
+ if (points.length <= 1) return points
+
+ const deduped: ResolvedSnapPoint[] = []
+ const seenHeights: number[] = []
+
+ for (let index = points.length - 1; index >= 0; index -= 1) {
+ const point = points[index]
+ const isDuplicate = seenHeights.some((height) => Math.abs(height - point.height) <= HEIGHT_DEDUP_EPSILON_PX)
+ if (isDuplicate) continue
+
+ seenHeights.push(point.height)
+ deduped.push(point)
+ }
+
+ deduped.reverse()
+ return deduped
+}
+
+export function findClosestSnapPoint(offset: number, snapPoints: ResolvedSnapPoint[]): ResolvedSnapPoint {
+ return snapPoints.reduce((acc, curr) => {
+ const closestDiff = Math.abs(offset - acc.offset)
+ const currentDiff = Math.abs(offset - curr.offset)
+ return currentDiff < closestDiff ? curr : acc
+ })
+}
diff --git a/packages/machines/drawer/src/utils/swipe.ts b/packages/machines/drawer/src/utils/swipe.ts
new file mode 100644
index 0000000000..12b1dd59ed
--- /dev/null
+++ b/packages/machines/drawer/src/utils/swipe.ts
@@ -0,0 +1,51 @@
+import { clampValue } from "@zag-js/utils"
+import type { Point } from "@zag-js/types"
+import type { SwipeDirection } from "../drawer.types"
+
+/** Swipe-area → swiping-open pointer intent (px). */
+const SWIPE_AREA_OPEN_INTENT_MIN_PX = 5
+
+/** Swipe axis is vertical (up/down) vs horizontal (left/right). */
+export function isVerticalSwipeDirection(direction: SwipeDirection): boolean {
+ return direction === "down" || direction === "up"
+}
+
+/** Dismiss direction uses negative offset along the swipe axis (up / left). */
+export function isNegativeSwipeDirection(direction: SwipeDirection): boolean {
+ return direction === "up" || direction === "left"
+}
+
+export const oppositeSwipeDirection: Record = {
+ up: "down",
+ down: "up",
+ left: "right",
+ right: "left",
+}
+
+/** Content size along the active swipe axis from a border box. */
+export function getSwipeDirectionSize(rect: DOMRect, direction: SwipeDirection): number {
+ return isVerticalSwipeDirection(direction) ? rect.height : rect.width
+}
+
+/** Normalized swipe progress (0–1) for stack UI from content size and offsets. */
+export function resolveSwipeProgress(
+ contentSize: number | null,
+ dragOffset: number | null,
+ snapPointOffset: number,
+): number {
+ if (!contentSize || contentSize <= 0) return 0
+ const currentOffset = dragOffset ?? snapPointOffset
+ return clampValue(1 - currentOffset / contentSize, 0, 1)
+}
+
+/**
+ * True when pointer moved far enough in the swipe-open direction from `start` to `current`
+ * (used for swipe-area → swiping-open transition).
+ */
+export function hasOpeningSwipeIntent(start: Point, current: Point, direction: SwipeDirection): boolean {
+ const isVertical = isVerticalSwipeDirection(direction)
+ const sign = isNegativeSwipeDirection(direction) ? -1 : 1
+ const axis = isVertical ? "y" : "x"
+ const displacement = (start[axis] - current[axis]) * sign
+ return displacement > SWIPE_AREA_OPEN_INTENT_MIN_PX
+}
diff --git a/packages/utilities/dom-query/src/node.ts b/packages/utilities/dom-query/src/node.ts
index ac7ce17a9f..832dd2537d 100644
--- a/packages/utilities/dom-query/src/node.ts
+++ b/packages/utilities/dom-query/src/node.ts
@@ -63,17 +63,18 @@ export function isEditableElement(el: HTMLElement | EventTarget | null) {
type Target = HTMLElement | EventTarget | null | undefined
+/** Whether `parent` contains `child` in the light tree, or `child` is inside a shadow tree hosted under `parent`. */
export function contains(parent: Target, child: Target) {
if (!parent || !child) return false
- if (!isHTMLElement(parent) || !isHTMLElement(child)) return false
- const rootNode = child.getRootNode?.()
- if (parent === child) return true
+ if (!isHTMLElement(parent) || !isNode(child)) return false
+ if (isHTMLElement(child) && parent === child) return true
if (parent.contains(child)) return true
+ const rootNode = child.getRootNode?.()
if (rootNode && isShadowRoot(rootNode)) {
- let next = child
+ let next: Node | null = child
while (next) {
if (parent === next) return true
- // @ts-ignore
+ // @ts-ignore ShadowRoot has host
next = next.parentNode || next.host
}
}
diff --git a/shared/src/controls.ts b/shared/src/controls.ts
index b3e8463ee6..86cc7f68f2 100644
--- a/shared/src/controls.ts
+++ b/shared/src/controls.ts
@@ -358,8 +358,8 @@ export const passwordInputControls = defineControls({
})
export const drawerControls = defineControls({
- swipeVelocityThreshold: { type: "number", defaultValue: 700 },
- closeThreshold: { type: "number", defaultValue: 0.25 },
+ swipeVelocityThreshold: { type: "number", defaultValue: 500 },
+ closeThreshold: { type: "number", defaultValue: 0.5 },
preventDragOnScroll: { type: "boolean", defaultValue: true },
swipeDirection: { type: "select", options: ["down", "up", "left", "right"] as const, defaultValue: "down" },
snapToSequentialPoints: { type: "boolean", defaultValue: false },
diff --git a/shared/src/routes.ts b/shared/src/routes.ts
index 47f7006e6e..95eb043644 100644
--- a/shared/src/routes.ts
+++ b/shared/src/routes.ts
@@ -44,6 +44,13 @@ export const componentRoutes: ComponentRoute[] = [
{ slug: "snap-points", title: "Snap Points" },
{ slug: "default-active-snap-point", title: "Active Snap Point" },
{ slug: "indent-background", title: "Indent Background" },
+ { slug: "swipe-area", title: "Swipe Area" },
+ { slug: "cross-axis-scroll", title: "Cross-Axis Scroll" },
+ { slug: "range-input", title: "Range Input" },
+ { slug: "action-sheet", title: "Action Sheet" },
+ { slug: "controlled", title: "Controlled" },
+ { slug: "mobile-nav", title: "Mobile Nav" },
+ { slug: "non-modal", title: "Non-Modal" },
],
},
{