Sheet
pnpm dlx shadcn@latest add …/r/sheet.jsonAn accessible React sheet for Next.js: a side panel that slides in from any edge of the screen, for forms, filters and navigation, built on Radix Dialog.
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
export function Filters() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Filters</Button>
</SheetTrigger>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
<SheetDescription>Narrow the results.</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
)
}import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { PanelBottomIcon, PanelLeftIcon, PanelRightIcon, PanelTopIcon } from "lucide-react";
import { useId } from "react";
const sides = [
{ side: "top", label: "Top", Icon: PanelTopIcon },
{ side: "right", label: "Right", Icon: PanelRightIcon },
{ side: "bottom", label: "Bottom", Icon: PanelBottomIcon },
{ side: "left", label: "Left", Icon: PanelLeftIcon },
] as const;
/** The same form in a sheet from each edge, each button named underneath */
export default function SheetSides() {
const id = useId();
return (
<ul className="flex flex-wrap items-end justify-center gap-x-8 gap-y-6">
{sides.map(({ side, label, Icon }) => (
<li key={side} className="flex flex-col items-center gap-2">
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" aria-label={`Open a sheet from the ${side}`}>
<Icon />
</Button>
</SheetTrigger>
<SheetContent side={side}>
<SheetHeader>
<SheetTitle>Edit profile</SheetTitle>
<SheetDescription>Change how others see you. Save when you're done.</SheetDescription>
</SheetHeader>
{/* Capped, so the fields stay a comfortable width in a sheet across the top or bottom */}
<div className="grid gap-4 sm:max-w-sm">
<div className="grid gap-2">
<label htmlFor={`${id}-${side}-name`} className="text-sm font-medium">
Name
</label>
<Input id={`${id}-${side}-name`} defaultValue="Maya Chen" autoComplete="name" />
</div>
<div className="grid gap-2">
<label htmlFor={`${id}-${side}-username`} className="text-sm font-medium">
Username
</label>
<Input id={`${id}-${side}-username`} defaultValue="maya" autoComplete="username" />
</div>
</div>
<SheetFooter>
<SheetClose asChild>
<Button>Save changes</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
<span aria-hidden className="text-xs text-muted-foreground">
{label}
</span>
</li>
))}
</ul>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority lucide-react motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Add Button first.
Copy the source into your project.
"use client"; import { Button } from "@/components/ui/button"; import { duration, ease, spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva } from "class-variance-authority"; import { XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Dialog as SheetPrimitive } from "radix-ui"; import { type ComponentProps, createContext, useCallback, useContext, useState } from "react"; /* * Sheet: a panel that slides in from an edge of the screen, for forms, filters, navigation or details beside * the page. Built on Radix Dialog, so it's a modal: focus moves in and stays there, Escape and the backdrop * close it, and focus goes back to the trigger. * - The root keeps the open state, controlled (`open`) or not (`defaultOpen`), so the panel can slide out: * the portal stays mounted (forceMount) inside AnimatePresence until the exit ends. * - Motion (.agents/rules/motion.md): a drawer slides. The panel comes in from its edge on spring.snappy and * leaves on a timed duration.base + ease.in. No blur: it's a surface moving, not content appearing. The * backdrop only fades. With reduced motion, both only fade. * - An 8px band in the panel's own colour lies just past the screen edge, so the spring's slight overshoot * never opens a gap between the panel and the edge. * - While it leaves, clicks go through to the page, so the page is usable again at once. Focus then goes * back to the trigger, unless the person has already focused something else there. */ type Side = "top" | "right" | "bottom" | "left"; const SheetContext = createContext<boolean | null>(null); function useSheetOpen(part: string) { const open = useContext(SheetContext); if (open === null) throw new Error(`${part} must be used inside Sheet`); return open; } /** Leaving is timed rather than a spring, so it's over quickly */ const leave = { duration: duration.base, ease: ease.in }; /** Where the panel waits off-screen, beyond its edge */ const offscreen = { top: { y: "-100%" }, right: { x: "100%" }, bottom: { y: "100%" }, left: { x: "-100%" }, } as const satisfies Record<Side, object>; const sheetVariants = cva( [ "fixed z-(--z-modal) flex flex-col gap-4 overflow-y-auto bg-background p-6 text-foreground shadow-lg outline-hidden", // Leaving, it no longer catches clicks "data-[state=closed]:pointer-events-none", ], { variants: { // Each side sits 8px past its edge with an 8px border in the panel's colour there (the overshoot band), // and a hairline on the side facing the page side: { top: "inset-x-0 -top-2 max-h-full border-t-8 border-b border-t-background border-b-border", right: "inset-y-0 -right-2 h-full w-3/4 border-r-8 border-l border-r-background border-l-border sm:max-w-sm", bottom: "inset-x-0 -bottom-2 max-h-full border-t border-b-8 border-t-border border-b-background", left: "inset-y-0 -left-2 h-full w-3/4 border-r border-l-8 border-r-border border-l-background sm:max-w-sm", }, }, defaultVariants: { side: "right" }, }, ); function Sheet({ open, defaultOpen, onOpenChange, ...props }: ComponentProps<typeof SheetPrimitive.Root>) { const [own, setOwn] = useState(defaultOpen ?? false); const current = open ?? own; const setOpen = useCallback( (next: boolean) => { setOwn(next); onOpenChange?.(next); }, [onOpenChange], ); return ( <SheetContext.Provider value={current}> <SheetPrimitive.Root data-slot="sheet" open={current} onOpenChange={setOpen} {...props} /> </SheetContext.Provider> ); } function SheetTrigger(props: ComponentProps<typeof SheetPrimitive.Trigger>) { return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />; } function SheetClose(props: ComponentProps<typeof SheetPrimitive.Close>) { return <SheetPrimitive.Close data-slot="sheet-close" {...props} />; } /** Stays mounted while the sheet is open and while its exit plays */ function SheetPortal({ children, ...props }: Omit<ComponentProps<typeof SheetPrimitive.Portal>, "forceMount">) { const open = useSheetOpen("SheetPortal"); return ( // initial={false}: a sheet that starts open is simply there, with no entrance <AnimatePresence initial={false}> {open ? ( <SheetPrimitive.Portal data-slot="sheet-portal" {...props} forceMount> {children} </SheetPrimitive.Portal> ) : null} </AnimatePresence> ); } function SheetOverlay({ className, style, ...props }: ComponentProps<typeof SheetPrimitive.Overlay>) { const open = useSheetOpen("SheetOverlay"); return ( <SheetPrimitive.Overlay data-slot="sheet-overlay" className={cn("fixed inset-0 z-(--z-overlay) bg-overlay", className)} // Radix keeps the backdrop clickable; while it fades out, clicks go through to the page style={{ pointerEvents: open ? "auto" : "none", ...style }} {...props} asChild forceMount > {/* The backdrop only fades: a full-screen blur would cost too much (ADR 0011) */} <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { duration: duration.base, ease: ease.out } }} exit={{ opacity: 0, transition: leave }} /> </SheetPrimitive.Overlay> ); } export type SheetContentProps = ComponentProps<typeof SheetPrimitive.Content> & { /** The edge it slides in from */ side?: Side; /** Show a close button in the top corner */ showCloseButton?: boolean; }; function SheetContent({ className, children, side = "right", showCloseButton = true, onCloseAutoFocus, ...props }: SheetContentProps) { const reduce = useReducedMotion() ?? false; const hidden = reduce ? { opacity: 0 } : offscreen[side]; return ( <SheetPortal> <SheetOverlay /> <SheetPrimitive.Content data-slot="sheet-content" data-side={side} className={cn( sheetVariants({ side }), // The header stops short of the close button, so a long title never runs under it showCloseButton && "**:data-[slot=sheet-header]:pe-8", className, )} onCloseAutoFocus={(event) => { onCloseAutoFocus?.(event); // Once the panel is gone, focus goes back to the trigger, unless the person has already focused // something on the page (clicks reach it while the panel leaves) if (document.activeElement !== document.body) event.preventDefault(); }} {...props} asChild forceMount > <motion.div initial={hidden} animate={ reduce ? { opacity: 1, transition: { duration: duration.base, ease: ease.out } } : { x: 0, y: 0, transition: spring.snappy } } exit={{ ...hidden, transition: leave }} > {children} {showCloseButton ? ( <SheetPrimitive.Close asChild> {/* Centred on the title's line, its icon lined up with the panel's padding */} <Button data-slot="sheet-close" variant="ghost" size="icon-sm" aria-label="Close" className="absolute end-4 top-5 text-muted-foreground hover:text-foreground" > <XIcon /> </Button> </SheetPrimitive.Close> ) : null} </motion.div> </SheetPrimitive.Content> </SheetPortal> ); } function SheetHeader({ className, ...props }: ComponentProps<"div">) { return <div data-slot="sheet-header" className={cn("grid gap-1.5", className)} {...props} />; } /** Actions at the panel's foot: stacked on phones with the main one on top, in a row at the end from sm */ function SheetFooter({ className, ...props }: ComponentProps<"div">) { return ( <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} /> ); } function SheetTitle({ className, ...props }: ComponentProps<typeof SheetPrimitive.Title>) { return ( <SheetPrimitive.Title data-slot="sheet-title" className={cn("text-base font-semibold", className)} {...props} /> ); } function SheetDescription({ className, ...props }: ComponentProps<typeof SheetPrimitive.Description>) { return ( <SheetPrimitive.Description data-slot="sheet-description" className={cn("text-sm text-muted-foreground", className)} {...props} /> ); } export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, sheetVariants, };Update the import paths to match your project.
API reference
Sheet
The root. It keeps the open state, so the panel can slide out before it unmounts.
| Prop | Type | Default | Description |
|---|---|---|---|
open? | boolean | – | Whether it's open, when you control it. |
defaultOpen? | boolean | false | Whether it starts open. |
onOpenChange? | (open: boolean) => void | – | Called when it opens or closes: the trigger, a close button, Escape or the backdrop. |
modal? | boolean | true | Modal blocks the page behind, hides it from screen readers and keeps focus inside. Set false to let people use the page while it's open (there's no backdrop then). |
SheetTrigger
The button that opens it. Focus comes back here when it closes.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild? | boolean | false | Use your own element, such as a Button, as the trigger. |
SheetContent
The panel, with its backdrop, in a portal. It slides in from its edge on a spring and leaves quickly; with reduced motion it only fades. Long content scrolls inside it. Give it a SheetTitle, which names it. Also accepts every Radix Dialog Content prop.
| Prop | Type | Default | Description |
|---|---|---|---|
side? | "top" | "right" | "bottom" | "left" | "right" | The edge it slides in from. Right and left panels are three quarters of the screen wide, up to 24rem; top and bottom ones are as tall as their content. |
showCloseButton? | boolean | true | Shows a close button in the top corner, named Close. |
onOpenAutoFocus? | (event: Event) => void | – | Runs as focus moves in (to the first focusable element). Call event.preventDefault() to focus something else yourself. |
onCloseAutoFocus? | (event: Event) => void | – | Runs as focus goes back to the trigger. Call event.preventDefault() to send it elsewhere. |
onEscapeKeyDown? | (event: KeyboardEvent) => void | – | Runs when Escape is pressed. Call event.preventDefault() to keep it open. |
SheetHeader
Holds the title and description. It leaves room for the close button.
| Prop | Type | Default | Description |
|---|
SheetFooter
Holds the actions at the panel's foot: stacked on phones with the last one on top, in a row at the end from the sm breakpoint.
| Prop | Type | Default | Description |
|---|
SheetTitle
The heading, and the sheet's accessible name.
| Prop | Type | Default | Description |
|---|
SheetDescription
A line under the title, read out after the name.
| Prop | Type | Default | Description |
|---|
SheetClose
Closes the sheet. Use asChild to make your own Button close it.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild? | boolean | false | Use your own element as the close button. |
Related components
An accessible React dialog for Next.js and Tailwind CSS: a modal window that grows out of its trigger, keeps focus inside and returns it, built on Radix Dialog.
An accessible React alert dialog for Next.js that asks people to confirm destructive actions, with focus starting on Cancel, built on Radix Alert Dialog.
- Right-click here
An accessible React context menu for Next.js: a right-click menu with a gliding highlight, shortcuts, checkbox and radio items and sub-menus, built on Radix.