Sheet
A slide-in panel on the radix Dialog primitive - pick one of four sides and get directional slide animations driven by the ai2 motion tokens.
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
export default function SheetDemo() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Open sheet</Button>
</SheetTrigger>
<SheetContent side="right" size="lg">
<SheetHeader>
<SheetTitle>Deployment details</SheetTitle>
<SheetDescription>dep_8f3k2m · us-east-1 · ready</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/sheetDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install class-variance-authority@^0.7.1 radix-ui@^1.6.1 lucide-react@^1.23.0 tw-animate-css@^1.4.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/sheet.tsx"use client"
import type * as React from "react"
import { X } from "lucide-react"
import { Dialog as SheetPrimitive } from "@/components/ui/primitives"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Sheet(props: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger(props: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose(props: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
const sheetVariants = cva(
"fixed z-50 flex flex-col gap-4 bg-popover text-popover-foreground shadow-lg transition-transform duration-(--motion-base) ease-(--motion-ease) data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none",
{
variants: {
side: {
right: "inset-y-0 right-0 h-full w-3/4 border-l border-border data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
left: "inset-y-0 left-0 h-full w-3/4 border-r border-border data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
top: "inset-x-0 top-0 h-auto border-b border-border data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom: "inset-x-0 bottom-0 h-auto border-t border-border data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
},
size: {
sm: "",
md: "",
lg: "",
},
},
compoundVariants: [
{ side: ["right", "left"], size: "sm", class: "sm:max-w-xs" },
{ side: ["right", "left"], size: "md", class: "sm:max-w-sm" },
{ side: ["right", "left"], size: "lg", class: "sm:max-w-md" },
],
defaultVariants: { side: "right", size: "md" },
}
)
interface SheetContentProps
extends React.ComponentProps<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
function SheetContent({ className, children, side, size, ...props }: SheetContentProps) {
return (
<SheetPrimitive.Portal>
<SheetPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 motion-reduce:animate-none" />
<SheetPrimitive.Content
data-slot="sheet-content"
data-size={size ?? "md"}
className={cn(sheetVariants({ side, size }), "p-6", className)}
{...props}
>
{/* The scroll container wraps the CONTENT, not the Content element
ITSELF. Putting overflow-y-auto on Content would have scrolled the
`absolute` Close button below away with the content and hidden it.
This way close always stays on screen while the body scrolls.
`flex flex-col gap-4`: Content's gap now applies to a single child,
so the same spacing moves here and the visual result is identical.
`min-h-0`: a flex child's default min-height:auto makes overflow
INEFFECTIVE - without this, scrolling does not work. */}
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain">
{children}
</div>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-md opacity-70 outline-none transition-opacity duration-(--motion-fast) after:absolute after:-inset-2 hover:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_i]:text-base [&_i]:leading-none">
<X />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2", className)} {...props} />
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}Manual installs skip the @ai2/tokens theme - add the token CSS from the theming guide or the tone colors will be missing.
Usage
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Open sheet</Button>
</SheetTrigger>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Deployment details</SheetTitle>
<SheetDescription>dep_8f3k2m · us-east-1 · ready</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>Set side on SheetContent. Use left/right for navigation drawers and detail panels, bottom for mobile filters.
Examples
Sides
With footer
SheetFooter uses mt-auto to pin its actions to the bottom of the panel; SheetClose asChild dismisses without extra state.
Sizes
The size prop sets the panel width for side="left" and side="right" from the sm breakpoint up: sm (max-w-xs), md (the default, max-w-sm) and lg (max-w-md). On mobile those panels stay w-3/4. For side="top" and side="bottom" the size prop is a no-op, since those bars are always full width and size to their content.
Props
SheetContent adds two props; every part also forwards its underlying radix Dialog props - e.g. open / onOpenChange on Sheet.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "right" | "left" | "top" | "bottom" | "right" | Edge the panel slides in from. Left/right are full-height columns; top/bottom are full-width bars. |
size | "sm" | "md" | "lg" | "md" | Panel width for side left/right from the sm breakpoint up: sm (max-w-xs), md (max-w-sm), lg (max-w-md). On mobile left/right stay w-3/4. No-op for side top/bottom, which are always full width and size to their content. |
ai2 Sheet: a side drawer for React, sliding from any edge
The ai2 Sheet is a shadcn-compatible sheet component for React, built on the radix-ui Dialog primitive and styled with Tailwind CSS v4. It is the classic side drawer: a panel that slides in from the right, left, top or bottom edge of the screen over a dimmed overlay, ideal for navigation menus, detail panels, edit forms and mobile filters.
It ships through the shadcn registry format, so one CLI command, an MCP agent, or a copy-paste puts the source in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Sheet?
It is an eight-part composition: Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter and SheetClose. The anatomy matches shadcn/ui exactly, so existing snippets and AI agents keep working without changes.
Under the hood it is a radix Dialog: the panel renders in a portal over a fixed overlay, traps focus while open, and closes on Escape or an overlay click. The two styling decisions you make are the side prop, which picks one of four directional slide animations driven by the shared ai2 motion tokens, and the size prop, which sets the panel width for the left and right sides.
Why use it
- Four sides from one prop: side="right" (the default), left, top or bottom. Left and right are full-height columns whose desktop width is set by the size prop (sm, md, lg); top and bottom are full-width bars that size to their content.
- Accessible by construction: The radix Dialog primitive provides focus trapping, aria-labelledby wiring via SheetTitle, aria-describedby via SheetDescription, Escape to close and focus return to the trigger.
- Motion from tokens: The slide and fade animations read --motion-base and --motion-ease from the shared token file, so the drawer moves at the same tempo as every other ai2 component.
- Built-in close button: SheetContent renders an X button in the top right corner with a screen-reader-only "Close" label and a focus-visible ring, so every sheet is dismissable without extra code.
- Controlled or uncontrolled: Let SheetTrigger manage the open state, or pass open and onOpenChange to the root to drive the drawer from router state or a keyboard shortcut.
Features
- shadcn registry install: One command adds the component, its dependencies and the @ai2/tokens theme to your project.
- Directional slide animations: Each side pairs slide-in-from and slide-out-to keyframes with a fading overlay, so open and close both read as one continuous movement.
- Header and footer slots: SheetHeader stacks the title and description; SheetFooter uses mt-auto to pin actions to the bottom edge of the panel regardless of content height.
- Portal rendering: The overlay and panel render in a portal at z-50, so the sheet escapes any overflow or stacking context in the surrounding layout.
- Data attributes for styling: Every part exposes data-slot, and the panel exposes data-state for open and closed, so you can restyle states from CSS without forking the component.
- TypeScript source: The file you install is typed end to end and forwards every radix Dialog prop, so autocomplete covers the full API.
Production tips
- Always include SheetTitle: The title is what radix announces as the dialog's accessible name. If you want a visually clean panel, keep the title and hide it with sr-only instead of omitting it.
- Match side to the use case: Right suits detail panels and edit forms, left suits navigation, bottom suits mobile filter and action sheets. Top works best for short announcements or search bars.
- Use SheetClose for cancel actions: Wrap your Cancel button in SheetClose asChild instead of wiring onOpenChange manually. It dismisses the sheet and keeps focus management intact.
- Mind the width on desktop: Left and right panels are w-3/4, capped from the sm breakpoint at a width set by the size prop: sm (max-w-xs), md (the default, max-w-sm) or lg (max-w-md). For content wider than lg, override the max width on SheetContent via className rather than fighting the layout inside.
- Do not nest heavy forms without scroll: The panel is a flex column with p-6. If a form can outgrow the viewport, add overflow-y-auto to a wrapper inside the content so the header and footer stay visible.
Works with the rest of ai2
The sheet composes naturally with the rest of the registry. Use an ai2 Button as the trigger and for footer actions, lay out edit forms inside with ai2 Field and ai2 Input rows, and divide long panels with an ai2 Separator.
When the surface should sit in the center of the screen instead of an edge, reach for Dialog instead: both share the same radix primitive and anatomy, so switching between them is mostly a rename. Everything reads one token source, so combinations stay consistent in both light and dark mode.