Primitives
Sheet
A panel that slides in from an edge of the screen and keeps the page in view.
Primitives
A panel that slides in from an edge of the screen and keeps the page in view.
import {
Button,
Field,
Input,
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
Textarea,
} from 'ferry-ui'
export default function SheetHero() {
return (
<Sheet>
<SheetTrigger asChild>
<Button>Edit member</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit member</SheetTitle>
<SheetDescription>The changes apply to each project of the workspace.</SheetDescription>
</SheetHeader>
<SheetBody>
<Field label="Full name">
<Input defaultValue="Maya Chen" />
</Field>
<Field label="Email">
<Input type="email" defaultValue="maya@example.com" />
</Field>
<Field label="Note" optional hint="Only the admins of the workspace see this note.">
<Textarea />
</Field>
</SheetBody>
<SheetFooter className="flex-row justify-end">
<SheetClose asChild>
<Button>Cancel</Button>
</SheetClose>
<SheetClose asChild>
<Button variant="primary">Save changes</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}SheetTitle is the accessible name of the sheet.Import the parts. Put them together in this order.
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from 'ferry-ui'
<Sheet>
<SheetTrigger />
<SheetContent>
<SheetHeader>
<SheetTitle />
<SheetDescription />
</SheetHeader>
<SheetBody />
<SheetFooter>
<SheetClose />
</SheetFooter>
</SheetContent>
</Sheet>| Part | Role |
|---|---|
Sheet | Holds the open state. |
SheetTrigger | Opens the sheet on a click. |
SheetContent | The panel. It adds the backdrop and the close button. |
SheetHeader | Holds the title and the description. |
SheetTitle | The title. It is required. |
SheetDescription | A short text below the title. |
SheetBody | The content. It is the only part that scrolls. |
SheetFooter | The actions. It stays at the bottom of the panel. |
SheetClose | Closes the sheet on a click. |
With asChild, SheetTrigger and SheetClose give their behavior to their child element. Use it with a Button.
The side prop of SheetContent sets the edge that the panel comes from.
import { Button, Sheet, SheetBody, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from 'ferry-ui'
const SIDES = ['top', 'right', 'bottom', 'left'] as const
export default function SheetSides() {
return (
<>
{SIDES.map((side) => (
<Sheet key={side}>
<SheetTrigger asChild>
<Button>{side}</Button>
</SheetTrigger>
<SheetContent side={side}>
<SheetHeader>
<SheetTitle>Side {side}</SheetTitle>
<SheetDescription>The panel comes from the {side} edge of the screen.</SheetDescription>
</SheetHeader>
<SheetBody className="text-[13px] text-foreground-light">The page stays in view behind the panel.</SheetBody>
</SheetContent>
</Sheet>
))}
</>
)
}| Side | Use |
|---|---|
right | A detail panel or an edit panel (default). |
left | A navigation drawer. For the app navigation on a phone, use Mobile Nav. |
bottom | A list of actions on a phone. |
top | An announcement or a search. It is rare. |
A left or right sheet is 384px wide at most. Pass a class such as sm:max-w-lg to SheetContent to make it wider.
import {
Button,
DescriptionItem,
DescriptionList,
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from 'ferry-ui'
export default function SheetWidth() {
return (
<Sheet>
<SheetTrigger asChild>
<Button>View order</Button>
</SheetTrigger>
{/* `sm:max-w-lg` makes the panel wider than the default width. */}
<SheetContent className="sm:max-w-lg">
<SheetHeader>
<SheetTitle>Order #10482</SheetTitle>
<SheetDescription>Acme placed this order on March 3, 2026.</SheetDescription>
</SheetHeader>
<SheetBody>
<DescriptionList variant="rows" divided={false} aria-label="Order details">
<DescriptionItem label="Customer">Acme</DescriptionItem>
<DescriptionItem label="Payment">Card that ends in 4242</DescriptionItem>
<DescriptionItem label="Delivery">Express, 2 days</DescriptionItem>
<DescriptionItem label="Total">$1,284.00</DescriptionItem>
</DescriptionList>
</SheetBody>
</SheetContent>
</Sheet>
)
}SheetFooter stacks its buttons. Add className="flex-row justify-end" to put them in one row.
import {
Button,
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from 'ferry-ui'
const FOOTERS = [
// No class: the footer stacks its buttons.
{ label: 'Stacked buttons', className: undefined },
// `flex-row justify-end` puts the buttons in one row, at the right.
{ label: 'Buttons in one row', className: 'flex-row justify-end' },
]
export default function SheetFooterLayouts() {
return (
<>
{FOOTERS.map((footer) => (
<Sheet key={footer.label}>
<SheetTrigger asChild>
<Button>{footer.label}</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Export invoices</SheetTitle>
<SheetDescription>The export has the invoices of March 2026.</SheetDescription>
</SheetHeader>
<SheetBody className="text-[13px] text-foreground-light">You get an email with a link to the CSV file.</SheetBody>
<SheetFooter className={footer.className}>
<SheetClose asChild>
<Button>Cancel</Button>
</SheetClose>
<SheetClose asChild>
<Button variant="primary">Export</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
))}
</>
)
}Put long content in SheetBody. The body scrolls. The header and the footer stay in view.
import {
Button,
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from 'ferry-ui'
const MEMBERS = ['Maya Chen', 'Sam Lee', 'Ada Park']
const EVENTS = ['changed the billing address', 'invited a new member', 'renamed a project', 'exported the invoices']
const ACTIVITY = Array.from({ length: 24 }, (_, index) => ({
id: index,
who: MEMBERS[index % MEMBERS.length],
what: EVENTS[index % EVENTS.length],
when: `${index + 2} min ago`,
}))
export default function SheetScroll() {
return (
<Sheet>
<SheetTrigger asChild>
<Button>Show activity</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Activity</SheetTitle>
<SheetDescription>The last changes in this workspace.</SheetDescription>
</SheetHeader>
{/* `gap-0 p-0` lets the list touch the edges of the panel. */}
<SheetBody className="gap-0 p-0">
<ul className="divide-y">
{ACTIVITY.map((item) => (
<li key={item.id} className="flex flex-col gap-0.5 px-4 py-2.5">
<span className="text-[13px] text-foreground">
<span className="font-medium">{item.who}</span> {item.what}
</span>
<span className="text-xs text-foreground-lighter">{item.when}</span>
</li>
))}
</ul>
</SheetBody>
<SheetFooter>
<Button>Load older activity</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}A sheet holds its open state by default. Use defaultOpen to open it at the start.
To control the state, pass open and onOpenChange. Use this to open the sheet from code, or to close it after an action.
import * as React from 'react'
import {
Button,
Checkbox,
Label,
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
toast,
} from 'ferry-ui'
import { ListFilter } from 'lucide-react'
const STATUSES = ['Paid', 'Open', 'Overdue']
export default function SheetControlled() {
const [open, setOpen] = React.useState(false)
function apply() {
toast.success('Filters applied')
// The work is done: close the sheet from the code.
setOpen(false)
}
return (
<>
<Button icon={<ListFilter />} onClick={() => setOpen(true)}>
Filters
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
<SheetDescription>Show only the invoices with these statuses.</SheetDescription>
</SheetHeader>
<SheetBody>
{STATUSES.map((status) => (
<Label key={status}>
<Checkbox defaultChecked={status !== 'Paid'} />
{status}
</Label>
))}
</SheetBody>
<SheetFooter>
<Button variant="primary" onClick={apply}>
Apply filters
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</>
)
}Set showCloseButton={false} to remove the close button. Then put a SheetClose in the footer.
import {
Button,
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from 'ferry-ui'
export default function SheetNoCloseButton() {
return (
<Sheet>
<SheetTrigger asChild>
<Button>Read the terms</Button>
</SheetTrigger>
<SheetContent showCloseButton={false}>
<SheetHeader>
<SheetTitle>Terms of service</SheetTitle>
<SheetDescription>The version of March 2026.</SheetDescription>
</SheetHeader>
<SheetBody className="text-[13px] text-foreground-light">
<p>Each member of the workspace must accept the terms before the first invoice.</p>
<p>An admin can read the accepted version in the settings of the workspace.</p>
</SheetBody>
{/* The panel has no close button: the footer gives the way out. */}
<SheetFooter>
<SheetClose asChild>
<Button variant="primary">Done</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}closeLabel sets the accessible name of the close button. The default is "Close".SheetDescription, pass aria-describedby={undefined} to SheetContent.Each part also accepts the props of its Radix UI primitive and the attributes of its element.
| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
| See the element or the Radix UI primitive this part renders. | ||
open | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
defaultOpen | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
onOpenChange | ((open: boolean) => void) | - |
| See the element or the Radix UI primitive this part renders. | ||
modal | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
| Prop | Type | Default |
|---|---|---|
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
| Prop | Type | Default |
|---|---|---|
side | "top" | "right" | "bottom" | "left" | right |
Edge the panel slides in from. | ||
showCloseButton | boolean | true |
Renders the top-right close (X) button. Default | ||
closeLabel | string | Close |
Accessible name of the built-in close (X) button. Default | ||
forceMount | true | - |
Used to force mounting when more control is needed. Useful when controlling animation with React animation libraries. | ||
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
deferPointerDownOutside | boolean | - |
When | ||
onEscapeKeyDown | ((event: KeyboardEvent) => void) | - |
Event handler called when the escape key is down. Can be prevented. | ||
onPointerDownOutside | ((event: PointerDownOutsideEvent) => void) | - |
Event handler called when the a | ||
onFocusOutside | ((event: FocusOutsideEvent) => void) | - |
Event handler called when the focus moves outside of the | ||
onInteractOutside | ((event: FocusOutsideEvent | PointerDownOutsideEvent) => void) | - |
Event handler called when an interaction happens outside the | ||
onOpenAutoFocus | ((event: Event) => void) | - |
Event handler called when auto-focusing on open. Can be prevented. | ||
onCloseAutoFocus | ((event: Event) => void) | - |
Event handler called when auto-focusing on close. Can be prevented. | ||
SheetHeader has no props of its own. It accepts the attributes of the element it renders.
| Prop | Type | Default |
|---|---|---|
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
| Prop | Type | Default |
|---|---|---|
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
SheetBody has no props of its own. It accepts the attributes of the element it renders.
SheetFooter has no props of its own. It accepts the attributes of the element it renders.
| Prop | Type | Default |
|---|---|---|
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||