Skip to content
Ndop UI

Sheet

A dialog that slides in from an edge of the screen, for secondary content such as settings or filters.

import { Component, signal } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
import {
  Sheet,
  SheetClose,
  SheetCloseButton,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetOverlay,
  SheetPortal,
  SheetTitle,
  SheetTrigger,
} from '@/ui/sheet/sheet';

/**
 * A profile form in a sheet from the end side, bound to Signal Forms and
 * saved from its footer, which closes the sheet.
 */
@Component({
  selector: 'example-sheet-demo',
  imports: [
    Button,
    FormField,
    Input,
    Label,
    NdIcon,
    Sheet,
    SheetClose,
    SheetCloseButton,
    SheetContent,
    SheetDescription,
    SheetFooter,
    SheetHeader,
    SheetOverlay,
    SheetPortal,
    SheetTitle,
    SheetTrigger,
  ],
  template: `
    <ng-container ndSheet [(open)]="open">
      <button ndButton variant="outline" ndSheetTrigger>Edit profile</button>
      <ng-template ndSheetPortal>
        <div ndSheetOverlay></div>
        <div ndSheetContent>
          <div ndSheetHeader>
            <h2 ndSheetTitle>Edit profile</h2>
            <p ndSheetDescription>
              Make changes to your profile here. Save when you're done.
            </p>
          </div>
          <form
            class="grid flex-1 auto-rows-min gap-6 px-4"
            (submit)="save($event)"
            id="sheet-demo-form"
          >
            <div class="grid gap-3">
              <label ndLabel for="sheet-demo-name">Name</label>
              <input ndInput id="sheet-demo-name" [formField]="profile.name" />
            </div>
            <div class="grid gap-3">
              <label ndLabel for="sheet-demo-username">Username</label>
              <input
                ndInput
                id="sheet-demo-username"
                [formField]="profile.username"
              />
            </div>
          </form>
          <div ndSheetFooter>
            <button ndButton type="submit" form="sheet-demo-form">
              Save changes
            </button>
            <button ndButton variant="outline" ndSheetClose>Cancel</button>
          </div>
          <button ndSheetCloseButton>
            <svg ndIcon="x"></svg>
            <span class="sr-only">Close</span>
          </button>
        </div>
      </ng-template>
    </ng-container>
  `,
})
export class SheetDemoExample {
  protected readonly open = signal(false);

  protected readonly model = signal({ name: 'Ada Lovelace', username: 'ada' });

  protected readonly profile = form(this.model, (path) => {
    required(path.name);
    required(path.username);
  });

  /**
   * Closes the sheet once the profile is valid, instead of reloading.
   *
   * @param event - The form's submit event.
   */
  protected save(event: Event): void {
    event.preventDefault();
    this.profile().markAsTouched();
    if (this.profile().valid()) {
      this.open.set(false);
    }
  }
}

A sheet is a dialog that slides in from an edge of the screen, for content that goes with the page without replacing it, such as settings, filters or a cart. It's a modal dialog: focus is trapped inside while it's open and returns when it closes, and Escape or the backdrop closes it. It comes from the end side by default; start and end are logical, so they swap in right-to-left layouts.

Installation

npx @ndop/cli add sheet

Usage

import { Button } from '@/ui/button/button';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
import {
  Sheet,
  SheetClose,
  SheetCloseButton,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetOverlay,
  SheetPortal,
  SheetTitle,
  SheetTrigger,
} from '@/ui/sheet/sheet';
<ng-container ndSheet [(open)]="open">
  <button ndButton variant="outline" ndSheetTrigger>Edit profile</button>
  <ng-template ndSheetPortal>
    <div ndSheetOverlay></div>
    <div ndSheetContent>
      <div ndSheetHeader>
        <h2 ndSheetTitle>Edit profile</h2>
        <p ndSheetDescription>
          Make changes to your profile here. Save when you're done.
        </p>
      </div>
      <form
        class="grid flex-1 auto-rows-min gap-6 px-4"
        (submit)="save($event)"
        id="sheet-demo-form"
      >
        <div class="grid gap-3">
          <label ndLabel for="sheet-demo-name">Name</label>
          <input ndInput id="sheet-demo-name" [formField]="profile.name" />
        </div>
        <div class="grid gap-3">
          <label ndLabel for="sheet-demo-username">Username</label>
          <input
            ndInput
            id="sheet-demo-username"
            [formField]="profile.username"
          />
        </div>
      </form>
      <div ndSheetFooter>
        <button ndButton type="submit" form="sheet-demo-form">
          Save changes
        </button>
        <button ndButton variant="outline" ndSheetClose>Cancel</button>
      </div>
      <button ndSheetCloseButton>
        <svg ndIcon="x"></svg>
        <span class="sr-only">Close</span>
      </button>
    </div>
  </ng-template>
