<bpdm/ui />
Overlay

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

drawer-usage.tsx
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>
  );
}
notifications.ts
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 {}
notifications.html
<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.

drawer-sides.tsx
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" />
    </>
  );
}
drawer-sides.ts
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 {}
drawer-sides.html
<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.

drawer-sizes.tsx
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" />
    </>
  );
}
drawer-sizes.ts
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 {}
drawer-sizes.html
<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.

drawer-form.tsx
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>
  );
}
edit-profile.ts
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 {}
edit-profile.html
<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).

drawer-controlled.tsx
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>
    </>
  );
}
controlled.ts
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);
}
controlled.html
<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 no title is set. Default "Drawer".

  • React — pass messages to <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:

drawer-i18n.tsx
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>
  );
}
workspace-settings.ts
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',
  };
}
workspace-settings.html
<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

PropTypeDefaultDescription
sideleft | right | top | bottomrightPhysical edge to slide in from. Angular: [side].
sizesm | md | lg | xl | fullmdWidth (left/right) or height (top/bottom). Angular: [size].
open / defaultOpenbooleanControlled / uncontrolled open. Angular: two-way [(open)].
onOpenChange(open: boolean) => voidReact — fires on open/close. Angular: covered by [(open)].
triggerReactNodeElement that opens it (React). Angular: the [bpdmDrawerTrigger] directive on your trigger.
titleReactNodeHeading (a visually-hidden fallback from messages.drawerLabel is used if omitted). Angular: [title].
descriptionReactNodeSub-heading under the title. Angular: [description].
childrenReactNodeBody content (React). Angular: the ng-template[bpdmDrawerBody].
footerReactNodeFooter area (React). Angular: the ng-template[bpdmDrawerFooter].
showClosebooleantrueShow the top-right close (X) button. Angular: [showClose].
classNamestringReact — extra classes on the panel.
messagesPartial<DrawerMessages>EnglishOverride 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 messages prop on <Drawer>.
  • Angular — the [messages] input on <bpdm-drawer>.
DrawerMessages keyDefaultDescription
closeCloseAccessible name (aria-label) for the close (X) button.
drawerLabelDrawerVisually-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 title is the drawer's accessible name (aria-labelledby); the description, when set, is linked via aria-describedby.
  • When no title is given, a visually-hidden fallback name (from messages.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 under dir="rtl".

On this page