Drawer
A slide-in panel (sheet) from any edge — focus-trapped, scroll-locked, in React (@bpdm/ui) and Angular (@bpdm/ng).
The Drawer is a slide-in panel ("sheet") built on Radix Dialog — focus trap,
scroll lock, Esc and outside-click to close, full ARIA. It slides in from any
edge. Use the low-config Drawer (pass a trigger, side, title, body and
footer), or compose DrawerRoot / DrawerContent. In Angular it's
<bpdm-drawer> with [bpdmDrawerTrigger] / [bpdmDrawerBody] / [bpdmDrawerFooter].
Usage
import { Drawer, DrawerClose } from '@bpdm/ui/drawer';
import { Button } from '@bpdm/ui/button';
export function Notifications() {
return (
<Drawer
trigger={<Button>Open drawer</Button>}
title="Notifications"
description="Your latest activity."
footer={
<DrawerClose asChild>
<Button>Mark all read</Button>
</DrawerClose>
}
>
<p>A slide-in panel from the right edge.</p>
</Drawer>
);
}import { Component } from '@angular/core';
import { BpdmDrawer, BpdmDrawerTrigger, BpdmDrawerBody, BpdmDrawerFooter, BpdmDrawerClose, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'notifications',
imports: [BpdmDrawer, BpdmDrawerTrigger, BpdmDrawerBody, BpdmDrawerFooter, BpdmDrawerClose, BpdmButton],
templateUrl: './notifications.html',
})
export class Notifications {}<bpdm-drawer title="Notifications" description="Your latest activity.">
<button bpdmButton bpdmDrawerTrigger>Open drawer</button>
<ng-template bpdmDrawerBody>
<p>A slide-in panel from the right edge.</p>
</ng-template>
<ng-template bpdmDrawerFooter>
<button bpdmButton bpdmDrawerClose>Mark all read</button>
</ng-template>
</bpdm-drawer>Sides
The side prop chooses the edge: right (default), left, top, or bottom.
import { Drawer } from '@bpdm/ui/drawer';
import { Button } from '@bpdm/ui/button';
export function Sides() {
return (
<>
<Drawer side="left" trigger={<Button>Left</Button>} title="Left drawer" />
<Drawer side="right" trigger={<Button>Right</Button>} title="Right drawer" />
<Drawer side="top" trigger={<Button>Top</Button>} title="Top drawer" />
<Drawer side="bottom" trigger={<Button>Bottom</Button>} title="Bottom drawer" />
</>
);
}import { Component } from '@angular/core';
import { BpdmDrawer, BpdmDrawerTrigger, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'drawer-sides',
imports: [BpdmDrawer, BpdmDrawerTrigger, BpdmButton],
templateUrl: './drawer-sides.html',
})
export class DrawerSides {}<bpdm-drawer side="left" title="Left drawer"><button bpdmButton bpdmDrawerTrigger>Left</button></bpdm-drawer>
<bpdm-drawer side="right" title="Right drawer"><button bpdmButton bpdmDrawerTrigger>Right</button></bpdm-drawer>
<bpdm-drawer side="top" title="Top drawer"><button bpdmButton bpdmDrawerTrigger>Top</button></bpdm-drawer>
<bpdm-drawer side="bottom" title="Bottom drawer"><button bpdmButton bpdmDrawerTrigger>Bottom</button></bpdm-drawer>Sizes
The size prop sets the panel's width (left/right) or height (top/bottom):
sm, md (default), lg, xl, full.
import { Drawer } from '@bpdm/ui/drawer';
import { Button } from '@bpdm/ui/button';
export function Sizes() {
return (
<>
<Drawer size="sm" trigger={<Button>SM</Button>} title="SM drawer" />
<Drawer size="md" trigger={<Button>MD</Button>} title="MD drawer" />
<Drawer size="lg" trigger={<Button>LG</Button>} title="LG drawer" />
<Drawer size="xl" trigger={<Button>XL</Button>} title="XL drawer" />
<Drawer size="full" trigger={<Button>Full</Button>} title="Full drawer" />
</>
);
}import { Component } from '@angular/core';
import { BpdmDrawer, BpdmDrawerTrigger, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'drawer-sizes',
imports: [BpdmDrawer, BpdmDrawerTrigger, BpdmButton],
templateUrl: './drawer-sizes.html',
})
export class DrawerSizes {}<bpdm-drawer size="sm" title="SM drawer"><button bpdmButton bpdmDrawerTrigger>SM</button></bpdm-drawer>
<bpdm-drawer size="md" title="MD drawer"><button bpdmButton bpdmDrawerTrigger>MD</button></bpdm-drawer>
<bpdm-drawer size="lg" title="LG drawer"><button bpdmButton bpdmDrawerTrigger>LG</button></bpdm-drawer>
<bpdm-drawer size="xl" title="XL drawer"><button bpdmButton bpdmDrawerTrigger>XL</button></bpdm-drawer>
<bpdm-drawer size="full" title="Full drawer"><button bpdmButton bpdmDrawerTrigger>Full</button></bpdm-drawer>With a form
A common use — an edit panel with fields and a Save footer.
import { Drawer, DrawerClose } from '@bpdm/ui/drawer';
import { Button } from '@bpdm/ui/button';
import { Input } from '@bpdm/ui/input';
export function EditProfile() {
return (
<Drawer
trigger={<Button>Edit profile</Button>}
title="Edit profile"
description="Update your details, then save."
footer={
<>
<DrawerClose asChild>
<Button variant="secondary" appearance="ghost">Cancel</Button>
</DrawerClose>
<DrawerClose asChild>
<Button>Save</Button>
</DrawerClose>
</>
}
>
<div className="flex flex-col gap-3">
<Input placeholder="Display name" aria-label="Display name" />
<Input type="email" placeholder="Email" aria-label="Email" />
</div>
</Drawer>
);
}import { Component } from '@angular/core';
import { BpdmDrawer, BpdmDrawerTrigger, BpdmDrawerBody, BpdmDrawerFooter, BpdmDrawerClose, BpdmButton, BpdmInput } from '@bpdm/ng';
@Component({
selector: 'edit-profile',
imports: [BpdmDrawer, BpdmDrawerTrigger, BpdmDrawerBody, BpdmDrawerFooter, BpdmDrawerClose, BpdmButton, BpdmInput],
templateUrl: './edit-profile.html',
})
export class EditProfile {}<bpdm-drawer title="Edit profile" description="Update your details, then save.">
<button bpdmButton bpdmDrawerTrigger>Edit profile</button>
<ng-template bpdmDrawerBody>
<div class="flex flex-col gap-3">
<input bpdmInput placeholder="Display name" aria-label="Display name" />
<input bpdmInput type="email" placeholder="Email" aria-label="Email" />
</div>
</ng-template>
<ng-template bpdmDrawerFooter>
<button bpdmButton variant="secondary" appearance="ghost" bpdmDrawerClose>Cancel</button>
<button bpdmButton bpdmDrawerClose>Save</button>
</ng-template>
</bpdm-drawer>Controlled
Drive the open state with open + onOpenChange (React) or two-way [(open)]
(Angular).
import { useState } from 'react';
import { Drawer } from '@bpdm/ui/drawer';
import { Button } from '@bpdm/ui/button';
export function Controlled() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Open from outside</Button>
<Drawer
open={open}
onOpenChange={setOpen}
title="Controlled drawer"
description="Driven by your own state."
footer={<Button onClick={() => setOpen(false)}>Close</Button>}
>
<p>Open it from anywhere.</p>
</Drawer>
</>
);
}import { Component, signal } from '@angular/core';
import { BpdmDrawer, BpdmDrawerFooter, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'controlled',
imports: [BpdmDrawer, BpdmDrawerFooter, BpdmButton],
templateUrl: './controlled.html',
})
export class Controlled {
readonly open = signal(false);
}<button bpdmButton (click)="open.set(true)">Open from outside</button>
<bpdm-drawer [(open)]="open" title="Controlled drawer" description="Driven by your own state.">
<ng-template bpdmDrawerFooter>
<button bpdmButton (click)="open.set(false)">Close</button>
</ng-template>
</bpdm-drawer>Internationalization
The drawer carries a little copy of its own — the close (X) button's accessible
name, and a screen-reader fallback title used when you don't pass a title. Set
them via a single messages object; it's a Partial<DrawerMessages> merged over
the English defaults, so override one key or both:
-
close— the close (X) button's accessible name (aria-label). Default"Close". -
drawerLabel— the visually-hidden fallback title (the accessible name) used only when notitleis set. Default"Drawer". -
React — pass
messagesto<Drawer messages={{ … }} />. -
Angular — bind
[messages]on<bpdm-drawer>.
The drawer content, header, footer, and the close (X) button all mirror under
dir="rtl" with no extra prop — the close (X) button, pinned to the inline-end
corner, flips with everything else. The side prop stays a physical edge
(left / right / top / bottom) that you choose explicitly, so pick the edge
that suits your layout in each direction.
The example below localises the built-in copy to German via messages — the close
button and the fallback title — while the drawer's own title and body stay
untouched:
import { Drawer, DrawerClose, type DrawerMessages } from '@bpdm/ui/drawer';
import { Button } from '@bpdm/ui/button';
// German labels for the built-in close button + screen-reader fallback title.
const messages: Partial<DrawerMessages> = {
close: 'Schließen',
drawerLabel: 'Seitenleiste',
};
export function WorkspaceSettings() {
return (
<Drawer
trigger={<Button>Open settings</Button>}
title="Workspace settings"
description="Manage members and defaults."
messages={messages}
footer={
<DrawerClose asChild>
<Button>Save</Button>
</DrawerClose>
}
>
<p>Your workspace preferences live here.</p>
</Drawer>
);
}import { Component } from '@angular/core';
import {
BpdmDrawer,
BpdmDrawerTrigger,
BpdmDrawerBody,
BpdmDrawerFooter,
BpdmDrawerClose,
BpdmButton,
type DrawerMessages,
} from '@bpdm/ng';
@Component({
selector: 'workspace-settings',
imports: [BpdmDrawer, BpdmDrawerTrigger, BpdmDrawerBody, BpdmDrawerFooter, BpdmDrawerClose, BpdmButton],
templateUrl: './workspace-settings.html',
})
export class WorkspaceSettings {
// German labels for the built-in close button + screen-reader fallback title.
readonly messages: Partial<DrawerMessages> = {
close: 'Schließen',
drawerLabel: 'Seitenleiste',
};
}<bpdm-drawer title="Workspace settings" description="Manage members and defaults." [messages]="messages">
<button bpdmButton bpdmDrawerTrigger>Open settings</button>
<ng-template bpdmDrawerBody>
<p>Your workspace preferences live here.</p>
</ng-template>
<ng-template bpdmDrawerFooter>
<button bpdmButton bpdmDrawerClose>Save</button>
</ng-template>
</bpdm-drawer>API
| Prop | Type | Default | Description |
|---|---|---|---|
side | left | right | top | bottom | right | Physical edge to slide in from. Angular: [side]. |
size | sm | md | lg | xl | full | md | Width (left/right) or height (top/bottom). Angular: [size]. |
open / defaultOpen | boolean | — | Controlled / uncontrolled open. Angular: two-way [(open)]. |
onOpenChange | (open: boolean) => void | — | React — fires on open/close. Angular: covered by [(open)]. |
trigger | ReactNode | — | Element that opens it (React). Angular: the [bpdmDrawerTrigger] directive on your trigger. |
title | ReactNode | — | Heading (a visually-hidden fallback from messages.drawerLabel is used if omitted). Angular: [title]. |
description | ReactNode | — | Sub-heading under the title. Angular: [description]. |
children | ReactNode | — | Body content (React). Angular: the ng-template[bpdmDrawerBody]. |
footer | ReactNode | — | Footer area (React). Angular: the ng-template[bpdmDrawerFooter]. |
showClose | boolean | true | Show the top-right close (X) button. Angular: [showClose]. |
className | string | — | React — extra classes on the panel. |
messages | Partial<DrawerMessages> | English | Override the close label / SR fallback title for i18n (see below). Angular: [messages]. |
Primitives (React): DrawerRoot, DrawerTrigger, DrawerClose,
DrawerContent (side, size, showClose), DrawerHeader, DrawerFooter,
DrawerTitle, DrawerDescription.
Directives (Angular): bpdmDrawerTrigger (opens the drawer), bpdmDrawerClose
(dismisses from inside), and the ng-template[bpdmDrawerBody] /
ng-template[bpdmDrawerFooter] content markers.
App-wide copy — messages
Localise the close button and the screen-reader fallback title in one place (see
Internationalization). It's a Partial<DrawerMessages>
merged over the English defaults — override one key or both.
- React — the
messagesprop on<Drawer>. - Angular — the
[messages]input on<bpdm-drawer>.
DrawerMessages key | Default | Description |
|---|---|---|
close | Close | Accessible name (aria-label) for the close (X) button. |
drawerLabel | Drawer | Visually-hidden fallback title (the accessible name) used only when no title is set. |
Accessibility
- It's a modal built on the Dialog machinery —
role="dialog"+aria-modal="true", with a focus trap and scroll lock while it's open. - The
titleis the drawer's accessible name (aria-labelledby); thedescription, when set, is linked viaaria-describedby. - When no
titleis given, a visually-hidden fallback name (frommessages.drawerLabel, default"Drawer") is used, so the dialog always has an accessible name. - The close (X) button carries an accessible name via
messages.close(default"Close"). Esc, a backdrop click, and the close (X) button all dismiss the drawer; the trigger keeps its own role and keyboard behaviour.- Focus moves into the panel on open and returns to the element that opened it — the trigger — on close.
- The panel slides in per edge, but conveys no meaning through motion alone.
- All copy — the close label and the fallback title — is translatable via
messages, and the content, header, footer, and close (X) button all mirror correctly underdir="rtl".