Skip to main content

Sheet

A container that overlays the current view from the edge of the screen. It is a lightweight way of allowing users to complete a task without losing contextual information of the view beneath it.

When to use

Use Sheet for side-panel content that slides in from the left or right edge: filter panels, detail/inspector views, navigation drawers, mobile menus. For a centered modal, use Dialog or AlertDialog (for destructive confirmations).

import { Button, IconButton } from "@ngrok/mantle/button";import { Separator } from "@ngrok/mantle/separator";import { Sheet } from "@ngrok/mantle/sheet";import { ListMagnifyingGlassIcon } from "@phosphor-icons/react/ListMagnifyingGlass";import { TerminalWindowIcon } from "@phosphor-icons/react/TerminalWindow";import { TrashSimpleIcon } from "@phosphor-icons/react/TrashSimple"; <Sheet.Root>	<Sheet.Trigger asChild>		<Button type="button" appearance="filled" intent="neutral">			Open Sheet		</Button>	</Sheet.Trigger>	<Sheet.Content>		<Sheet.Header>			<Sheet.TitleGroup>				<Sheet.Title>Are you absolutely sure?</Sheet.Title>				<Sheet.Actions>					<IconButton						appearance="ghost"						intent="neutral"						type="button"						icon={<TerminalWindowIcon />}						label="Start a Tunnel"					/>					<IconButton						appearance="ghost"						intent="neutral"						type="button"						icon={<ListMagnifyingGlassIcon />}						label="See Traffic"					/>					<IconButton						appearance="ghost"						intent="neutral"						type="button"						icon={<TrashSimpleIcon />}						label="Delete"					/>					<Separator orientation="vertical" className="h-[80%]" />					<Sheet.CloseIconButton />				</Sheet.Actions>			</Sheet.TitleGroup>			<Sheet.Description>				This action cannot be undone. This will permanently delete your account and remove your data				from our servers.			</Sheet.Description>		</Sheet.Header>		<Sheet.Body className="space-y-4">			<p>Lorem ipsum</p>		</Sheet.Body>		<Sheet.Footer>			<Sheet.Close asChild>				<Button type="button" appearance="outlined" intent="neutral">					Close				</Button>			</Sheet.Close>		</Sheet.Footer>	</Sheet.Content></Sheet.Root>;

Layering and z-index

Sheet.Content renders its floating layer at Tailwind z-60, Mantle's overlay tier, above every float (z-50). Floats composed inside the content portal into the positioner (data-slot="sheet-positioner") and paint above the content. When multiple overlays are open, the most recently mounted overlay renders on top.

Sheet, Dialog, and AlertDialog each render their own backdrop, so stacking modal layers compounds the dim and blur effect on the layers underneath.

UI above an open sheet

UI that must take clicks above an open sheet, such as a consent banner, carries the overlay-prompt class. A click inside it does not count as an outside click. Add pointer-events-auto too, because the open sheet sets pointer-events: none on <body>. mantle's Toaster does both.

<div className="overlay-prompt pointer-events-auto fixed inset-x-0 bottom-0 z-max">	<ConsentBanner /></div>

The class reaches pointer input only. The open sheet traps keyboard focus and hides outside content from assistive technology with aria-hidden, so a keyboard or screen reader user cannot reach the banner. Put a control that every user must reach inside the sheet content.

Examples

Router or state management controlled Sheet

Control when to render a Sheet with a router or with outside state management to open and close it programmatically without a Sheet.Trigger.

