Skip to content
Ndop UI

Drawer

A panel that slides up from the bottom of the screen and can be dragged away, suited to phones.

import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import {
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHandle,
  DrawerHeader,
  DrawerOverlay,
  DrawerTitle,
  Drawer,
  DrawerClose,
  DrawerPortal,
  DrawerTrigger,
} from '@/ui/drawer/drawer';

/**
 * A daily goal set with buttons in a bottom drawer, with a handle to drag
 * it away. The goal is announced as it changes.
 */
@Component({
  selector: 'example-drawer-demo',
  imports: [
    Button,
    DrawerContent,
    DrawerDescription,
    DrawerFooter,
    DrawerHandle,
    DrawerHeader,
    DrawerOverlay,
    DrawerTitle,
    Drawer,
    DrawerClose,
    DrawerPortal,
    DrawerTrigger,
    NdIcon,
  ],
  template: `
    <ng-container ndDrawer>
      <button ndButton variant="outline" ndDrawerTrigger>Set goal</button>
      <ng-template ndDrawerPortal>
        <div ndDrawerOverlay></div>
        <div ndDrawerContent>
          <div ndDrawerHandle></div>
          <div class="mx-auto w-full max-w-sm">
            <div ndDrawerHeader>
              <h2 ndDrawerTitle>Move goal</h2>
              <p ndDrawerDescription>Set your daily activity goal.</p>
            </div>
            <div class="flex items-center justify-center gap-4 p-4">
              <button
                ndButton
                variant="outline"
                size="icon"
                class="rounded-full"
                [disabled]="goal() <= 200"
                (click)="change(-10)"
              >
                <svg ndIcon="minus"></svg>
                <span class="sr-only">Decrease</span>
              </button>
              <p class="text-center" aria-live="polite">
                <span class="block text-6xl font-bold tracking-tighter">{{
                  goal()
                }}</span>
                <span class="text-[0.70rem] text-muted-foreground uppercase">
                  Calories a day
                </span>
              </p>
              <button
                ndButton
                variant="outline"
                size="icon"
                class="rounded-full"
                [disabled]="goal() >= 400"
                (click)="change(10)"
              >
                <svg ndIcon="plus"></svg>
                <span class="sr-only">Increase</span>
              </button>
            </div>
            <div ndDrawerFooter>
              <button ndButton ndDrawerClose>Submit</button>
              <button ndButton variant="outline" ndDrawerClose>Cancel</button>
            </div>
          </div>
        </div>
      </ng-template>
    </ng-container>
  `,
})
export class DrawerDemoExample {
  protected readonly goal = signal(350);

  /**
   * Moves the goal by a step, between 200 and 400.
   *
   * @param step - How much to add.
   */
  protected change(step: number): void {
    this.goal.update((goal) => Math.min(400, Math.max(200, goal + step)));
  }
}

A drawer is a panel that slides up from the bottom of the screen, the way phone apps show options, and can be dragged back down to close. It's a modal dialog underneath, so it's labelled by its title, traps focus while open, and closes with Escape, its backdrop or a close button too. Dragging past a quarter of its height, or flicking it, closes it; a shorter drag springs back. Reduced-motion users get no animation.

Installation

npx @ndop/cli add drawer

Usage

import { Button } from '@/ui/button/button';
import {
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHandle,
  DrawerHeader,
  DrawerOverlay,
  DrawerTitle,
  Drawer,
  DrawerClose,
  DrawerPortal,
  DrawerTrigger,
} from '@/ui/drawer/drawer';
<ng-container ndDrawer>
  <button ndButton variant="outline" ndDrawerTrigger>Set goal</button>
  <ng-template ndDrawerPortal>
    <div ndDrawerOverlay></div>
    <div ndDrawerContent>
      <div ndDrawerHandle></div>
      <div class="mx-auto w-full max-w-sm">
        <div ndDrawerHeader>
          <h2 ndDrawerTitle>Move goal</h2>
          <p ndDrawerDescription>Set your daily activity goal.</p>
        </div>
        <div class="flex items-center justify-center gap-4 p-4">
          <button
            ndButton
            variant="outline"
            size="icon"
            class="rounded-full"
            [disabled]="goal() <= 200"
            (click)="change(-10)"
          >
            <svg ndIcon="minus"></svg>
            <span class="sr-only">Decrease</span>
          </button>
          <p class="text-center" aria-live="polite">
            <span class="block text-6xl font-bold tracking-tighter">{{
              goal()
            }}</span>
            <span class="text-[0.70rem] text-muted-foreground uppercase">
              Calories a day
            </span>
          </p>
          <button
            ndButton
            variant="outline"
            size="icon"
            class="rounded-full"
            [disabled]="goal() >= 400"
            (click)="change(10)"
          >
            <svg ndIcon="plus"></svg>
            <span class="sr-only">Increase</span>
          </button>
        </div>
        <div ndDrawerFooter>
          <button ndButton ndDrawerClose>Submit</button>
          <button ndButton variant="outline" ndDrawerClose>Cancel</button>
        </div>
      </div>
    </div>
  </ng-template>
</ng-container>

Examples

Drawer directions

Drawers from the end side and the top.

import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import {
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerOverlay,
  DrawerTitle,
  Drawer,
  DrawerClose,
  DrawerPortal,
  DrawerTrigger,
} from '@/ui/drawer/drawer';

