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
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.
size | left / right (width) | top / bottom (height) |
|---|---|---|
sm | 400px | 25% of the viewport |
md (default) | 520px | 33% of the viewport |
lg | 720px | 50% of the viewport |
xl | 960px | 75% of the viewport |
full | 100dvw | 100dvh |
Opening and closing both come from the same two CSS declarations — no keyframes, no JavaScript timer:
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
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");
}A footer with actions
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.
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.
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
Dialog
Open a modal or non-modal window on the native <dialog> element, and get a typed result back from an imperative call() without wiring a viewChild by hand.
Menu
An accessible dropdown or contextual menu built on @angular/aria — roving focus, typeahead, unlimited nested submenus, and checkable items with no interactive control inside a menuitem.