Repository navigation
Recommended pattern for Base UI Drawer with Next.js Intercepting Routes: exit animations and scroll restoration #12638
|
Hi! What is the recommended way to use the Base UI version of shadcn Drawer with Next.js Parallel Routes and Intercepting Routes? The basic routing works, but coordinating the drawer’s animation lifecycle with navigation and scroll restoration has been tricky. Use case A product opens in a drawer above the catalog through client-side navigation. Opening the same URL directly or refreshing renders a full product page. The layout renders {children} and {modal}. The intercepted page wraps server-rendered product content in a client drawer component. Environment
Current implementation "use client"
import { useRouter } from "next/navigation"
import { useEffect, useState, type ReactNode } from "react"
import { Drawer, DrawerContent } from "@/components/ui/drawer"
import { ScrollArea } from "@/components/ui/scroll-area"
export function ProductDrawer({ children }: { children: ReactNode }) {
const router = useRouter()
const [open, setOpen] = useState(false)
useEffect(() => {
const frame = requestAnimationFrame(() => setOpen(true))
return () => cancelAnimationFrame(frame)
}, [])
return (
<Drawer
open={open}
onOpenChange={setOpen}
onOpenChangeComplete={(isOpen) => {
if (!isOpen) router.back()
}}
>
<DrawerContent className="h-[calc(100dvh-6rem)]">
<ScrollArea className="min-h-0 flex-1">
<div className="flex min-h-full flex-col p-4">
{children}
</div>
</ScrollArea>
</DrawerContent>
</Drawer>
)
}Mounting the drawer closed and then opening it allows the entrance transition to run. Calling router.back() after onOpenChangeComplete(false) allows the exit animation to finish before the route unmounts. The remaining problem: scrolling during dismissal There seems to be a gap between scroll unlocking and history navigation:
However, that property affects document history globally, so I’m unsure whether managing it around drawer dismissal is the right integration strategy. I also tried modal="trap-focus". It removes scroll locking, but the subsequent Back navigation still restores the previous position. In my local shadcn wrapper, the backdrop also disappears because it is only rendered when modal === true. Questions
A small official example covering these interactions would be very helpful. |
Replies: 1 comment
|
Short answers in order: 1. 2. Scroll lock during the exit: there's no prop for it. In Base UI the lock is literally // packages/react/src/dialog/root/useDialogRoot.ts
useScrollLock(open && modal === true, popupElement);so it's released the moment One way around it is to take a second lock that I hold until the route actually unmounts. The locker is a ref-counted singleton, so two holders don't fight: import { useScrollLock } from "@base-ui/utils/useScrollLock"
export function ProductDrawer({ children }: { children: ReactNode }) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [closing, setClosing] = useState(false)
// stays locked from open until this component unmounts after router.back()
useScrollLock(open || closing)
useEffect(() => {
const frame = requestAnimationFrame(() => setOpen(true))
return () => cancelAnimationFrame(frame)
}, [])
return (
<Drawer
open={open}
onOpenChange={(next) => {
if (!next) setClosing(true)
setOpen(next)
}}
onOpenChangeComplete={(isOpen) => {
if (!isOpen) router.back()
}}
>
{/* ... */}
</Drawer>
)
}
3. 4. |
Short answers in order:
1.
setOpen(false)→onOpenChangeComplete(false)→router.back(): yes, that's the right shape for UI-initiated dismissal. The popup stays mounted through the exit animation andonOpenChangeCompleteonly fires once it has finished, so navigating there is what keeps the animation intact. Browser Back is the case where the route unmounts first and you just don't get an exit animation. That's fine, and nothing callsrouter.back()twice because the component is already gone.2. Scroll lock during the exit: there's no prop for it. In Base UI the lock is literally
so it'…