/**
 * Drawers from the end side and the top, each dragged back toward its
 * own edge.
 */
@Component({
  selector: 'example-drawer-directions',
  imports: [
    Button,
    DrawerContent,
    DrawerDescription,
    DrawerFooter,
    DrawerHeader,
    DrawerOverlay,
    DrawerTitle,
    Drawer,
    DrawerClose,
    DrawerPortal,
    DrawerTrigger,
  ],
  template: `
    <div class="flex gap-2">
      @for (direction of directions; track direction) {
        <ng-container ndDrawer>
          <button ndButton variant="outline" ndDrawerTrigger class="capitalize">
            {{ direction }}
          </button>
          <ng-template ndDrawerPortal>
            <div ndDrawerOverlay></div>
            <div ndDrawerContent [direction]="direction">
              <div ndDrawerHeader>
                <h2 ndDrawerTitle class="capitalize">{{ direction }} drawer</h2>
                <p ndDrawerDescription>Drag it toward its edge to close it.</p>
              </div>
              <div ndDrawerFooter>
                <button ndButton variant="outline" ndDrawerClose>Close</button>
              </div>
            </div>
          </ng-template>
        </ng-container>
      }
    </div>
  `,
})
export class DrawerDirectionsExample {
  protected readonly directions = ['end', 'top'] as const;
}

Accessibility

Implements the Dialog (Modal) pattern of the WAI-ARIA Authoring Practices.

Keyboard

Keys the component handles.

Key Action
TabMoves focus to the next focusable element inside the dialog; from the last one, wraps to the first.
ShiftTabMoves focus to the previous focusable element inside the dialog; from the first one, wraps to the last.
EscapeCloses the dialog, unless closeOnEscape is false.
TabMoves focus to the next focusable element inside; wraps from last to first.
ShiftTabMoves focus to the previous focusable element inside; wraps from first to last.
EscapeCloses the drawer, unless closeOnEscape is false.

Focus

  • On open: focus moves to the first focusable element in the content, or to the content element itself when it has none.
  • While open: focus is trapped in the content.
  • On close: focus returns to the element that had it before opening, usually the trigger.

Right-to-left

Nothing mirrors: the dialog is centered, and Tab order follows the DOM. start and end drawers come from the right and left in right-to-left layouts, and are dragged toward those edges. Direction comes from the nearest dir attribute.

API reference

Drawer

[ndDrawer]

A drawer (@ndop/primitives/drawer): a modal panel that slides in from an edge and can be dragged back out. Put it on an ng-container; open is two-way bindable, and the content's direction sets the edge.

Drawer binding Type Default
[defaultOpen]
Initial open state when open is not bound.
booleanfalse
[closeOnEscape]
Whether Escape closes the dialog.
booleantrue
[closeOnOverlayClick]
Whether a click on [ndDialogOverlay] closes the dialog.
booleantrue
[(open)]
Whether the dialog is open. Two-way bindable with [(open)].
booleanfalse

DrawerTrigger

[ndDrawerTrigger]

The button that opens its drawer.

Manages aria-haspopup, aria-expanded, aria-controls.

Data attribute Values
data-slotdrawer-trigger
data-state —

DrawerPortal

ng-template[ndDrawerPortal]

Marks the ng-template its drawer renders while open.

DrawerClose

[ndDrawerClose]

A button that closes its drawer.

Data attribute Values
data-slotdrawer-close

DrawerOverlay

[ndDrawerOverlay]

The dimmed backdrop behind a drawer; clicking it closes the drawer.

Manages aria-hidden.

DrawerOverlay binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdrawer-overlay
data-state —

DrawerContent

[ndDrawerContent]role dialog

The drawer panel (@ndop/primitives/drawer): a modal dialog at the edge its direction names (bottom by default, top, start or end), which follows a drag toward that edge and closes past a quarter of its size. Motion stops for reduced-motion users; the drag still works.

Manages aria-modal, aria-labelledby, aria-describedby.

DrawerContent binding Type Default
[class]
Extra classes, merged last so they win.
string''
[direction]
The edge it slides in from and is dragged back to.
'bottom' | 'top' | 'start' | 'end''bottom'
Data attribute Values
data-slotdrawer-content
data-state —

DrawerHandle

[ndDrawerHandle]

The grip at the top of a bottom drawer, showing it can be dragged; hidden from assistive technology, and on other edges.

Manages aria-hidden.

DrawerHandle binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdrawer-handle

DrawerHeader

[ndDrawerHeader]

Lays out a drawer's title and description, centered on top and bottom drawers on small screens.

DrawerHeader binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdrawer-header

DrawerFooter

[ndDrawerFooter]

Lays out a drawer's buttons at its bottom.

DrawerFooter binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdrawer-footer

DrawerTitle

[ndDrawerTitle]

A drawer's title, which labels it.

DrawerTitle binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdrawer-title

DrawerDescription

[ndDrawerDescription]

A drawer's description.

DrawerDescription binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdrawer-description

Agent notes

Do

  • Use it on small screens, and a Dialog or Sheet on large ones.
  • Keep a close or done button in it: dragging is a shortcut, not the only way out.
  • Show the handle on bottom drawers, so people know they can drag.

Don't

  • Don't put content that scrolls sideways or sliders in it without data-no-drag, or dragging them would move the drawer.
  • Don't use it for content people read for long; use a page.