Nested modal navigation
Keep a side sheet inside a centered modal with an independent native scrollbar, swipe-back through content, and separate Back and Close actions.
A nested side sheet can stay physically inside a centered modal while retaining native scrolling, swipe-back, focus ownership, and independent Back and Close actions.
Complete React structure
import { useRef, useState } from "react";
import { Sheet } from "@velvetui/react/sheet";
import { Scroll } from "@velvetui/react/utilities";
import "@velvetui/react/sheet.css";
export function StudioLibrary() {
const [libraryOpen, setLibraryOpen] = useState(false);
const [articleOpen, setArticleOpen] = useState(false);
const nestedHostRef = useRef(null);
const closeAll = () => {
setArticleOpen(false);
setLibraryOpen(false);
};
return (
<Sheet.Root open={libraryOpen} onOpenChange={setLibraryOpen}>
<Sheet.Trigger type="button">Open library</Sheet.Trigger>
<Sheet.Portal>
<Sheet.View
side="center"
tracks="auto"
swipeOvershoot={false}
onClickOutside={{ dismiss: false }}
>
<Sheet.Backdrop />
<Sheet.Content className="library-modal">
{/* Parent labels must stay outside the nested Root. */}
<header className="library-header">
<div>
<Sheet.Title>Studio library</Sheet.Title>
<Sheet.Description>Notes from the studio.</Sheet.Description>
</div>
<Sheet.Close type="button" aria-label="Close library">×</Sheet.Close>
</header>
<Sheet.Root open={articleOpen} onOpenChange={setArticleOpen}>
<Scroll.Root className="library-scroll-root">
<Scroll.View className="library-scroll-view">
<Scroll.Content>
<button type="button" onClick={() => setArticleOpen(true)}>
Read launch notes
</button>
</Scroll.Content>
</Scroll.View>
</Scroll.Root>
<div ref={nestedHostRef} className="nested-portal-host" />
<Sheet.Portal container={nestedHostRef}>
<Sheet.View
className="nested-detail-view"
side="right"
nativeEdgeSwipePrevention
swipeOvershoot={false}
onClickOutside={{ dismiss: false }}
>
<Sheet.Backdrop />
<Sheet.Content className="nested-detail-panel">
<header className="detail-header">
<Sheet.Close type="button" aria-label="Back to library">←</Sheet.Close>
<Sheet.Title>Launch notes</Sheet.Title>
<button type="button" aria-label="Close library" onClick={closeAll}>×</button>
</header>
<Scroll.Root className="detail-scroll-root">
<Scroll.View
className="detail-scroll-view"
scrollGestureTrap={{ y: true }}
>
<Scroll.Content><Article /></Scroll.Content>
</Scroll.View>
</Scroll.Root>
</Sheet.Content>
</Sheet.View>
</Sheet.Portal>
</Sheet.Root>
</Sheet.Content>
</Sheet.View>
</Sheet.Portal>
</Sheet.Root>
);
}Sheet.Portal accepts either a resolved element or a React ref. Passing the ref directly avoids callback-ref state and the extra guarded render that older recipes needed.
Required layout CSS
.library-modal {
position: relative;
isolation: isolate;
display: flex;
width: min(64rem, calc(100vw - 2rem));
height: min(48rem, calc(var(--velvet-100lvh) - 2rem));
min-height: 0;
overflow: hidden;
flex-direction: column;
border-radius: 1.75rem;
background: white;
}
.library-header,
.detail-header { flex: none; }
.library-scroll-root,
.detail-scroll-root {
display: flex;
min-height: 0;
flex: 1;
overflow: hidden;
flex-direction: column;
}
.library-scroll-view,
.detail-scroll-view {
width: 100%;
height: 100%;
min-height: 0;
overflow-y: auto;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
}
.nested-portal-host {
position: absolute;
z-index: 20;
inset: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
}
.nested-detail-view { position: absolute !important; }
.nested-detail-panel {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
flex-direction: column;
border-radius: inherit;
background: white;
}
@media (max-width: 700px) {
.library-modal {
width: 100vw;
height: var(--velvet-100lvh);
border-radius: 0;
}
}The portal host establishes the clipping boundary. The nested View must be absolute because a normal Sheet.View is viewport-fixed. Keep the sticky header outside Scroll.View so only the article becomes the native overflow surface.
Tailwind CSS v4
@import "tailwindcss";// The component tree is identical; replace the structural classes only.
<Sheet.Content className="relative isolate flex h-[min(48rem,calc(var(--velvet-100lvh)_-_2rem))] min-h-0 w-[min(64rem,calc(100vw_-_2rem))] flex-col overflow-hidden rounded-[28px] bg-white max-[700px]:h-[var(--velvet-100lvh)] max-[700px]:w-screen max-[700px]:rounded-none">
<header className="shrink-0">...</header>
<Sheet.Root open={articleOpen} onOpenChange={setArticleOpen}>
<Scroll.Root className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Scroll.View className="h-full min-h-0 overflow-y-auto [scrollbar-gutter:stable]">
<Scroll.Content>...</Scroll.Content>
</Scroll.View>
</Scroll.Root>
<div ref={nestedHostRef} className="pointer-events-none absolute inset-0 z-20 overflow-hidden rounded-[inherit]" />
<Sheet.Portal container={nestedHostRef}>
<Sheet.View className="!absolute" side="right" nativeEdgeSwipePrevention>
<Sheet.Content className="flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[inherit] bg-white">
<header className="shrink-0">...</header>
<Scroll.Root className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Scroll.View
className="h-full min-h-0 overflow-y-auto [scrollbar-gutter:stable]"
scrollGestureTrap={{ y: true }}
>
<Scroll.Content><Article /></Scroll.Content>
</Scroll.View>
</Scroll.Root>
</Sheet.Content>
</Sheet.View>
</Sheet.Portal>
</Sheet.Root>
</Sheet.Content>Keep import "@velvetui/react/sheet.css" in the JavaScript entry. Tailwind owns the authored surface; Velvet's stylesheet owns gesture tracks, portal mechanics, modality, and Scroll coordination.
Live package examples
Both projects install @velvetui/react from npm. They do not alias the package to repository source or copy Velvet's internals into the example.
Rules that prevent the common failures
- Put the parent
Sheet.TitleandSheet.Descriptionbefore the childSheet.Root; labels always register with the nearest Root. - Use exactly one
Scroll.Root → Scroll.View → Scroll.Contentchain per scrolling layer. - Give every Back, Close, and row trigger
type="button";href="#"causes page jumps and an implicit submit button can trigger a form. - Never use
scrollGestureTrap={true}for a full-page horizontal child. Leave itfalse, or use{ y: true }to contain only vertical chaining while horizontal swipe-back remains available over paragraphs, cards, and controls. - Style the scrollbar on
Scroll.View; it is the real browser overflow element. - Control both roots only when a child action must close the complete flow. A child
Sheet.Closeshould otherwise dismiss only the child.
Mobile check
Test after a cold reload on a phone. Scroll the article from the middle, swipe back beginning directly over text, use Back to preserve the parent modal, use Close to leave both layers, and confirm the page and parent scroll positions never change.