Drawer
Component for showing additional content alongside the main page.
The <Drawer> is a slide-in panel that hosts a supplementary, in-context task alongside the main view, without blocking it. It is non-modal on desktop, falls back to a modal sheet on small screens, and is intended for light, single-step work.
Anatomy
A <Drawer> slides in alongside the main page content. It is composed of an optional title, an optional close button, the main content area, and a row of actions.
- Page content: The main view that stays visible and interactive while the drawer is open (non-modal on desktop).
- Drawer: The slide-in panel itself, anchored to the side of the screen and hosting the supplementary task.
- Title: Optional heading at the top of the panel that gives context for the task inside.
- Close button: Optional dismiss control in the top corner, opt in via the
closeButtonprop. - Content: The body of the drawer, holding the main message or interactive elements like a form.
- Actions: Bottom row of buttons for confirming or cancelling the task.
Appearance
The appearance of a component can be customized using the variant and size props. These props adjust the visual style and dimensions of the component, available values are based on the active theme.
| Property | Type | Description |
|---|---|---|
variant | - | The available variants of this component. |
size | xsmall | small | medium | The available sizes of this component. |
Usage
A drawer hosts a supplementary, in-context task that benefits from staying alongside the main view. Three constraints define it:
- It stays non-modal on desktop, so the page behind remains interactive.
- Its content is about what is already on screen.
- It caps task complexity at roughly five fields and a single information layer.
When in doubt, ask: is this content about the visible main view, or for it? About means a drawer fits. For means a dialog or an inline UI is the better fit.
Do
Use a drawer when
- users inspect or adjust something already on screen,
- the main workflow should stay uninterrupted,
- the task is light: roughly five fields, a single information layer.
Don't
Avoid a drawer when
- critical confirmations or destructive actions are required,
- the flow has multiple steps, multiple save points, or its own internal navigation, which is a page,
- the task requires full attention or a shareable URL.
Drawer vs page
The call teams most often get wrong is choosing a drawer for content that really belongs on a page. It comes down to how many information layers the content has.
- Single layer → drawer. One coherent set of fields or content the user works through linearly, without needing to navigate inside it. A record's details, a small edit form, a filter panel, a comments feed.
- Multiple layers → page. Content that splits into separately navigable areas: tabs across different aspects, nested editable records, drill-down sub-views, or multi-step flows where each step has its own content.
A drawer slides over the current view and is temporary (opened, used, dismissed). A page replaces it or routes somewhere new and is lasting, with its own URL, browser history, and place in the app's information architecture. Multi-layer content needs that durability to keep users oriented.
Reach for a page over a drawer when:
- the user needs a URL to share, bookmark, or return to later,
- the content needs tabs or internal navigation inside the panel,
- the form has nested editable records (line items, sub-tasks with their own forms),
- the flow has multiple steps with their own commit moments (save here, then save again there),
- the user could reasonably get lost inside it without a back button or breadcrumbs to orient them.
Drawer vs Dialog
A user is filling out a "Create event" form and needs to pick a category that doesn't exist yet. The temptation is to slide in a drawer for "Create category" so the half-filled form stays visible behind it. This is exactly where a dialog fits better than a drawer: the new category is for that form, not about the page.
This is the canonical drawer-vs-dialog confusion. The underlying difference: a dialog is modal, blocking the page and asking the user to commit, acknowledge, or decide before continuing. A drawer is non-modal, so the page behind stays interactive while the user inspects, refines, or augments.
Choose a dialog when the user must acknowledge or confirm before moving on, especially for destructive actions. The same applies when the task simply demands full attention and interacting with the page behind would be a distraction. Choose a drawer when the work is supplementary to whatever is already on screen.
Detail-row inspection
The user's task here is "scan rows, drill into one." Keeping the list visible preserves orientation, while routing away breaks the rhythm.
Click a row in a table or list and a drawer slides in showing the record's full details. Fields are read-only or support small inline edits (a status toggle, a quick rename, picking a value from a list), and the user can move on to the next row without losing the table view.
Ticket | Priority | Status | Assignee | Action |
|---|---|---|---|---|
#4521 – Login issue | High | Open | Jane Doe | |
#4520 – Email notifications not sent | Medium | In Progress | Marco Lee | |
#4519 – Dashboard loading slowly | Low | Open | Priya Singh |
import { Button, Drawer, Inline, Stack, Table, Text,} from '@marigold/components';import type { DrawerProps } from '@marigold/components';type Ticket = { id: string; subject: string; priority: 'High' | 'Medium' | 'Low'; status: 'Open' | 'In Progress' | 'Resolved'; assignee: string; created: string; updated: string; description: string; customerNote: string;};const tickets: Ticket[] = [ { id: '4521', subject: 'Login issue', priority: 'High', status: 'Open', assignee: 'Jane Doe', created: 'Sep 12, 2025', updated: 'Sep 15, 2025', description: 'User reports being unable to log in after the latest update. Error: "Invalid session token."', customerNote: '"I tried resetting my password, but I’m still locked out."', }, { id: '4520', subject: 'Email notifications not sent', priority: 'Medium', status: 'In Progress', assignee: 'Marco Lee', created: 'Sep 11, 2025', updated: 'Sep 14, 2025', description: 'Order confirmation emails are not arriving for customers using gmail.com addresses.', customerNote: '"Two of my recent orders never came through to my inbox."', }, { id: '4519', subject: 'Dashboard loading slowly', priority: 'Low', status: 'Open', assignee: 'Priya Singh', created: 'Sep 10, 2025', updated: 'Sep 12, 2025', description: 'Dashboard takes 8–10 seconds to load on first visit. Subsequent loads are normal.', customerNote: '"It used to be much faster a few weeks ago."', },];const TicketDrawer = ({ ticket, ...props}: { ticket: Ticket } & DrawerProps) => ( <Drawer.Trigger> <Button variant="secondary" size="small"> View </Button> <Drawer {...props} size="medium"> <Drawer.Title> Ticket #{ticket.id} – {ticket.subject} </Drawer.Title> <Drawer.Content> <Stack space="group"> <Text> <strong>Description:</strong> {ticket.description} </Text> <Stack space="related"> <Text> <strong>Status:</strong> {ticket.status} </Text> <Text> <strong>Priority:</strong> {ticket.priority} </Text> <Text> <strong>Assigned to:</strong> {ticket.assignee} </Text> <Text> <strong>Created:</strong> {ticket.created} </Text> <Text> <strong>Last Updated:</strong> {ticket.updated} </Text> </Stack> <Text> <strong>Customer Notes:</strong> {ticket.customerNote} </Text> </Stack> </Drawer.Content> <Drawer.Actions> <Inline space="regular"> <Button slot="close">Close</Button> <Button slot="close" variant="primary"> Resolve Ticket </Button> </Inline> </Drawer.Actions> </Drawer> </Drawer.Trigger>);export default function (props: DrawerProps) { return ( <Table aria-label="Support tickets"> <Table.Header> <Table.Column rowHeader>Ticket</Table.Column> <Table.Column>Priority</Table.Column> <Table.Column>Status</Table.Column> <Table.Column>Assignee</Table.Column> <Table.Column>Action</Table.Column> </Table.Header> <Table.Body> {tickets.map(ticket => ( <Table.Row key={ticket.id} id={ticket.id}> <Table.Cell> #{ticket.id} – {ticket.subject} </Table.Cell> <Table.Cell>{ticket.priority}</Table.Cell> <Table.Cell>{ticket.status}</Table.Cell> <Table.Cell>{ticket.assignee}</Table.Cell> <Table.Cell> <TicketDrawer ticket={ticket} {...props} /> </Table.Cell> </Table.Row> ))} </Table.Body> </Table> );}Quick edit or create from a list
Users often need to make a small change to a list, like editing one row or adding a new one, without losing their place. A drawer alongside the list handles both: a small form slides in, the user saves, the drawer closes, and the list reflects the change.
The form should be small: a handful of fields, a single save, no multi-step flow. The list stays visible behind the drawer, so the surrounding rows, filters, and sort order tell the user why they are working on this one. The "roughly five fields" rule of thumb is a proxy for the underlying constraint: a single information layer. The moment the form needs nested records or tabs to organize its fields, it has crossed into page territory.
The two flows share the same shape but differ in trigger and result:
| Quick edit | Quick create | |
|---|---|---|
| Trigger | "Edit" on a row | "Add" above the list |
| Result | The row updates in place | A new row is added to the list |
| Outgrows the drawer when | The edit needs nested records or multi-step validation | Setup is multi-step or has its own sub-records |
For the related case of creating an object that belongs to a different form on the page (e.g. a category needed inside an "Edit event" form), see Drawer vs Dialog.
Members
Name | Role | Email | Action |
|---|---|---|---|
Anna Müller | Designer | anna@example.com | |
Tom Becker | Developer | tom@example.com | |
Sara Klein | PM | sara@example.com |
import { Button, Drawer, Headline, Inline, Select, Stack, Table, TextField,} from '@marigold/components';type Member = { id: string; name: string; role: string; email: string;};const members: Member[] = [ { id: '1', name: 'Anna Müller', role: 'Designer', email: 'anna@example.com', }, { id: '2', name: 'Tom Becker', role: 'Developer', email: 'tom@example.com', }, { id: '3', name: 'Sara Klein', role: 'PM', email: 'sara@example.com', },];const EditDrawer = ({ member }: { member: Member }) => ( <Drawer.Trigger> <Button variant="secondary" size="small"> Edit </Button> <Drawer> <Drawer.Title>Edit member</Drawer.Title> <Drawer.Content> <Stack space="regular"> <TextField label="Name" defaultValue={member.name} /> <Select label="Role" defaultSelectedKey={member.role}> <Select.Option id="Designer">Designer</Select.Option> <Select.Option id="Developer">Developer</Select.Option> <Select.Option id="PM">PM</Select.Option> </Select> <TextField label="Email" defaultValue={member.email} /> </Stack> </Drawer.Content> <Drawer.Actions> <Button slot="close">Cancel</Button> <Button slot="close" variant="primary"> Save changes </Button> </Drawer.Actions> </Drawer> </Drawer.Trigger>);const AddDrawer = () => ( <Drawer.Trigger> <Button>+ Add member</Button> <Drawer> <Drawer.Title>Add member</Drawer.Title> <Drawer.Content> <Stack space="regular"> <TextField label="Name" placeholder="Full name" /> <Select label="Role" placeholder="Pick a role"> <Select.Option id="Designer">Designer</Select.Option> <Select.Option id="Developer">Developer</Select.Option> <Select.Option id="PM">PM</Select.Option> </Select> <TextField label="Email" placeholder="email@example.com" /> </Stack> </Drawer.Content> <Drawer.Actions> <Button slot="close">Cancel</Button> <Button slot="close" variant="primary"> Add member </Button> </Drawer.Actions> </Drawer> </Drawer.Trigger>);export default function () { return ( <Stack space="regular"> <Inline alignX="between" alignY="center"> <Headline level={3}>Members</Headline> <AddDrawer /> </Inline> <Table aria-label="Members"> <Table.Header> <Table.Column rowHeader>Name</Table.Column> <Table.Column>Role</Table.Column> <Table.Column>Email</Table.Column> <Table.Column>Action</Table.Column> </Table.Header> <Table.Body> {members.map(m => ( <Table.Row key={m.id} id={m.id}> <Table.Cell>{m.name}</Table.Cell> <Table.Cell>{m.role}</Table.Cell> <Table.Cell>{m.email}</Table.Cell> <Table.Cell> <EditDrawer member={m} /> </Table.Cell> </Table.Row> ))} </Table.Body> </Table> </Stack> );}Filter panel
A collection of filter controls displayed as a side panel. The user adjusts, applies, and results update behind the panel. Non-modality is the whole point: users iterate filters and want to see the table change.
See the filter pattern for end-to-end guidance, including applied-filter display.
Comments, activity, and history
The user's task here is "what's happened to this, and what should I add to it?" Long-lived records like tickets, pull requests, and issues accumulate context: comments, status changes, assignments, audit entries. A drawer slides in alongside the record and holds the whole feed, so users can scan the history, catch up on a thread, or post a reply without leaving the main view.
The pattern fits a drawer because:
- the feed grows long and needs its own scroll,
- users want the main record visible while reading or replying,
- activity is something to glance at occasionally, not the focus of the page.
If activity is the primary task (a chat-first product, for example), it belongs in the main column, not in a drawer.
Ticket #4521 – Login issue
import { Badge, Button, Card, Drawer, Headline, Inline, Stack, Text, TextField,} from '@marigold/components';import type { DrawerProps } from '@marigold/components';export default function (props: DrawerProps) { return ( <Card> <Stack space="regular"> <Stack space="regular"> <Inline space="related" alignY="center"> <Headline level={3}>Ticket #4521 – Login issue</Headline> <Badge variant="warning">High priority</Badge> </Inline> <Text> User reports being unable to log in after the latest update. Error:{' '} <em>"Invalid session token."</em> </Text> <Text> Assigned to <strong>Jane Doe</strong> · Updated 2 hours ago </Text> </Stack> <Inline> <Drawer.Trigger> <Button variant="secondary">View activity (3)</Button> <Drawer {...props} size="medium"> <Drawer.Title>Activity · Ticket #4521</Drawer.Title> <Drawer.Content> <Stack space="group"> <Stack space="tight"> <Inline space="related" alignY="center"> <Text weight="bold">Jane Doe</Text> <Text>changed status to Open</Text> </Inline> <Text>2 hours ago</Text> </Stack> <Stack space="tight"> <Inline space="related" alignY="center"> <Text weight="bold">Sam Müller</Text> <Text>commented</Text> </Inline> <Text> Customer reset their password but is still locked out. Asked them to clear cookies and try again. </Text> <Text>4 hours ago</Text> </Stack> <Stack space="tight"> <Inline space="related" alignY="center"> <Text weight="bold">Jane Doe</Text> <Text>assigned to Sam Müller</Text> </Inline> <Text>Yesterday</Text> </Stack> <TextField label="Add a comment" placeholder="Write a reply…" /> </Stack> </Drawer.Content> <Drawer.Actions> <Button slot="close">Close</Button> <Button slot="close" variant="primary"> Post comment </Button> </Drawer.Actions> </Drawer> </Drawer.Trigger> </Inline> </Stack> </Card> );}Contextual utility
Persistent or summon-able utilities that support the main task without being part of it: in-app help, support chat, AI assistant, scratchpad. These utilities live alongside the main work, available when needed but never the focus.
The right shape is a drawer the user can pin or summon, not a modal or a popover. It opens when needed and never takes focus from the main task.
import { Accordion, Button, Drawer, Stack, Text } from '@marigold/components';import type { DrawerProps } from '@marigold/components';export default function (props: DrawerProps) { return ( <Drawer.Trigger> <Button>Open Help</Button> <Drawer {...props}> <Drawer.Title>Quick Help</Drawer.Title> <Drawer.Content> <Stack space="regular"> <Text> Common questions while handling tickets. Expand a section for the full answer. </Text> <Accordion> <Accordion.Item id="reset-password"> <Accordion.Header> How do I reset a user's password? </Accordion.Header> <Accordion.Content> Open the user record, click <strong>Account</strong>, then{' '} <strong>Send password reset</strong>. The user receives an email with a one-time link valid for 24 hours. </Accordion.Content> </Accordion.Item> <Accordion.Item id="escalation"> <Accordion.Header> When should I escalate a ticket? </Accordion.Header> <Accordion.Content> Escalate any ticket marked <strong>High priority</strong> that has been open longer than four hours, or any ticket flagged by the customer as a security concern. </Accordion.Content> </Accordion.Item> <Accordion.Item id="login-loop"> <Accordion.Header> Customer is stuck in a login loop. What now? </Accordion.Header> <Accordion.Content> Most login loops resolve when the customer clears site cookies and reauthenticates. If that fails, verify their SSO provider status before assigning a developer. </Accordion.Content> </Accordion.Item> <Accordion.Item id="refund"> <Accordion.Header>How do I issue a refund?</Accordion.Header> <Accordion.Content> Refunds under €50 can be issued from the order detail page. Anything above that amount needs manager approval. Flag the ticket with <strong>refund-review</strong>. </Accordion.Content> </Accordion.Item> </Accordion> </Stack> </Drawer.Content> <Drawer.Actions> <Button slot="close">Close</Button> </Drawer.Actions> </Drawer> </Drawer.Trigger> );}Multi-field bulk edit
Users select rows in a table, an ActionBar appears, and one of its actions opens a drawer with a small form for editing several fields across the selection (for example, changing the date and venue across many events at once).
The drawer keeps the selected rows visible so users can confirm the selection while editing. Bulk edit stays within the single-layer rule because the fields are flat and identical across all selected records: one form applied to N rows, not N forms or nested edits. The boundary is clean: single-verb bulk actions (delete, archive, assign) belong in the action bar, while multi-field bulk edit belongs in a drawer triggered from it. Destructive bulk actions still need a confirm dialog after the verb.
Event | Date | Venue | Status | |
|---|---|---|---|---|
Spring Gala | Apr 15, 2025 | Freiburg | Confirmed | |
Jazz Night | May 2, 2025 | Berlin | Confirmed | |
Open Air Theater | Jun 10, 2025 | Hamburg | On Sale | |
Summer Festival | Jul 22, 2025 | Berlin | Confirmed |
import { Pencil } from 'lucide-react';import { ActionBar, Badge, Button, Checkbox, DatePicker, Drawer, Select, Stack, Table, Text,} from '@marigold/components';import type { DrawerProps } from '@marigold/components';const events = [ { id: '1', name: 'Spring Gala', date: 'Apr 15, 2025', venue: 'Freiburg', status: 'Confirmed', }, { id: '2', name: 'Jazz Night', date: 'May 2, 2025', venue: 'Berlin', status: 'Confirmed', }, { id: '3', name: 'Open Air Theater', date: 'Jun 10, 2025', venue: 'Hamburg', status: 'On Sale', }, { id: '4', name: 'Summer Festival', date: 'Jul 22, 2025', venue: 'Berlin', status: 'Confirmed', },];export default function (props: DrawerProps) { return ( <Table aria-label="Events" selectionMode="multiple" defaultSelectedKeys={new Set(['1', '2', '3'])} actionBar={() => ( <ActionBar> <Drawer.Trigger> <ActionBar.Button> <Pencil /> Edit </ActionBar.Button> <Drawer {...props} size="medium"> <Drawer.Title>Edit selected events</Drawer.Title> <Drawer.Content> <Stack space="regular"> <Text> Changes will apply to all selected events. Empty fields stay unchanged. </Text> <DatePicker label="Event date" /> <Select label="Venue" placeholder="Choose a venue"> <Select.Option id="freiburg">Freiburg</Select.Option> <Select.Option id="berlin">Berlin</Select.Option> <Select.Option id="hamburg">Hamburg</Select.Option> <Select.Option id="online">Online</Select.Option> </Select> <Checkbox label="Notify attendees of changes" /> </Stack> </Drawer.Content> <Drawer.Actions> <Button slot="close">Cancel</Button> <Button slot="close" variant="primary"> Apply changes </Button> </Drawer.Actions> </Drawer> </Drawer.Trigger> </ActionBar> )} > <Table.Header> <Table.Column rowHeader>Event</Table.Column> <Table.Column>Date</Table.Column> <Table.Column>Venue</Table.Column> <Table.Column>Status</Table.Column> </Table.Header> <Table.Body> {events.map(event => ( <Table.Row key={event.id} id={event.id}> <Table.Cell>{event.name}</Table.Cell> <Table.Cell>{event.date}</Table.Cell> <Table.Cell>{event.venue}</Table.Cell> <Table.Cell> <Badge>{event.status}</Badge> </Table.Cell> </Table.Row> ))} </Table.Body> </Table> );}Accessibility
A drawer renders with the ARIA role complementary by default, which fits almost every use case in this guide. Two exceptions are worth knowing:
- For a filter panel, consider
role="search"to give screen-reader users a more precise landmark. - For long-lived activity panels, consider
role="region"with anaria-labelif the panel is part of the persistent layout.
On small screens, the drawer falls back to a modal sheet that traps focus inside the panel. On desktop the drawer is non-modal, so focus only returns to the trigger when the drawer closes. Dismissal on Escape is handled automatically.
Props
Drawer
Prop
Type
Accessibility props (5)
Prop
Type
DOM event handlers (64)
Prop
Type
Drawer.Trigger
Prop
Type
Drawer.Title
Prop
Type
Drawer.Content
Prop
Type
Drawer.Actions
Prop
Type
Alternative components
- Dialog: Use for modal, attention-required moments like confirmations, destructive actions, and parent-form quick-create.
- ActionBar: Use for single-verb bulk actions on a selection (delete, archive, assign). Pair with a drawer when bulk-editing several fields.
- Menu: Use for a small set of toggles or quick choices, like table column settings, sort order, or density. A drawer is overkill when the controls are a handful of options that fit inline next to their trigger.
- Sidebar: Use for app-level navigation, not for in-context content alongside a record.