@@ -115,6 +115,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
115115
116116 const { width : windowWidth , height : windowHeight } = useWindowDimensions ( ) ;
117117 const isLandscapeOrTablet = windowWidth >= 600 || windowWidth > windowHeight ;
118+ const isFormSheet = isLandscapeOrTablet && presentation === 'form' ;
118119
119120 // presentation='form' implies a floating/detached sheet on web — mirrors iOS
120121 // form-sheet semantics where the sheet is never edge-attached.
@@ -483,7 +484,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
483484 methodsRef ,
484485 drawerContentRef ,
485486 isOpen ,
486- isLandscapeOrTablet && presentation === 'form'
487+ isFormSheet
487488 ) ;
488489 dismissAboveRef . current = dismissAbove ;
489490
@@ -497,14 +498,31 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
497498
498499 const transition = `transform ${ TRANSITIONS . DURATION } s cubic-bezier(${ TRANSITIONS . EASE . join ( ',' ) } )` ;
499500 const wrapperTransition = `clip-path ${ TRANSITIONS . DURATION } s cubic-bezier(${ TRANSITIONS . EASE . join ( ',' ) } )` ;
501+ const CLIP_NONE = 'inset(0px round 0px)' ;
502+
503+ // Animate clip-path on the wrapper. Dedupes via DOM read so repeat ticks
504+ // (mutation observer) skip identical writes. Seeds CLIP_NONE before the
505+ // first inset() so the browser interpolates between two inset() shapes —
506+ // `none → inset()` interpolates inconsistently.
507+ const setClip = ( next : string ) => {
508+ if ( ! parentWrapper ) return ;
509+ const current = parentWrapper . style . clipPath ;
510+ if ( current === next ) return ;
511+ if ( next === CLIP_NONE && ! current ) return ;
512+ if ( ! current ) {
513+ parentWrapper . style . transition = '' ;
514+ parentWrapper . style . clipPath = CLIP_NONE ;
515+ // eslint-disable-next-line no-void
516+ void parentWrapper . offsetHeight ;
517+ }
518+ parentWrapper . style . transition = wrapperTransition ;
519+ parentWrapper . style . clipPath = next ;
520+ } ;
500521
501522 if ( descendants . length === 0 ) {
502523 parent . style . transition = transition ;
503524 parent . style . transform = '' ;
504- if ( parentWrapper && parentWrapper . style . clipPath ) {
505- parentWrapper . style . transition = wrapperTransition ;
506- parentWrapper . style . clipPath = 'inset(0px round 0px)' ;
507- }
525+ setClip ( CLIP_NONE ) ;
508526 return ;
509527 }
510528
@@ -520,36 +538,22 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
520538 return targetY ;
521539 } ;
522540
523- // When a form-sheet descendant is present, clip the parent to the form
524- // card's viewport box so the parts that would peek above/around the card
525- // are hidden. Form-card box is derived from the child's inline styles
526- // (vs. getBoundingClientRect) so it's the at-rest box, not skewed by
527- // vaul's wrapper slide-in. Insets are relative to the parent wrapper's
528- // rect — the parent wrapper isn't necessarily viewport-sized (e.g., it
529- // has DEFAULT_MAX_WIDTH on tablet/landscape).
530- // Clip-path transitions with the same duration/easing as the cascade
531- // transform so the visible box and the drawer's content slide together;
532- // otherwise the content animates against a static wrapper boundary.
533- // We seed clip-path from `inset(0)` (= no clip) before setting the target
534- // inset so the browser interpolates between two inset() shapes — going
535- // from `none` directly to inset() is interpolated inconsistently.
541+ // When a form-sheet descendant opens, clip the parent to the child card's
542+ // viewport box so it doesn't peek above/around. Geometry comes from the
543+ // child's inline styles (not getBoundingClientRect) to read the at-rest
544+ // box, unskewed by vaul's slide-in.
536545 const applyFormClip = ( ) => {
537- if ( ! parentWrapper ) return ;
538546 const form = descendants . find ( ( d ) => d . isFormSheetRef . current ) ;
539547 if ( ! form ) {
540- if ( parentWrapper . style . clipPath ) {
541- parentWrapper . style . transition = wrapperTransition ;
542- parentWrapper . style . clipPath = 'inset(0px round 0px)' ;
543- }
548+ setClip ( CLIP_NONE ) ;
544549 return ;
545550 }
546551 const childDrawer = form . nodeRef . current ;
547552 const childWrapper = childDrawer ?. closest < HTMLElement > ( '[data-vaul-detached-wrapper]' ) ;
548- if ( ! childDrawer || ! childWrapper ) return ;
553+ if ( ! parentWrapper || ! childDrawer || ! childWrapper ) return ;
549554 const snapY = parseFloat ( childDrawer . style . getPropertyValue ( '--snap-point-height' ) ) || 0 ;
550555 const childBottomGap = parseFloat ( childWrapper . style . bottom ) || 0 ;
551- const childMaxW =
552- parseFloat ( childWrapper . style . maxWidth ) || window . innerWidth ;
556+ const childMaxW = parseFloat ( childWrapper . style . maxWidth ) || window . innerWidth ;
553557 const formLeft = ( window . innerWidth - childMaxW ) / 2 ;
554558 const formRight = ( window . innerWidth + childMaxW ) / 2 ;
555559 const formBottom = window . innerHeight - childBottomGap ;
@@ -558,29 +562,27 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
558562 const left = Math . max ( 0 , formLeft - rect . left ) ;
559563 const right = Math . max ( 0 , rect . right - formRight ) ;
560564 const bottom = Math . max ( 0 , rect . bottom - formBottom ) ;
561- // Seed inset(0 round 0) so the first transition interpolates between
562- // two inset() shapes with matching `round` values (a missing `round`
563- // would interpolate inconsistently).
564- if ( ! parentWrapper . style . clipPath ) {
565- parentWrapper . style . transition = '' ;
566- parentWrapper . style . clipPath = 'inset(0px round 0px)' ;
567- // eslint-disable-next-line no-void
568- void parentWrapper . offsetHeight ;
569- }
570- parentWrapper . style . transition = wrapperTransition ;
571565 const radius = cornerRadius ?? DEFAULT_CORNER_RADIUS ;
572- parentWrapper . style . clipPath = `inset(${ top } px ${ right } px ${ bottom } px ${ left } px round ${ radius } px)` ;
566+ setClip ( `inset(${ top } px ${ right } px ${ bottom } px ${ left } px round ${ radius } px)` ) ;
573567 } ;
574568
575569 const apply = ( ) => {
570+ applyFormClip ( ) ;
571+ // Mirror iOS: a page-sheet child fully covers a form-sheet parent, so the
572+ // cascade push-down has no visible effect — and would briefly peek above
573+ // the page during the present animation. Leave the parent put.
574+ const child = descendants [ 0 ] ;
575+ if ( isFormSheet && child && ! child . isFormSheetRef . current ) {
576+ parent . style . transition = transition ;
577+ parent . style . transform = '' ;
578+ return ;
579+ }
576580 const targetY = computeTargetY ( ) ;
577581 const match = parent . style . transform . match ( / t r a n s l a t e 3 d \( [ ^ , ] * , \s * ( - ? \d * \. ? \d + ) p x / ) ;
578582 const currentY = match ? parseFloat ( match [ 1 ] ! ) : 0 ;
579- if ( Math . abs ( currentY - targetY ) >= 0.5 ) {
580- parent . style . transition = transition ;
581- parent . style . transform = `translate3d(0, ${ targetY } px, 0)` ;
582- }
583- applyFormClip ( ) ;
583+ if ( Math . abs ( currentY - targetY ) < 0.5 ) return ;
584+ parent . style . transition = transition ;
585+ parent . style . transform = `translate3d(0, ${ targetY } px, 0)` ;
584586 } ;
585587
586588 const raf = requestAnimationFrame ( apply ) ;
@@ -594,7 +596,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
594596 cancelAnimationFrame ( raf ) ;
595597 observer . disconnect ( ) ;
596598 } ;
597- } , [ descendants , activeSnapPoint ] ) ;
599+ } , [ descendants , activeSnapPoint , cornerRadius , isFormSheet ] ) ;
598600
599601 // Focus/blur events fire when a descendant sheet appears on top of this one
600602 // (blur) or when all descendants are dismissed (focus). will-events fire
@@ -723,7 +725,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
723725 // with a computed offset that centers it vertically. `presentation` is
724726 // absolute: when 'form', `maxContentWidth` is ignored and the card uses
725727 // DEFAULT_FORM_SHEET_WIDTH.
726- const isFormSheet = isLandscapeOrTablet && presentation === 'form' ;
727728
728729 // Vaul measures the auto-size wrapper's offsetHeight (always, post fork).
729730 // Track it here so the form sheet can size its card to fit content,
0 commit comments