33import React from 'react' ;
44
55import * as DialogPrimitive from '@radix-ui/react-dialog' ;
6+ import { Presence } from '@radix-ui/react-presence' ;
67
78import { DrawerContext , useDrawerContext } from './context' ;
89import './style.css' ;
@@ -239,19 +240,6 @@ export function Root({
239240 setTimeout ( ( ) => {
240241 onAnimationEnd ?.( o ) ;
241242 } , TRANSITIONS . DURATION * 1000 ) ;
242-
243- if ( o && ! modal ) {
244- if ( typeof window !== 'undefined' ) {
245- window . requestAnimationFrame ( ( ) => {
246- document . body . style . pointerEvents = 'auto' ;
247- } ) ;
248- }
249- }
250-
251- if ( ! o ) {
252- // This will be removed when the exit animation ends (`500ms`)
253- document . body . style . pointerEvents = 'auto' ;
254- }
255243 } ,
256244 } ) ;
257245 const [ hasBeenOpened , setHasBeenOpened ] = React . useState < boolean > ( false ) ;
@@ -566,88 +554,6 @@ export function Root({
566554 onPositionChangeRef . current = onPositionChange ;
567555 } ) ;
568556
569- // Radix's DismissableLayer (modal) sets body pointer-events to "none" so the
570- // overlay traps clicks. When the active snap is below fadeFromIndex the
571- // overlay is fully transparent, and we want clicks to reach the content
572- // behind the drawer — so we restore body interactivity. DismissableLayer
573- // captures its DOM node via a ref-callback `setNode`, so its body write
574- // runs one render later than ours on initial open — re-assert on rAF so
575- // the final write is ours.
576- React . useEffect ( ( ) => {
577- if ( ! modal || ! snapPoints || fadeFromIndex === undefined ) return ;
578- // On close, restore body interactivity. `useControllableState.onChange`
579- // only fires for internal `setIsOpen` calls — when the parent flips the
580- // `open` prop imperatively, the reset at line ~201 never runs.
581- if ( ! isOpen ) {
582- document . body . style . pointerEvents = 'auto' ;
583- return ;
584- }
585- if ( typeof activeSnapPointIndex !== 'number' ) return ;
586- const isBelowFade = activeSnapPointIndex < fadeFromIndex ;
587- const value = isBelowFade ? 'auto' : 'none' ;
588- document . body . style . pointerEvents = value ;
589- const id = window . requestAnimationFrame ( ( ) => {
590- document . body . style . pointerEvents = value ;
591- } ) ;
592-
593- // React Navigation keeps unfocused screens mounted with `display: none` on
594- // web, so the drawer never unmounts and neither `isOpen` nor this effect's
595- // deps change — a lingering `none` body style would then leak to whichever
596- // screen becomes visible. Observe the drawer: when an ancestor hides it,
597- // restore `auto`; when it returns, re-apply the desired value.
598- let observer : IntersectionObserver | null = null ;
599- let rafId = 0 ;
600- const startObserving = ( ) => {
601- const node = drawerRef . current ;
602- if ( ! node ) {
603- rafId = window . requestAnimationFrame ( startObserving ) ;
604- return ;
605- }
606- observer = new IntersectionObserver ( ( entries ) => {
607- const entry = entries [ entries . length - 1 ] ;
608- if ( ! entry ) return ;
609- document . body . style . pointerEvents = entry . isIntersecting ? value : 'auto' ;
610- } ) ;
611- observer . observe ( node ) ;
612- } ;
613- rafId = window . requestAnimationFrame ( startObserving ) ;
614-
615- // Always restore on cleanup so an unmount while the sheet is still open
616- // doesn't leave the page uninteractive. If the effect re-runs for a
617- // normal state change, it re-applies the correct value.
618- return ( ) => {
619- window . cancelAnimationFrame ( id ) ;
620- window . cancelAnimationFrame ( rafId ) ;
621- observer ?. disconnect ( ) ;
622- document . body . style . pointerEvents = 'auto' ;
623- } ;
624- } , [ isOpen , modal , snapPoints , fadeFromIndex , activeSnapPointIndex ] ) ;
625-
626- // Radix DismissableLayer's body-restore on unmount is broken: its two
627- // useEffects (see @radix-ui/react-dismissable-layer dist/index.mjs:68-92)
628- // run cleanups in reverse declaration order, so the layers-set decrement
629- // (effect B) runs BEFORE the size-1 body-restore check (effect A). When
630- // the last layer unmounts, A sees size=0 and skips the restore — body
631- // stays 'none'. Take ownership of the restore here on Drawer.Root unmount.
632- // Skipped when nested or non-modal so an inner drawer's unmount doesn't
633- // unlock an outer drawer's modal trap. Also bail if any other Radix
634- // dismissable layer is still open (sibling Dialog/AlertDialog/Popover) —
635- // that one wants the lock kept.
636- React . useEffect ( ( ) => {
637- if ( nested || ! modal ) return ;
638- return ( ) => {
639- if ( typeof document === 'undefined' ) return ;
640- if ( document . body . style . pointerEvents !== 'none' ) return ;
641- if (
642- document . querySelector (
643- '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'
644- )
645- )
646- return ;
647- document . body . style . pointerEvents = 'auto' ;
648- } ;
649- } , [ nested , modal ] ) ;
650-
651557 React . useEffect ( ( ) => {
652558 function onVisualViewportChange ( ) {
653559 if ( ! drawerRef . current || ! repositionInputs ) return ;
@@ -916,15 +822,6 @@ export function Root({
916822 }
917823 }
918824
919- React . useEffect ( ( ) => {
920- if ( ! modal ) {
921- // Need to do this manually unfortunately
922- window . requestAnimationFrame ( ( ) => {
923- document . body . style . pointerEvents = 'auto' ;
924- } ) ;
925- }
926- } , [ modal ] ) ;
927-
928825 return (
929826 < DialogPrimitive . Root
930827 defaultOpen = { defaultOpen }
@@ -939,7 +836,14 @@ export function Root({
939836 setIsOpen ( open ) ;
940837 } }
941838 open = { isOpen }
942- modal = { modal }
839+ // Always non-modal at the Radix level — Radix's modal mode locks
840+ // body.style.pointerEvents and its restore on layer unmount is buggy
841+ // (see git history). Vaul's own overlay handles click-trapping; the
842+ // overlay sets pointer-events: none below fadeFromIndex so clicks
843+ // pass through to the page (matches native dimmedDetentIndex). Vaul's
844+ // own `modal` prop still drives overlay rendering and click-outside
845+ // behavior below.
846+ modal = { false }
943847 >
944848 < DrawerContext . Provider
945849 value = { {
@@ -1035,26 +939,38 @@ export const Overlay = React.forwardRef<
1035939 typeof activeSnapPointIndex === 'number' &&
1036940 activeSnapPointIndex < fadeFromIndex ;
1037941
942+ // Render our own overlay element instead of `DialogPrimitive.Overlay`:
943+ // Radix's Overlay returns null when the Dialog is non-modal, and we run
944+ // Radix as `modal={false}` to keep it from locking body pointer-events.
945+ // `Presence` keeps the node mounted through the closing animation
946+ // (`fadeOut`) before unmounting.
1038947 return (
1039- < DialogPrimitive . Overlay
1040- onMouseUp = { onMouseUp }
1041- ref = { composedRef }
1042- data-vaul-overlay = ""
1043- data-vaul-snap-points = { isOpen && hasSnapPoints ? 'true' : 'false' }
1044- data-vaul-delayed-snap-points = { delayedSnapPoints ? 'true' : 'false' }
1045- data-vaul-snap-points-overlay = { isOpen && shouldFade ? 'true' : 'false' }
1046- data-vaul-animate = { shouldAnimate ?. current ? 'true' : 'false' }
1047- { ...rest }
1048- style = { isBelowFade ? overlayBelowFadeStyle ( style ) : style }
1049- />
948+ < Presence present = { isOpen } >
949+ < div
950+ onMouseUp = { onMouseUp }
951+ ref = { composedRef }
952+ data-state = { isOpen ? 'open' : 'closed' }
953+ data-vaul-overlay = ""
954+ data-vaul-snap-points = { isOpen && hasSnapPoints ? 'true' : 'false' }
955+ data-vaul-delayed-snap-points = { delayedSnapPoints ? 'true' : 'false' }
956+ data-vaul-snap-points-overlay = { isOpen && shouldFade ? 'true' : 'false' }
957+ data-vaul-animate = { shouldAnimate ?. current ? 'true' : 'false' }
958+ { ...rest }
959+ style = { overlayStyle ( style , ! ! isBelowFade ) }
960+ />
961+ </ Presence >
1050962 ) ;
1051963} ) ;
1052964
1053965Overlay . displayName = 'Drawer.Overlay' ;
1054966
1055- const overlayPointerEventsNone : React . CSSProperties = { pointerEvents : 'none' } ;
1056- const overlayBelowFadeStyle = ( style : React . CSSProperties | undefined ) : React . CSSProperties =>
1057- style ? { ...style , ...overlayPointerEventsNone } : overlayPointerEventsNone ;
967+ const overlayStyle = (
968+ style : React . CSSProperties | undefined ,
969+ isBelowFade : boolean
970+ ) : React . CSSProperties => ( {
971+ ...style ,
972+ pointerEvents : isBelowFade ? 'none' : 'auto' ,
973+ } ) ;
1058974
1059975export type ContentProps = React . ComponentPropsWithoutRef < typeof DialogPrimitive . Content > & {
1060976 /**
@@ -1385,10 +1301,12 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
13851301 }
13861302 } }
13871303 onFocusOutside = { ( e ) => {
1388- if ( ! modal || isBelowFade ) {
1389- e . preventDefault ( ) ;
1390- return ;
1391- }
1304+ // Never auto-dismiss the sheet on focus shifts. Without Radix's
1305+ // FocusScope trap (we run modal={false}), this fires whenever
1306+ // focus naturally leaves the drawer — e.g. React Navigation's
1307+ // web driver hides the previous screen via display:none, which
1308+ // moves focus off the trigger and dismisses the sheet.
1309+ e . preventDefault ( ) ;
13921310 } }
13931311 onPointerMove = { ( event ) => {
13941312 lastKnownPointerEventRef . current = event ;
0 commit comments