import { Button } from "@ngrok/mantle/button";import { Sheet } from "@ngrok/mantle/sheet";import { useNavigate } from "react-router"; // this is a react-router route module component exportexport default function Component() {	const navigate = useNavigate(); 	return (		<Sheet.Root open onOpenChange={() => navigate("..")}>			<Sheet.Content>				<Sheet.Header>					<Sheet.TitleGroup>						<Sheet.Title>A controlled Sheet</Sheet.Title>						<Sheet.Actions>							<Sheet.CloseIconButton />						</Sheet.Actions>					</Sheet.TitleGroup>					<Sheet.Description>						This sheet is controlled by a router or state manager.					</Sheet.Description>				</Sheet.Header>				<Sheet.Body>					<p>						Consequat do voluptate culpa fugiat consequat nostrud duis aliqua minim. Tempor						voluptate cillum elit velit. Voluptate aliqua ipsum aliqua dolore in nisi ea fugiat						aliqua velit proident amet.					</p>				</Sheet.Body>				<Sheet.Footer>					<Sheet.Close asChild>						<Button type="button" appearance="outlined" intent="neutral">							Close						</Button>					</Sheet.Close>				</Sheet.Footer>			</Sheet.Content>		</Sheet.Root>	);}

Setting a preferred width of the Sheet

By default, a Sheet's content width is responsive with a default preferred width: the maximum width of the Sheet.Content when the window viewport is larger than the mobile breakpoint (sm). Control the preferred width of the Sheet.Content with the preferredWidth prop:

import { Button } from "@ngrok/mantle/button";import { Sheet } from "@ngrok/mantle/sheet"; <Sheet.Root>	<Sheet.Trigger asChild>		<Button type="button" appearance="filled" intent="neutral">			Open 800px wide Sheet		</Button>	</Sheet.Trigger>	{/* using the 👇 `preferredWidth` prop */}	<Sheet.Content preferredWidth="sm:max-w-[800px]">		<Sheet.Header>			<Sheet.TitleGroup>				<Sheet.Title>Tempor pariatur fugiat fugiat cupidatat velit.</Sheet.Title>				<Sheet.Actions>					<Sheet.CloseIconButton />				</Sheet.Actions>			</Sheet.TitleGroup>		</Sheet.Header>		<Sheet.Body className="space-y-4">			<p>				Consequat do voluptate culpa fugiat consequat nostrud duis aliqua minim. Tempor voluptate				cillum elit velit. Voluptate aliqua ipsum aliqua dolore in nisi ea fugiat aliqua velit				proident amet.			</p>		</Sheet.Body>		<Sheet.Footer>			<Sheet.Close asChild>				<Button type="button" appearance="outlined" intent="neutral">					Close				</Button>			</Sheet.Close>			<Button type="button" appearance="filled" intent="neutral">				Save			</Button>		</Sheet.Footer>	</Sheet.Content></Sheet.Root>;

With TanStack Form

Sheets often host small create/edit forms. Use TanStack Form with Field for label, description, and error wiring. Sheet.Content is a flex column, so wrap Sheet.Body and Sheet.Footer in a display: contents form; the footer's submit button participates in the form while the body keeps its scrollable layout. Close the sheet from the form's onSubmit (it only runs once validation passes) and reset the form when the sheet closes so a cancelled draft doesn't leak into the next open.

