Drawer
A panel that slides up from the bottom of the screen and can be dragged away, suited to phones.
- v1.0.0
- WAI-ARIA: Dialog (Modal)
- SSR
- Zoneless
- 4.1 kB gzip
- +9.7 kB Angular CDK, shared
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 drawerpnpm dlx @ndop/cli add draweryarn dlx @ndop/cli add drawerbunx @ndop/cli add drawerInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/drawer/drawer.tsimport { computed, Directive, input } from '@angular/core'; import { NdDialog } from '@ndop/primitives/dialog'; import { NdDrawerClose, NdDrawerContent, NdDrawerDescription, NdDrawerHandle, NdDrawerOverlay, NdDrawerPortal, NdDrawerTitle, NdDrawerTrigger, } from '@ndop/primitives/drawer'; import { cn } from '@/lib/utils'; /** * 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. * * @example * ```html * <ng-container ndDrawer>…</ng-container> * ``` */ @Directive({ selector: '[ndDrawer]', hostDirectives: [ { directive: NdDialog, inputs: ['open', 'defaultOpen', 'closeOnEscape', 'closeOnOverlayClick'], outputs: ['openChange'], }, ], }) export class Drawer {} /** * The button that opens its drawer. * * @example * ```html * <button ndButton variant="outline" ndDrawerTrigger>Open</button> * ``` */ @Directive({ selector: '[ndDrawerTrigger]', hostDirectives: [NdDrawerTrigger], host: { 'data-slot': 'drawer-trigger' }, }) export class DrawerTrigger {} /** * Marks the `ng-template` its drawer renders while open. * * @example * ```html * <ng-template ndDrawerPortal>…</ng-template> * ``` */ @Directive({ selector: 'ng-template[ndDrawerPortal]', hostDirectives: [NdDrawerPortal], }) export class DrawerPortal {} /** * A button that closes its drawer. * * @example * ```html * <button ndButton ndDrawerClose>Done</button> * ``` */ @Directive({ selector: '[ndDrawerClose]', hostDirectives: [NdDrawerClose], host: { 'data-slot': 'drawer-close' }, }) export class DrawerClose {} /** * The dimmed backdrop behind a drawer; clicking it closes the drawer. * * @example * ```html * <div ndDrawerOverlay></div> * ``` */ @Directive({ selector: '[ndDrawerOverlay]', hostDirectives: [NdDrawerOverlay], host: { 'data-slot': 'drawer-overlay', '[class]': 'classes()' }, }) export class DrawerOverlay { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', this.class(), ), ); } /** * 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. * * @example * ```html * <div ndDrawerContent> * <div ndDrawerHandle></div> * <div ndDrawerHeader><h2 ndDrawerTitle>Move goal</h2></div> * </div> * ``` */ @Directive({ selector: '[ndDrawerContent]', hostDirectives: [{ directive: NdDrawerContent, inputs: ['direction'] }], host: { 'data-slot': 'drawer-content', '[class]': 'classes()' }, }) export class DrawerContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/drawer-content fixed z-50 flex h-auto flex-col bg-background', 'data-[direction=top]:inset-x-0 data-[direction=top]:top-0 data-[direction=top]:mb-24 data-[direction=top]:max-h-[80vh] data-[direction=top]:rounded-b-lg data-[direction=top]:border-b', 'data-[direction=bottom]:inset-x-0 data-[direction=bottom]:bottom-0 data-[direction=bottom]:mt-24 data-[direction=bottom]:max-h-[80vh] data-[direction=bottom]:rounded-t-lg data-[direction=bottom]:border-t', 'data-[direction=end]:inset-y-0 data-[direction=end]:end-0 data-[direction=end]:w-3/4 data-[direction=end]:border-s data-[direction=end]:sm:max-w-sm', 'data-[direction=start]:inset-y-0 data-[direction=start]:start-0 data-[direction=start]:w-3/4 data-[direction=start]:border-e data-[direction=start]:sm:max-w-sm', // Follows the drag, and springs back when released short of closing. 'transition-[translate] duration-300 ease-out data-dragging:transition-none motion-reduce:transition-none', 'data-[direction=bottom]:translate-y-(--nd-drawer-drag) data-[direction=top]:-translate-y-(--nd-drawer-drag)', 'data-[direction=end]:translate-x-(--nd-drawer-drag) rtl:data-[direction=end]:-translate-x-(--nd-drawer-drag)', 'data-[direction=start]:-translate-x-(--nd-drawer-drag) rtl:data-[direction=start]:translate-x-(--nd-drawer-drag)', 'data-[state=open]:animate-in data-[state=open]:duration-500 motion-reduce:animate-none data-[direction=bottom]:slide-in-from-bottom data-[direction=top]:slide-in-from-top data-[direction=end]:slide-in-from-end data-[direction=start]:slide-in-from-start', this.class(), ), ); } /** * The grip at the top of a bottom drawer, showing it can be dragged; * hidden from assistive technology, and on other edges. * * @example * ```html * <div ndDrawerHandle></div> * ``` */ @Directive({ selector: '[ndDrawerHandle]', hostDirectives: [NdDrawerHandle], host: { 'data-slot': 'drawer-handle', '[class]': 'classes()' }, }) export class DrawerHandle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[direction=bottom]/drawer-content:block', this.class(), ), ); } /** * Lays out a drawer's title and description, centered on top and bottom * drawers on small screens. * * @example * ```html * <div ndDrawerHeader>…</div> * ``` */ @Directive({ selector: '[ndDrawerHeader]', host: { 'data-slot': 'drawer-header', '[class]': 'classes()' }, }) export class DrawerHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-col gap-0.5 p-4 group-data-[direction=bottom]/drawer-content:text-center group-data-[direction=top]/drawer-content:text-center md:gap-1.5 md:text-start', this.class(), ), ); } /** * Lays out a drawer's buttons at its bottom. * * @example * ```html * <div ndDrawerFooter>…</div> * ``` */ @Directive({ selector: '[ndDrawerFooter]', host: { 'data-slot': 'drawer-footer', '[class]': 'classes()' }, }) export class DrawerFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('mt-auto flex flex-col gap-2 p-4', this.class()), ); } /** * A drawer's title, which labels it. * * @example * ```html * <h2 ndDrawerTitle>Move goal</h2> * ``` */ @Directive({ selector: '[ndDrawerTitle]', hostDirectives: [NdDrawerTitle], host: { 'data-slot': 'drawer-title', '[class]': 'classes()' }, }) export class DrawerTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-semibold text-foreground', this.class()), ); } /** * A drawer's description. * * @example * ```html * <p ndDrawerDescription>Set your daily activity goal.</p> * ``` */ @Directive({ selector: '[ndDrawerDescription]', hostDirectives: [NdDrawerDescription], host: { 'data-slot': 'drawer-description', '[class]': 'classes()' }, }) export class DrawerDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-sm text-muted-foreground', this.class()), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
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 |
|---|---|
| Tab | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. |
| ShiftTab | Moves focus to the previous focusable element inside the dialog; from the first one, wraps to the last. |
| Escape | Closes the dialog, unless closeOnEscape is false. |
| Tab | Moves focus to the next focusable element inside; wraps from last to first. |
| ShiftTab | Moves focus to the previous focusable element inside; wraps from first to last. |
| Escape | Closes 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. | boolean | false |
[closeOnEscape]Whether Escape closes the dialog. | boolean | true |
[closeOnOverlayClick]Whether a click on [ndDialogOverlay] closes the dialog. | boolean | true |
[(open)]Whether the dialog is open. Two-way bindable with [(open)]. | boolean | false |
DrawerTrigger
[ndDrawerTrigger]The button that opens its drawer.
Manages aria-haspopup, aria-expanded, aria-controls.
| Data attribute | Values |
|---|---|
data-slot | drawer-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-slot | drawer-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-slot | drawer-overlay |
data-state | — |
DrawerContent
[ndDrawerContent]role dialogThe 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-slot | drawer-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-slot | drawer-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-slot | drawer-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-slot | drawer-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-slot | drawer-title |
DrawerDescription
[ndDrawerDescription]A drawer's description.
| DrawerDescription binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | drawer-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.