</ng-container>

Examples

Sheet sides

A sheet from each edge: top, end, bottom and start.

import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetOverlay,
  SheetPortal,
  SheetTitle,
  SheetTrigger,
  type SheetSide,
} from '@/ui/sheet/sheet';

/**
 * A sheet from each edge. `start` and `end` are logical, so they swap in
 * right-to-left layouts.
 */
@Component({
  selector: 'example-sheet-sides',
  imports: [
    Button,
    Sheet,
    SheetClose,
    SheetContent,
    SheetDescription,
    SheetHeader,
    SheetOverlay,
    SheetPortal,
    SheetTitle,
    SheetTrigger,
  ],
  template: `
    <div class="grid grid-cols-2 gap-2">
      @for (side of sides; track side) {
        <ng-container ndSheet>
          <button ndButton variant="outline" ndSheetTrigger class="capitalize">
            {{ side }}
          </button>
          <ng-template ndSheetPortal>
            <div ndSheetOverlay></div>
            <div ndSheetContent [side]="side">
              <div ndSheetHeader>
                <h2 ndSheetTitle class="capitalize">{{ side }} sheet</h2>
                <p ndSheetDescription>It slides in from the {{ side }}.</p>
              </div>
              <div class="p-4">
                <button ndButton variant="outline" ndSheetClose>Close</button>
              </div>
            </div>
          </ng-template>
        </ng-container>
      }
    </div>
  `,
})
export class SheetSidesExample {
  protected readonly sides: readonly SheetSide[] = [
    'top',
    'end',
    'bottom',
    'start',
  ];
}

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.

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.

API reference

Sheet

[ndSheet]

A sheet: a modal dialog (@ndop/primitives/dialog) that slides in from an edge, for secondary content such as settings or filters. Put it on an ng-container; open, defaultOpen, closeOnEscape and closeOnOverlayClick are Dialog's.

Sheet 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

SheetTrigger

[ndSheetTrigger]

The button that opens a sheet.

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

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

SheetPortal

ng-template[ndSheetPortal]

Marks the ng-template a sheet renders while open.

SheetClose

[ndSheetClose]

A button that closes its sheet.

Data attribute Values
data-slotsheet-close

SheetOverlay

[ndSheetOverlay]

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

Manages aria-hidden.

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

SheetContent

[ndSheetContent]role dialog

The sheet panel: a modal dialog labelled by its title, sliding in from its side. start and end are logical, so they swap in right-to-left layouts.

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

SheetContent binding Type Default
[side]
The edge it slides in from.
'bottom' | 'top' | 'start' | 'end''end'
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsheet-content
data-side —
data-state —

SheetCloseButton

button[ndSheetCloseButton]

The close button in a sheet's corner; put an icon and a visually hidden name in it.

SheetCloseButton binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsheet-close

SheetHeader

[ndSheetHeader]

Lays out a sheet's title and description.

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

SheetFooter

[ndSheetFooter]

Lays out a sheet's buttons at its bottom.

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

SheetTitle

[ndSheetTitle]

A sheet's title, which labels it.

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

SheetDescription

[ndSheetDescription]

A sheet's description.

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

Agent notes

Do

  • Give it a title, which names it.
  • Put a close button in it, with a visible or visually hidden name.
  • Use the end side for secondary panels and the bottom on small screens.

Don't

  • Don't use it for your main navigation on large screens; use a Sidebar.
  • Don't stack sheets on top of each other.