import { Button } from "@ngrok/mantle/button";import { Field, toErrorMessages } from "@ngrok/mantle/field";import { Input } from "@ngrok/mantle/input";import { Sheet } from "@ngrok/mantle/sheet";import { TextArea } from "@ngrok/mantle/text-area";import { useForm } from "@tanstack/react-form";import { useState } from "react";import { z } from "zod"; const formSchema = z.object({	name: z.string().min(1, "Name your API key."),	note: z.string().max(280, "Keep the note under 280 characters."),}); function Example() {	const [open, setOpen] = useState(false);	const form = useForm({		defaultValues: {			name: "",			note: "",		},		validators: {			onSubmit: formSchema,		},		onSubmit: ({ value }) => {			// Handle the create with `value`, then close the sheet			handleOpenChange(false);		},	}); 	const handleOpenChange = (nextOpen: boolean) => {		setOpen(nextOpen);		if (!nextOpen) {			form.reset();		}	}; 	return (		<Sheet.Root open={open} onOpenChange={handleOpenChange}>			<Sheet.Trigger asChild>				<Button type="button" appearance="filled" intent="neutral">					New API key				</Button>			</Sheet.Trigger>			<Sheet.Content>				<Sheet.Header>					<Sheet.TitleGroup>						<Sheet.Title>New API key</Sheet.Title>						<Sheet.Actions>							<Sheet.CloseIconButton />						</Sheet.Actions>					</Sheet.TitleGroup>					<Sheet.Description>						Create a key for programmatic access to the ngrok API.					</Sheet.Description>				</Sheet.Header>				<form					className="contents"					onSubmit={(event) => {						event.preventDefault();						event.stopPropagation();						void form.handleSubmit();					}}				>					<Sheet.Body className="space-y-4">						<form.Field name="name">							{(field) => (								<Field.Item name={field.name}>									<Field.Label>Name</Field.Label>									<Field.Control>										<Input											placeholder="production-deploys"											value={field.state.value}											onBlur={field.handleBlur}											onChange={(event) => field.handleChange(event.target.value)}										/>									</Field.Control>									<Field.Errors messages={toErrorMessages(field.state.meta.errors)} />								</Field.Item>							)}						</form.Field>						<form.Field name="note">							{(field) => (								<Field.Item name={field.name}>									<Field.Label>Note</Field.Label>									<Field.Control>										<TextArea											placeholder="What is this key used for?"											value={field.state.value}											onBlur={field.handleBlur}											onChange={(event) => field.handleChange(event.target.value)}										/>									</Field.Control>									<Field.Description>										Optional. Help your team know where this key is used.									</Field.Description>									<Field.Errors messages={toErrorMessages(field.state.meta.errors)} />								</Field.Item>							)}						</form.Field>					</Sheet.Body>					<Sheet.Footer>						<Sheet.Close asChild>							<Button type="button" appearance="outlined" intent="neutral">								Cancel							</Button>						</Sheet.Close>						<Button type="submit" appearance="filled" intent="neutral">							Create key						</Button>					</Sheet.Footer>				</form>			</Sheet.Content>		</Sheet.Root>	);}

Composition

Compose the parts of a Sheet together to build your own:

Sheet.Root├── Sheet.Trigger└── Sheet.Content    ├── Sheet.Header    │   ├── Sheet.TitleGroup    │   │   ├── Sheet.Title    │   │   └── Sheet.Actions    │   └── Sheet.Description    ├── Sheet.Body    └── Sheet.Footer        └── Sheet.Close

Polymorphism

Sheet.Trigger, Sheet.Close, Sheet.Content, Sheet.Title, Sheet.Description, and Sheet.CloseIconButton accept asChild, which renders your single child instead of the part's default element while keeping its styling and behavior.

Sheet.Trigger asChild is the most common: it makes your own Button open the sheet rather than nesting a button inside one. Sheet.Close asChild lets any element dismiss the sheet, which is handy for a "Cancel" button in Sheet.Footer, and Sheet.Title asChild adjusts the heading level to fit the page outline.

Sheet.Root renders no DOM of its own, so it takes no asChild. Sheet.Header, Sheet.Body, Sheet.Footer, Sheet.TitleGroup, and Sheet.Actions are layout containers that do not currently accept it.

API Reference

The Sheet components are built on top of Radix Dialog.

Sheet.Root

The root component for a Sheet. Should compose the Sheet.Trigger and Sheet.Content. Acts as a stateful provider for the Sheet's open/closed state.

Every Sheet part reads a dialog scope of its own, so it binds to the nearest Sheet.Root and never to a Dialog.Root. The reverse holds too: a Dialog.Trigger or Command.SearchTrigger inside Sheet.Content reaches the Dialog.Root or Command.DialogRoot above the sheet, not the sheet. A Dialog.Close inside a sheet with no Dialog.Root above it throws Radix's "must be used within Dialog"; use Sheet.Close to close a sheet.

Sheet renders its floating layer at Tailwind z-60, Mantle's overlay tier, above every float (z-50). Floats composed inside the content portal into the positioner (data-slot="sheet-positioner") and paint above the content. When multiple overlays are open, the most recently mounted overlay renders on top.

All props from Radix Dialog.Root.

Sheet.Trigger

The button trigger for a Sheet. Should be rendered as a child of the Sheet component. Renders an unstyled button by default, but can be customized with the asChild prop.

Opens the nearest Sheet.Root. It never opens a Dialog.Root, and a Dialog.Trigger inside the sheet never opens the sheet.

All props from Radix Dialog.Trigger.

Sheet.Close

The close button for a Sheet. Should be rendered as a child of the Sheet.Content component. Usually contained within the Sheet.Footer component. Renders an unstyled button by default, but can be customized with the asChild prop.

Closes the nearest Sheet.Root. It never closes a Dialog.Root, and a Dialog.Close inside the sheet never closes the sheet.

Radix Dialog.Close props.

Sheet.Content

The main container for a Sheet. Should be rendered as a child of the Sheet component. Renders on top of the overlay backdrop. Should contain the Sheet.Header, Sheet.Body, and Sheet.Footer.

Sheet.Content renders its floating layer at Tailwind z-60, Mantle's overlay tier, above every float (z-50). Floats composed inside the content portal into the positioner (data-slot="sheet-positioner") and paint above the content. When multiple overlays are open, the most recently mounted overlay renders on top.

Sheet.Content renders inside a positioner element (data-slot="sheet-positioner") that lays out the content and receives floats portaled from inside the sheet. The full stacking contract lives at Stacking Layers.

All props from Radix Dialog.Content, plus:

PropTypeDefaultDescription
preferredWidth?`sm:max-w-${string}`"sm:max-w-120"The preferred width of the Sheet.Content as a tailwind max-w- class. By default, a Sheet's content width is responsive with a default preferred width: the maximum width of the Sheet.Content when the window viewport is larger than the mobile breakpoint (sm).
side?"left" | "right""right"The side of the screen the sheet slides in from.

Sheet.CloseIconButton

An icon button that closes the Sheet when clicked. Should be rendered within the Sheet.Header as a child of Sheet.Actions.

PropTypeDefaultDescription
appearance?"filled" | "ghost" | "outlined""ghost"The visual style of the close icon button.
intent?"neutral""neutral"The tone of the close icon button. IconButton draws the neutral tone only.
size?"xs" | "sm" | "md" | "lg" | "xl""md"The size of the close icon button.
label?string"Close Sheet"The accessible label for the close button. Important for screen readers.

All remaining props (except icon, which is fixed to an X icon) forward to the Mantle IconButton.

Sheet.Body

The body container for a Sheet. This is where you would typically place the main content of the sheet, such as forms or text. Should be rendered as a child of Sheet.Content.

All props from div.

Sheet.Header

The header container for a Sheet. This is where you would typically place the title, description, and actions. Should be rendered as a child of Sheet.Content.

All props from div.

Sheet.Footer

The footer container for a Sheet. This is where you would typically place close and submit buttons. Should be rendered as a child of Sheet.Content.

All props from div.

Sheet.Title

The title for a Sheet. Typically rendered as a child of Sheet.TitleGroup. Defaults to an h2 element, but can be changed via the asChild prop.

All props from Radix Dialog.Title.

Sheet.TitleGroup

A group container for the title and actions of a Sheet. Typically rendered as a child of Sheet.Header.

All props from div.

Sheet.Description

A description for a Sheet. Typically rendered as a child of Sheet.Header.

All props from Radix Dialog.Description.

Sheet.Actions

A group container for the actions of a Sheet. Typically rendered as a child of Sheet.TitleGroup.

All props from div.