@@ -4,7 +4,9 @@ import { X } from 'lucide-react';
44import React from 'react' ;
55
66import { Button } from './button' ;
7+ import { useAnimatedAutoHeight } from '../lib/use-animated-auto-height' ;
78import { usePortalContainer } from '../lib/use-portal-container' ;
9+ import { useScrollShadow } from '../lib/use-scroll-shadow' ;
810import {
911 asChildTrigger ,
1012 narrowOpenChange ,
@@ -59,10 +61,9 @@ function DialogClose({ ...props }: DialogCloseProps) {
5961function DialogOverlay ( { className, ...props } : React . ComponentProps < typeof DialogPrimitive . Backdrop > ) {
6062 return (
6163 < DialogPrimitive . Backdrop
62- // fill-mode-forwards holds the exit keyframe until Base UI unmounts;
63- // without it the backdrop flashes back to its natural opacity for one frame.
6464 className = { cn (
65- 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40 fill-mode-forwards backdrop-blur-xs data-[state=closed]:animate-out data-[state=open]:animate-in' ,
65+ 'fixed inset-0 z-50 bg-black/40 backdrop-blur-xs transition-opacity duration-200 ease-out' ,
66+ 'data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none' ,
6667 className
6768 ) }
6869 data-slot = "dialog-overlay"
@@ -72,8 +73,9 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
7273 ) ;
7374}
7475
76+ // `height` is intentionally omitted from the transition list — useAnimatedAutoHeight drives it.
7577const dialogContentVariants = cva (
76- 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex max-h-[85vh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded-xl border bg-background fill-mode-forwards shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in ' ,
78+ 'fixed top-[50%] left-[50%] z-50 flex w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded-xl border bg-background shadow-lg transition-[opacity,transform,max-height,min-height] duration-200 ease-out data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none ' ,
7779 {
7880 variants : {
7981 size : {
@@ -88,10 +90,18 @@ const dialogContentVariants = cva(
8890 centered : 'text-center' ,
8991 destructive : 'border-destructive/50' ,
9092 } ,
93+ height : {
94+ auto : 'max-h-[85vh]' ,
95+ sm : 'h-[min(85vh,400px)]' ,
96+ md : 'h-[min(85vh,560px)]' ,
97+ lg : 'h-[min(85vh,720px)]' ,
98+ xl : 'h-[min(85vh,880px)]' ,
99+ } ,
91100 } ,
92101 defaultVariants : {
93102 size : 'md' ,
94103 variant : 'standard' ,
104+ height : 'auto' ,
95105 } ,
96106 }
97107) ;
@@ -111,6 +121,7 @@ function DialogContent({
111121 showOverlay = true ,
112122 size,
113123 variant,
124+ height,
114125 testId,
115126 container,
116127 onOpenAutoFocus,
@@ -123,13 +134,16 @@ function DialogContent({
123134 'Use `initialFocus` on Base UI `Dialog.Popup` instead.'
124135 ) ;
125136 const portalContainer = usePortalContainer ( ) ;
137+ const isAutoHeight = ! height || height === 'auto' ;
138+ const setPopupRef = useAnimatedAutoHeight < HTMLDivElement > ( isAutoHeight ) ;
126139 return (
127140 < DialogPortal container = { container ?? portalContainer } >
128141 { showOverlay ? < DialogOverlay /> : null }
129142 < DialogPrimitive . Popup
130- className = { cn ( dialogContentVariants ( { size, variant } ) , className ) }
143+ className = { cn ( dialogContentVariants ( { size, variant, height } ) , className ) }
131144 data-slot = "dialog-content"
132145 data-testid = { testId }
146+ ref = { setPopupRef }
133147 render = { renderWithDataState ( 'div' ) }
134148 { ...props }
135149 >
@@ -249,9 +263,17 @@ function DialogDescription({
249263 ) ;
250264}
251265
252- // min-h-0 lets the body shrink below its natural height so overflow-y-auto scrolls.
253- const dialogBodyVariants = cva ( 'min-h-0 flex-1 overflow-y-auto p-4' , {
266+ // Padding lives on the inner wrapper so scroll shadows can sit flush against the body edges.
267+ const dialogBodyContainerVariants = cva ( 'relative min-h-0 flex-1 overflow-y-auto' ) ;
268+
269+ const dialogBodyContentVariants = cva ( '' , {
254270 variants : {
271+ padding : {
272+ none : '' ,
273+ sm : 'p-2' ,
274+ md : 'p-4' ,
275+ lg : 'p-6' ,
276+ } ,
255277 spacing : {
256278 none : '' ,
257279 sm : 'space-y-2' ,
@@ -260,14 +282,58 @@ const dialogBodyVariants = cva('min-h-0 flex-1 overflow-y-auto p-4', {
260282 } ,
261283 } ,
262284 defaultVariants : {
285+ padding : 'md' ,
263286 spacing : 'md' ,
264287 } ,
265288} ) ;
266289
267- interface DialogBodyProps extends React . ComponentProps < 'div' > , VariantProps < typeof dialogBodyVariants > { }
290+ interface DialogBodyProps extends React . ComponentProps < 'div' > , VariantProps < typeof dialogBodyContentVariants > {
291+ /** Show fading top/bottom shadows when the body overflows. Defaults to `true`. */
292+ scrollShadow ?: boolean ;
293+ }
294+
295+ function DialogBody ( { className, padding, spacing, scrollShadow = true , children, style, ...props } : DialogBodyProps ) {
296+ const { containerRef, topRef, bottomRef, edges } = useScrollShadow < HTMLDivElement > ( scrollShadow ) ;
268297
269- function DialogBody ( { className, spacing, ...props } : DialogBodyProps ) {
270- return < div className = { cn ( dialogBodyVariants ( { spacing } ) , className ) } data-slot = "dialog-body" { ...props } /> ;
298+ return (
299+ < div
300+ className = { cn ( dialogBodyContainerVariants ( ) , className ) }
301+ data-slot = "dialog-body"
302+ ref = { containerRef }
303+ style = { style }
304+ { ...props }
305+ >
306+ { scrollShadow ? (
307+ < >
308+ < div aria-hidden className = "h-px shrink-0" ref = { topRef } />
309+ < div
310+ aria-hidden
311+ className = { cn (
312+ 'pointer-events-none sticky top-0 z-10 h-0 transition-opacity duration-150' ,
313+ edges . top ? 'opacity-100' : 'opacity-0'
314+ ) }
315+ >
316+ < div className = "absolute inset-x-0 top-0 h-3 bg-gradient-to-b from-black/[0.10] to-transparent" />
317+ </ div >
318+ </ >
319+ ) : null }
320+ < div className = { cn ( dialogBodyContentVariants ( { padding, spacing } ) ) } > { children } </ div >
321+ { scrollShadow ? (
322+ < >
323+ < div
324+ aria-hidden
325+ className = { cn (
326+ 'pointer-events-none sticky bottom-0 z-10 h-0 transition-opacity duration-150' ,
327+ edges . bottom ? 'opacity-100' : 'opacity-0'
328+ ) }
329+ >
330+ < div className = "absolute inset-x-0 bottom-0 h-3 bg-gradient-to-t from-black/[0.10] to-transparent" />
331+ </ div >
332+ < div aria-hidden className = "h-px shrink-0" ref = { bottomRef } />
333+ </ >
334+ ) : null }
335+ </ div >
336+ ) ;
271337}
272338
273339const dialogFieldVariants = cva ( 'flex flex-col' , {
0 commit comments