Atomic Angular — Doc interne

Sheet

A modal panel anchored to one edge of the viewport — the drawer pattern — built on the same native <dialog> as Dialog, with its own slide-in/out animation and scroll lock.

Filters sliding in from the right, a navigation panel from the left, a mobile action tray from the bottom — Sheet is that edge-anchored panel. It is built on a native <dialog> opened with showModal(), exactly as Dialog is: a sheet is a modal dialog, only its geometry differs, so both share the same DIALOG_REF/MODAL_LABEL tokens and the same DialogEvent close events.

Concept — the dialog *is* the panel

Unlike Dialog, Sheet has no inner content element: the <dialog> itself carries the surface, the geometry and the animation. A native modal <dialog> already exposes role="dialog" and aria-modal="true", so an inner element repeating them would put a second, redundant dialog in the accessibility tree — and the element that animates has to be the one the component owns, which projected content is not.

Minimal example

A right-hand panel, opened from a button
sample.component.ts
import { Component } from "@angular/core";
import {
  ButtonComponent,
  DialogTitleComponent,
  SheetBodyComponent,
  SheetComponent,
  SheetHeaderComponent,
} from "@sinequa/galactik";

@Component({
  selector: "sample-component",
  imports: [SheetComponent, SheetHeaderComponent, SheetBodyComponent, DialogTitleComponent, ButtonComponent],
  template: `
    <button variant="secondary" size="md" (click)="panel.open()">Open panel</button>

    <Sheet #panel="sheet" side="right" size="md">
      <SheetHeader closeLabel="Close panel">
        <DialogTitle>Document details</DialogTitle>
      </SheetHeader>
      <SheetBody>
        <p class="modal-body-text">Everything you never wanted to know about this document.</p>
      </SheetBody>
    </Sheet>
  `,
})
export class SampleComponent {}

#panel="sheet" reads SheetComponent off its exportAs, then open() (an alias for showModal()) shows it. There is no intermediate content element — SheetHeader/SheetBody project straight into <Sheet>.

How it works

side is what distinguishes a sheet from a dialog: it drives the inset, the size axis (a width for left/right, a height for top/bottom), the border, the rounded corners (the two corners flush against the viewport stay square) and the slide direction, all from the one input.

sizeleft / right (width)top / bottom (height)
sm400px25% of the viewport
md (default)520px33% of the viewport
lg720px50% of the viewport
xl960px75% of the viewport
full100dvw100dvh

Opening and closing both come from the same two CSS declarations — no keyframes, no JavaScript timer:

showModal() native dialog opens: top layer, focus trap, ::backdrop @starting-style gives the transition a starting value -> slides in close() display / overlay declared allow-discrete panel stays visible and in the top layer while it slides out getAnimations() + Promise.allSettled -> closeEnd once settled

getAnimations() rather than a transitionend listener: a transition that is interrupted emits transitioncancel, never transitionend, and one that never starts — under prefers-reduced-motion, where every duration is zero — emits nothing at all. Both were measured on a consumer, where the projected content stayed mounted for good inside a hidden panel because closeEnd never fired to release it.

The page scroll is locked while a sheet is open — the one deliberate divergence from Dialog. A dialog covers a small part of the viewport and locks nothing; a sheet covers a whole edge of it, so a wheel or touch drag scrolling the page behind it would be disorienting. The lock is counted (openSheets), so stacked sheets restore the previous value only once, at the last close — and it compensates for the scrollbar it removes with an equal padding-right, so a consumer's width-based media query (a BreakpointObserverService mobile/desktop split, say) never observes a spurious viewport-width change from opening one.

Recipes

The four sides

Switching side and size at runtime
sample.component.ts
import { Component, signal } from "@angular/core";
import {
  ButtonComponent,
  DialogTitleComponent,
  SheetBodyComponent,
  SheetComponent,
  SheetHeaderComponent,
} from "@sinequa/galactik";

type Side = "left" | "right" | "top" | "bottom";

