Docs Components Overlays

Dialog

React example follows below.
ts
import * as React from "react";
/**
* The base dialog plus one entry per §08 configuration. Each variant is opened
* by its own trigger button; the demos keep a single Dialog instance bound to
* whichever variant is active.
*/
interface DialogVariantFixture {
readonly id: string;
/** Trigger button label. */
readonly trigger: string;
readonly title: string;
readonly description: string;
readonly size?: "compact" | "default" | "wide";
/** Title ruled into its own header row, footer on the sunken surface. */
readonly divided?: boolean;
/** Footer actions stacked full-width, primary on top. */
readonly stackActions?: boolean;
}
const dialogFixture = {
id: "dialog",
trigger: "View deployment",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
body: "The deployment is ready for review.",
cancelLabel: "Cancel",
closeLabel: "Close",
dismissable: true,
variants: [
{
id: "default",
trigger: "Default",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
},
{
id: "wide",
trigger: "Wide",
size: "wide",
title: "Tear down ledger-api?",
description: "This removes the service, its infra, and all replicas. This cannot be undone.",
},
{
id: "divided",
trigger: "Header + divided footer",
size: "wide",
divided: true,
title: "Move to region",
description:
"ledger-api will drain connections before the cutover. Expect one minute of elevated latency.",
},
{
id: "compact",
trigger: "Compact — stacked actions",
size: "compact",
stackActions: true,
title: "Tear down ledger-api?",
description: "This cannot be undone.",
},
],
} satisfies {
id: string;
trigger: string;
title: string;
description: string;
body: string;
cancelLabel: string;
closeLabel: string;
dismissable: boolean;
variants: readonly DialogVariantFixture[];
};
import { Button, Dialog } from "@grassroot/ui-react";
const ROW = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: "12px" } as const;
export default function DialogDemo() {
const [activeId, setActiveId] = React.useState<string | null>(null);
// The Dialog stays mounted so `open` transitions false -> true and the panel
// animates in; only which variant it is bound to changes.
const active =
dialogFixture.variants.find((variant) => variant.id === activeId) ?? dialogFixture.variants[0];
return (
<div>
<div style={ROW}>
{dialogFixture.variants.map((variant) => (
<Button key={variant.id} variant="ghost" onClick={() => setActiveId(variant.id)}>
{variant.trigger}
</Button>
))}
</div>
<Dialog
open={activeId !== null}
onOpenChange={(open) => setActiveId(open ? activeId : null)}
title={active.title}
description={active.description}
size={active.size}
divided={active.divided}
stackActions={active.stackActions}
dismissable={dialogFixture.dismissable}
footer={
<>
<Button variant="ghost" onClick={() => setActiveId(null)}>
{dialogFixture.cancelLabel}
</Button>
<Button onClick={() => setActiveId(null)}>{dialogFixture.closeLabel}</Button>
</>
}
>
{dialogFixture.body}
</Dialog>
</div>
);
}
ts
<script setup lang="ts">
import { computed, ref } from "vue";
/**
* The base dialog plus one entry per §08 configuration. Each variant is opened
* by its own trigger button; the demos keep a single Dialog instance bound to
* whichever variant is active.
*/
interface DialogVariantFixture {
readonly id: string;
/** Trigger button label. */
readonly trigger: string;
readonly title: string;
readonly description: string;
readonly size?: "compact" | "default" | "wide";
/** Title ruled into its own header row, footer on the sunken surface. */
readonly divided?: boolean;
/** Footer actions stacked full-width, primary on top. */
readonly stackActions?: boolean;
}
const dialogFixture = {
id: "dialog",
trigger: "View deployment",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
body: "The deployment is ready for review.",
cancelLabel: "Cancel",
closeLabel: "Close",
dismissable: true,
variants: [
{
id: "default",
trigger: "Default",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
},
{
id: "wide",
trigger: "Wide",
size: "wide",
title: "Tear down ledger-api?",
description: "This removes the service, its infra, and all replicas. This cannot be undone.",
},
{
id: "divided",
trigger: "Header + divided footer",
size: "wide",
divided: true,
title: "Move to region",
description:
"ledger-api will drain connections before the cutover. Expect one minute of elevated latency.",
},
{
id: "compact",
trigger: "Compact — stacked actions",
size: "compact",
stackActions: true,
title: "Tear down ledger-api?",
description: "This cannot be undone.",
},
],
} satisfies {
id: string;
trigger: string;
title: string;
description: string;
body: string;
cancelLabel: string;
closeLabel: string;
dismissable: boolean;
variants: readonly DialogVariantFixture[];
};
import { Button, Dialog } from "@grassroot/ui-vue";
const ROW = "display:flex;flex-wrap:wrap;align-items:center;gap:12px";
const activeId = ref<string | null>(null);
// The Dialog stays mounted so `open` transitions false -> true and the panel
// animates in; only which variant it is bound to changes.
const active = computed(
() =>
dialogFixture.variants.find((variant) => variant.id === activeId.value) ??
dialogFixture.variants[0],
);
const open = computed({
get: () => activeId.value !== null,
set: (next: boolean) => {
if (!next) activeId.value = null;
},
});
</script>
<template>
<div>
<div :style="ROW">
<Button
v-for="variant in dialogFixture.variants"
:key="variant.id"
variant="ghost"
@click="activeId = variant.id"
>{{ variant.trigger }}</Button
>
</div>
<Dialog
v-model:open="open"
:size="active.size"
:divided="active.divided"
:stack-actions="active.stackActions"
:dismissable="dialogFixture.dismissable"
>
<template #title>{{ active.title }}</template>
<template #description>{{ active.description }}</template>
{{ dialogFixture.body }}
<template #footer>
<Button variant="ghost" @click="activeId = null">{{ dialogFixture.cancelLabel }}</Button>
<Button @click="activeId = null">{{ dialogFixture.closeLabel }}</Button>
</template>
</Dialog>
</div>
</template>
ts
import { Component, computed, signal } from "@angular/core";
/**
* The base dialog plus one entry per §08 configuration. Each variant is opened
* by its own trigger button; the demos keep a single Dialog instance bound to
* whichever variant is active.
*/
interface DialogVariantFixture {
readonly id: string;
/** Trigger button label. */
readonly trigger: string;
readonly title: string;
readonly description: string;
readonly size?: "compact" | "default" | "wide";
/** Title ruled into its own header row, footer on the sunken surface. */
readonly divided?: boolean;
/** Footer actions stacked full-width, primary on top. */
readonly stackActions?: boolean;
}
const dialogFixture = {
id: "dialog",
trigger: "View deployment",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
body: "The deployment is ready for review.",
cancelLabel: "Cancel",
closeLabel: "Close",
dismissable: true,
variants: [
{
id: "default",
trigger: "Default",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
},
{
id: "wide",
trigger: "Wide",
size: "wide",
title: "Tear down ledger-api?",
description: "This removes the service, its infra, and all replicas. This cannot be undone.",
},
{
id: "divided",
trigger: "Header + divided footer",
size: "wide",
divided: true,
title: "Move to region",
description:
"ledger-api will drain connections before the cutover. Expect one minute of elevated latency.",
},
{
id: "compact",
trigger: "Compact — stacked actions",
size: "compact",
stackActions: true,
title: "Tear down ledger-api?",
description: "This cannot be undone.",
},
],
} satisfies {
id: string;
trigger: string;
title: string;
description: string;
body: string;
cancelLabel: string;
closeLabel: string;
dismissable: boolean;
variants: readonly DialogVariantFixture[];
};
import {
GrassrootButton,
GrassrootDialog,
GrassrootDialogBody,
GrassrootDialogClose,
GrassrootDialogDescription,
GrassrootDialogFooter,
GrassrootDialogHeader,
GrassrootDialogTitle,
} from "@grassroot/ui-angular";
@Component({
selector: "app-dialog-demo",
standalone: true,
imports: [
GrassrootButton,
GrassrootDialog,
GrassrootDialogTitle,
GrassrootDialogDescription,
GrassrootDialogFooter,
GrassrootDialogHeader,
GrassrootDialogBody,
GrassrootDialogClose,
],
template: `
<div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:12px">
@for (variant of fixture.variants; track variant.id) {
<grassroot-button variant="ghost" (click)="activeId.set(variant.id)">{{
variant.trigger
}}</grassroot-button>
}
</div>
<grassroot-dialog
[open]="activeId() !== null"
(openChange)="onOpenChange($event)"
[size]="active().size ?? 'default'"
[divided]="!!active().divided"
[stackActions]="!!active().stackActions"
[dismissable]="fixture.dismissable"
>
@if (active().divided) {
<!-- The header/body wrappers are nested, so they travel together
through the catch-all slot in author order, which is where the
divided layout wants them. aria-labelledby still resolves
because the dialog title query is a descendant query. -->
<div grassrootDialogHeader>
<h2 grassrootDialogTitle>{{ active().title }}</h2>
<button grassrootDialogClose type="button" (click)="activeId.set(null)">✕</button>
</div>
<div grassrootDialogBody>
<p grassrootDialogDescription>{{ active().description }}</p>
{{ fixture.body }}
</div>
} @else {
<h2 grassrootDialogTitle>{{ active().title }}</h2>
<p grassrootDialogDescription>{{ active().description }}</p>
{{ fixture.body }}
}
<div grassrootDialogFooter>
<grassroot-button variant="ghost" (click)="activeId.set(null)">{{
fixture.cancelLabel
}}</grassroot-button>
<grassroot-button (click)="activeId.set(null)">{{
fixture.closeLabel
}}</grassroot-button>
</div>
</grassroot-dialog>
</div>
`,
})
export class DialogDemoComponent {
protected readonly fixture = dialogFixture;
protected readonly activeId = signal<string | null>(null);
// The dialog stays mounted so `open` transitions false -> true and the panel
// animates in; only which variant it is bound to changes.
protected readonly active = computed(
() =>
dialogFixture.variants.find((variant) => variant.id === this.activeId()) ??
dialogFixture.variants[0],
);
protected onOpenChange(open: boolean): void {
if (!open) this.activeId.set(null);
}
}
export default DialogDemoComponent;
ts
<script lang="ts">
import { Button, Dialog } from "@grassroot/ui-svelte";
/**
* The base dialog plus one entry per §08 configuration. Each variant is opened
* by its own trigger button; the demos keep a single Dialog instance bound to
* whichever variant is active.
*/
interface DialogVariantFixture {
readonly id: string;
/** Trigger button label. */
readonly trigger: string;
readonly title: string;
readonly description: string;
readonly size?: "compact" | "default" | "wide";
/** Title ruled into its own header row, footer on the sunken surface. */
readonly divided?: boolean;
/** Footer actions stacked full-width, primary on top. */
readonly stackActions?: boolean;
}
const dialogFixture = {
id: "dialog",
trigger: "View deployment",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
body: "The deployment is ready for review.",
cancelLabel: "Cancel",
closeLabel: "Close",
dismissable: true,
variants: [
{
id: "default",
trigger: "Default",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
},
{
id: "wide",
trigger: "Wide",
size: "wide",
title: "Tear down ledger-api?",
description: "This removes the service, its infra, and all replicas. This cannot be undone.",
},
{
id: "divided",
trigger: "Header + divided footer",
size: "wide",
divided: true,
title: "Move to region",
description:
"ledger-api will drain connections before the cutover. Expect one minute of elevated latency.",
},
{
id: "compact",
trigger: "Compact — stacked actions",
size: "compact",
stackActions: true,
title: "Tear down ledger-api?",
description: "This cannot be undone.",
},
],
} satisfies {
id: string;
trigger: string;
title: string;
description: string;
body: string;
cancelLabel: string;
closeLabel: string;
dismissable: boolean;
variants: readonly DialogVariantFixture[];
};
const ROW = "display:flex;flex-wrap:wrap;align-items:center;gap:12px";
let activeId = $state<string | null>(null);
// The Dialog stays mounted so `open` transitions false -> true and the panel
// animates in; only which variant it is bound to changes.
const active = $derived(
dialogFixture.variants.find((variant) => variant.id === activeId) ?? dialogFixture.variants[0],
);
</script>
{#snippet title()}{active.title}{/snippet}
{#snippet description()}{active.description}{/snippet}
{#snippet footer()}<Button variant="ghost" onClick={() => (activeId = null)}
>{dialogFixture.cancelLabel}</Button
><Button onClick={() => (activeId = null)}>{dialogFixture.closeLabel}</Button>{/snippet}
{#snippet children()}{dialogFixture.body}{/snippet}
<div>
<div style={ROW}>
{#each dialogFixture.variants as variant (variant.id)}
<Button variant="ghost" onClick={() => (activeId = variant.id)}>{variant.trigger}</Button>
{/each}
</div>
<Dialog
open={activeId !== null}
onOpenChange={(next) => {
if (!next) activeId = null;
}}
size={active.size}
divided={active.divided}
stackActions={active.stackActions}
dismissable={dialogFixture.dismissable}
{title}
{description}
{footer}
{children}
/>
</div>
ts
/** @jsxImportSource solid-js */
import { For, createMemo, createSignal } from "solid-js";
import { Button, Dialog } from "@grassroot/ui-solid";
/**
* The base dialog plus one entry per §08 configuration. Each variant is opened
* by its own trigger button; the demos keep a single Dialog instance bound to
* whichever variant is active.
*/
interface DialogVariantFixture {
readonly id: string;
/** Trigger button label. */
readonly trigger: string;
readonly title: string;
readonly description: string;
readonly size?: "compact" | "default" | "wide";
/** Title ruled into its own header row, footer on the sunken surface. */
readonly divided?: boolean;
/** Footer actions stacked full-width, primary on top. */
readonly stackActions?: boolean;
}
const dialogFixture = {
id: "dialog",
trigger: "View deployment",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
body: "The deployment is ready for review.",
cancelLabel: "Cancel",
closeLabel: "Close",
dismissable: true,
variants: [
{
id: "default",
trigger: "Default",
title: "Deploy ledger-api?",
description: "The rolling restart updates six replicas.",
},
{
id: "wide",
trigger: "Wide",
size: "wide",
title: "Tear down ledger-api?",
description: "This removes the service, its infra, and all replicas. This cannot be undone.",
},
{
id: "divided",
trigger: "Header + divided footer",
size: "wide",
divided: true,
title: "Move to region",
description:
"ledger-api will drain connections before the cutover. Expect one minute of elevated latency.",
},
{
id: "compact",
trigger: "Compact — stacked actions",
size: "compact",
stackActions: true,
title: "Tear down ledger-api?",
description: "This cannot be undone.",
},
],
} satisfies {
id: string;
trigger: string;
title: string;
description: string;
body: string;
cancelLabel: string;
closeLabel: string;
dismissable: boolean;
variants: readonly DialogVariantFixture[];
};
const ROW = "display:flex;flex-wrap:wrap;align-items:center;gap:12px";
export default function DialogDemo() {
const [activeId, setActiveId] = createSignal<string | null>(null);
// The Dialog stays mounted so `open` transitions false -> true and the panel
// animates in; only which variant it is bound to changes.
const active = createMemo(
() =>
dialogFixture.variants.find((variant) => variant.id === activeId()) ??
dialogFixture.variants[0],
);
return (
<div>
<div style={ROW}>
<For each={dialogFixture.variants}>
{(variant) => (
<Button variant="ghost" onClick={() => setActiveId(variant.id)}>
{variant.trigger}
</Button>
)}
</For>
</div>
<Dialog
open={activeId() !== null}
onOpenChange={(open) => setActiveId(open ? activeId() : null)}
title={active().title}
description={active().description}
size={active().size}
divided={active().divided}
stackActions={active().stackActions}
dismissable={dialogFixture.dismissable}
footer={
<>
<Button variant="ghost" onClick={() => setActiveId(null)}>
{dialogFixture.cancelLabel}
</Button>
<Button onClick={() => setActiveId(null)}>{dialogFixture.closeLabel}</Button>
</>
}
>
{dialogFixture.body}
</Dialog>
</div>
);
}

Installation

API Reference

Import

import * as React from "react";
import { Button, Dialog } from "@grassroot/ui-react";

Props

Prop
Type
Default
Requirement
Description
open
boolean
required
onOpenChange
(open: boolean) => void
required
dismissable
boolean | undefined
true
optional
Close when the scrim is clicked.
size
DialogSize | undefined
"default"
optional
Panel width.
divided
boolean | undefined
optional
Rule the title into its own header row and the footer onto the sunken surface, both reaching the panel's edges.
stackActions
boolean | undefined
optional
Stack the footer's actions full-width, primary on top.
closeLabel
string | undefined
"Close"
optional
Accessible name for the divided header's close button.
className
string | undefined
optional
Prop
open
Type
boolean
Default
Requirement
required
Description
Prop
onOpenChange
Type
(open: boolean) => void
Default
Requirement
required
Description
Prop
dismissable
Type
boolean | undefined
Default
true
Requirement
optional
Description
Close when the scrim is clicked.
Prop
size
Type
DialogSize | undefined
Default
"default"
Requirement
optional
Description
Panel width.
Prop
divided
Type
boolean | undefined
Default
Requirement
optional
Description
Rule the title into its own header row and the footer onto the sunken surface, both reaching the panel's edges.
Prop
stackActions
Type
boolean | undefined
Default
Requirement
optional
Description
Stack the footer's actions full-width, primary on top.
Prop
closeLabel
Type
string | undefined
Default
"Close"
Requirement
optional
Description
Accessible name for the divided header's close button.
Prop
className
Type
string | undefined
Default
Requirement
optional
Description

Events

EventPayload
onOpenChange(open: boolean) => void

Content regions

  • children
  • description
  • footer
  • title

Import

import { computed, ref } from "vue";
import { Button, Dialog } from "@grassroot/ui-vue";

Props

Prop
Type
Default
Requirement
open
boolean
required
dismissable
boolean
true
optional
size
DialogSize | undefined
undefined
optional
divided
boolean
false
optional
stackActions
boolean
false
optional
closeLabel
string
"Close"
optional
Prop
open
Type
boolean
Default
Requirement
required
Prop
dismissable
Type
boolean
Default
true
Requirement
optional
Prop
size
Type
DialogSize | undefined
Default
undefined
Requirement
optional
Prop
divided
Type
boolean
Default
false
Requirement
optional
Prop
stackActions
Type
boolean
Default
false
Requirement
optional
Prop
closeLabel
Type
string
Default
"Close"
Requirement
optional

Events

EventPayload
update:open(_open: boolean) => true

Content regions

  • default
  • description
  • footer
  • title

Import

import { Component, computed, signal } from "@angular/core";
import {
  GrassrootButton,
  GrassrootDialog,
  GrassrootDialogBody,
  GrassrootDialogClose,
  GrassrootDialogDescription,
  GrassrootDialogFooter,
  GrassrootDialogHeader,
  GrassrootDialogTitle,
} from "@grassroot/ui-angular";

Props

Prop
Type
Default
Requirement
open
boolean
required
dismissable
boolean
true
optional
size
DialogSize | undefined
undefined
optional
divided
boolean
false
optional
stackActions
boolean
false
optional
closeLabel
string
"Close"
optional
className
string
optional
Prop
open
Type
boolean
Default
Requirement
required
Prop
dismissable
Type
boolean
Default
true
Requirement
optional
Prop
size
Type
DialogSize | undefined
Default
undefined
Requirement
optional
Prop
divided
Type
boolean
Default
false
Requirement
optional
Prop
stackActions
Type
boolean
Default
false
Requirement
optional
Prop
closeLabel
Type
string
Default
"Close"
Requirement
optional
Prop
className
Type
string
Default
Requirement
optional

Events

EventPayload
openChangeboolean

Content regions

  • default
  • description
  • footer
  • title

Import

import { Button, Dialog } from "@grassroot/ui-svelte";

Props

Prop
Type
Default
Requirement
open
boolean
required
dismissable
boolean
true
optional
onOpenChange
(open: boolean) => void
required
size
DialogSize
optional
divided
boolean
false
optional
stackActions
boolean
false
optional
closeLabel
string
"Close"
optional
className
string
optional
Prop
open
Type
boolean
Default
Requirement
required
Prop
dismissable
Type
boolean
Default
true
Requirement
optional
Prop
onOpenChange
Type
(open: boolean) => void
Default
Requirement
required
Prop
size
Type
DialogSize
Default
Requirement
optional
Prop
divided
Type
boolean
Default
false
Requirement
optional
Prop
stackActions
Type
boolean
Default
false
Requirement
optional
Prop
closeLabel
Type
string
Default
"Close"
Requirement
optional
Prop
className
Type
string
Default
Requirement
optional

Events

EventPayload
onOpenChange(open: boolean) => void

Content regions

  • children
  • description
  • footer
  • title

Import

import { For, createMemo, createSignal } from "solid-js";
import { Button, Dialog } from "@grassroot/ui-solid";

Props

Prop
Type
Default
Requirement
open
boolean
required
onOpenChange
(open: boolean) => void
required
dismissable
boolean
true
optional
size
DialogSize
optional
divided
boolean
false
optional
stackActions
boolean
false
optional
closeLabel
string
"Close"
optional
className
string
optional
Prop
open
Type
boolean
Default
Requirement
required
Prop
onOpenChange
Type
(open: boolean) => void
Default
Requirement
required
Prop
dismissable
Type
boolean
Default
true
Requirement
optional
Prop
size
Type
DialogSize
Default
Requirement
optional
Prop
divided
Type
boolean
Default
false
Requirement
optional
Prop
stackActions
Type
boolean
Default
false
Requirement
optional
Prop
closeLabel
Type
string
Default
"Close"
Requirement
optional
Prop
className
Type
string
Default
Requirement
optional

Events

EventPayload
onOpenChange(open: boolean) => void

Content regions

  • children
  • description
  • footer
  • title