@Component({
  selector: "sample-component",
  imports: [SheetComponent, SheetHeaderComponent, SheetBodyComponent, DialogTitleComponent, ButtonComponent],
  template: `
    @for (s of sides; track s) {
      <button variant="secondary" size="md" (click)="side.set(s)">{{ s }}</button>
    }
    <button variant="secondary" size="md" (click)="panel.open()">Open from {{ side() }}</button>

    <Sheet #panel="sheet" [side]="side()" size="md">
      <SheetHeader closeLabel="Close">
        <DialogTitle>Anchored {{ side() }}</DialogTitle>
      </SheetHeader>
      <SheetBody>
        <p class="modal-body-text">A left/right panel is sized by its width, a top/bottom one by its height.</p>
      </SheetBody>
    </Sheet>
  `,
})
export class SampleComponent {
  protected readonly sides: Side[] = ["left", "right", "top", "bottom"];
  protected readonly side = signal<Side>("right");
}

SheetFooter is a right-aligned action row on a tinted band, pinned below the scrollable body. Every close path — a footer button, the header's close button, Escape, a scrim click — lands on the same closed output.

A panel with no visible title

A sheet still needs an accessible name with no heading in the design. ariaLabel provides it directly and takes precedence over labelledby and over any projected title; closeButton floats a close affordance for a full-bleed panel that carries no SheetHeader at all.

A full-bleed action tray
sample.component.ts
import { Component } from "@angular/core";
import { ButtonComponent, SheetBodyComponent, SheetComponent } from "@sinequa/galactik";

@Component({
  selector: "sample-component",
  imports: [SheetComponent, SheetBodyComponent, ButtonComponent],
  template: `
    <button variant="secondary" size="md" (click)="panel.open()">Quick actions</button>

    <Sheet #panel="sheet" side="bottom" size="sm" ariaLabel="Quick actions" [closeButton]="true" closeLabel="Close quick actions">
      <SheetBody>
        <button variant="secondary" size="md">Share</button>
        <button variant="secondary" size="md">Export</button>
      </SheetBody>
    </Sheet>
  `,
})
export class SampleComponent {}

Returning a value — createCallable

A sheet that answers a question — pick a collection, confirm an export — opens imperatively and resolves, exactly like a callable dialog: createCallable takes any standalone component, and nothing in that path requires a <dialog> element, so a Sheet works with it unchanged. Wire end() to closeEnd rather than to the click that closes the panel — that is what lets the exit animation play before the component is destroyed.

Picking a collection, awaited
pick-collection.sheet.ts
import { afterNextRender, Component, viewChild } from "@angular/core";
import {
  createCallable,
  DialogTitleComponent,
  injectCallRef,
  SheetBodyComponent,
  SheetComponent,
  SheetFooterComponent,
  SheetHeaderComponent,
} from "@sinequa/galactik";

@Component({
  selector: "pick-collection-sheet",
  imports: [SheetComponent, SheetHeaderComponent, SheetBodyComponent, SheetFooterComponent, DialogTitleComponent],
  template: `
    <Sheet #panel side="right" size="sm" (closeEnd)="call.end(picked)">
      <SheetHeader closeLabel="Close">
        <DialogTitle>{{ call.props().title }}</DialogTitle>
      </SheetHeader>

      <SheetBody>
        @for (name of call.props().collections; track name) {
          <button type="button" (click)="choose(name)">{{ name }}</button>
        }
      </SheetBody>

      <SheetFooter>
        <button type="button" (click)="panel().close()">Cancel</button>
      </SheetFooter>
    </Sheet>
  `,
})
export class PickCollectionSheet {
  protected readonly call = injectCallRef<{ title: string; collections: string[] }, string | undefined>();
  protected readonly panel = viewChild.required(SheetComponent);
  protected picked: string | undefined;

  constructor() {
    // The component is created already "open" — nothing else would show it.
    afterNextRender(() => this.panel().open());
  }

  protected choose(name: string) {
    this.picked = name;
    this.panel().close();
  }
}

export const PickCollection = createCallable(PickCollectionSheet);

Cancelling resolves with undefined, since picked was never set — the promise always settles, whichever way the panel closed, Escape and a scrim click included. Reach for this only when the panel's purpose is to produce an answer for its caller; a panel driven by state that lives elsewhere (a preview bound to the current selection) has no result to await, and a declarative <Sheet> fits better there.

Options

Prop

Type

closed emits a DialogEvent once per close, however triggered. closeEnd emits once the exit animation has settled and the content has left the DOM — use it, not closed, to release anything rendered inside the panel.

Pitfalls

What's next

On this page