import { useState } from "react";
import { Button, Input, LayerDialog } from "@cloudflare/kumo";
export function LayerDialogActionDemo() {
const [name, setName] = useState("Production API");
const [hostname, setHostname] = useState("api.example.com");
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open settings</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Configure custom hostname</LayerDialog.Title>
<LayerDialog.Description>
Route requests for this hostname to your Worker.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="flex flex-col gap-5">
<Input
label="Hostname"
onChange={(event) => setHostname(event.target.value)}
value={hostname}
/>
<Input
label="Display name"
onChange={(event) => setName(event.target.value)}
value={name}
/>
</div>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary
disabled={!hostname || !name}
onClick={() => undefined}
>
Save hostname
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Installation
Barrel
import { LayerDialog } from "@cloudflare/kumo";Granular
import { LayerDialog } from "@cloudflare/kumo/components/layer-dialog";Usage
import { Button, LayerDialog } from "@cloudflare/kumo";
export default function Example() {
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open settings</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Configure custom hostname</LayerDialog.Title>
<LayerDialog.Description>
Route requests for this hostname to your Worker.
</LayerDialog.Description>
<LayerDialog.Body>{/* form fields */}</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary onClick={save}>
Save hostname
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Composition rules
LayerDialog.Content accepts exactly one Title, one Body, an optional Description, and an optional Actions. It chooses its dismissal UI automatically:
- Without
Actions: show an X in the title frame and no footer. - With
Actions: remove the X and show a footer with Close or Cancel and one primary action.
Consumers cannot mix these layouts or add more primary actions. Use the X for read-only content, Actions when the user must commit a change, and LayerDialog.Alert for destructive or critical confirmations.
Description renders directly beneath the title inside the sticky title frame and becomes the dialog’s accessible description. Without one, the body copy describes the dialog instead.
Informational dialog
Title and Description always live in the bordered body surface. The title frame remains visible while the body scrolls, gains a bottom border after scrolling, and has the sole automatic X dismissal action. Once the body scrolls past the top, the description folds away beneath the title to give the content more room, and unfolds again at the top. The content edge mask signals overflow.
import { Button, LayerDialog, Text } from "@cloudflare/kumo";
function LongContent() {
return (
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-kumo-line p-4 text-kumo-subtle">
Navigation and command shortcuts
</div>
<Text variant="secondary">
The title frame stays visible, receives a divider once content scrolls,
and the scroll mask indicates more content below.
</Text>
<div className="h-96" />
</div>
);
}
export function LayerDialogInformationalDemo() {
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open keyboard shortcuts</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Keyboard shortcuts</LayerDialog.Title>
<LayerDialog.Description>
Browse available shortcuts without changing a setting.
</LayerDialog.Description>
<LayerDialog.Body>
<LongContent />
</LayerDialog.Body>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Standard action
Adding Actions selects a fixed footer with exactly one Kumo primary action and an automatic Close button. Consumers cannot add custom footer controls, change button sizes, or add extra CTAs. The primary action accepts variant="primary" (default) or variant="destructive".
import { useState } from "react";
import { Button, Input, LayerDialog } from "@cloudflare/kumo";
export function LayerDialogActionDemo() {
const [name, setName] = useState("Production API");
const [hostname, setHostname] = useState("api.example.com");
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open settings</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Configure custom hostname</LayerDialog.Title>
<LayerDialog.Description>
Route requests for this hostname to your Worker.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="flex flex-col gap-5">
<Input
label="Hostname"
onChange={(event) => setHostname(event.target.value)}
value={hostname}
/>
<Input
label="Display name"
onChange={(event) => setName(event.target.value)}
value={name}
/>
</div>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary
disabled={!hostname || !name}
onClick={() => undefined}
>
Save hostname
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Cancellation wording
Use dismissLabel="Cancel" only when the workflow has an explicit cancellation outcome. The component continues to own the button’s handler, placement, and styling.
import { useState } from "react";
import { Button, Input, LayerDialog } from "@cloudflare/kumo";
export function LayerDialogCancelDemo() {
const [email, setEmail] = useState("alex@example.com");
const [name, setName] = useState("Alex Morgan");
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Edit profile</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Edit profile</LayerDialog.Title>
<LayerDialog.Description>
Update the profile information shown to your teammates. Changes are
not saved until you confirm.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="flex flex-col gap-5">
<Input
label="Display name"
onChange={(event) => setName(event.target.value)}
value={name}
/>
<Input
label="Email address"
onChange={(event) => setEmail(event.target.value)}
type="email"
value={email}
/>
</div>
</LayerDialog.Body>
<LayerDialog.Actions dismissLabel="Cancel">
<LayerDialog.Actions.Primary
disabled={!email || !name}
onClick={() => undefined}
>
Save changes
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Confirmation and destructive actions
LayerDialog.Alert follows Base UI’s alert dialog: it supplies role="alertdialog", an automatic Cancel, no X, is always modal, and blocks backdrop and swipe dismissal. Escape still cancels, matching the ARIA alert dialog pattern.
Pass variant="destructive" to the primary action when the confirmation is irreversible. Alerts that confirm a non-destructive but critical step keep the default primary styling.
import { useState } from "react";
import { Button, Input, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogAlertDemo() {
const workerName = "example-worker";
const [confirmation, setConfirmation] = useState("");
return (
<LayerDialog.Alert>
<LayerDialog.Trigger
render={(props) => (
<Button variant="secondary-destructive" {...props}>
Delete Worker
</Button>
)}
/>
<LayerDialog.Content>
<LayerDialog.Title>Delete Worker</LayerDialog.Title>
<LayerDialog.Description>
Deleting <strong className="text-kumo-default">{workerName}</strong>{" "}
is permanent.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="flex flex-col gap-5">
<Text variant="secondary">
This deletes the Worker, deployments, and configuration. If this
Worker consumes Queues, those connections are removed first.
Queues, D1 databases, and messages stay in your account.
</Text>
<Input
label={
<>
Type <strong>{workerName}</strong> to confirm
</>
}
onChange={(event) => setConfirmation(event.target.value)}
placeholder={workerName}
value={confirmation}
/>
</div>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary
disabled={confirmation !== workerName}
onClick={() => undefined}
variant="destructive"
>
Delete Worker
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Alert>
);
}Pending work
dismissDisabled blocks every user-initiated dismissal together while asynchronous work is in flight. Programmatic closes, through actionsRef.current.close() or a controlled open prop, still work so a successful action can dismiss the dialog. The single primary action owns its separate loading or disabled state.
import { useState } from "react";
import { Button, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogPendingDemo() {
const [pending, setPending] = useState(false);
return (
<LayerDialog.Root dismissDisabled={pending}>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Save a setting</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Save a setting</LayerDialog.Title>
<LayerDialog.Description>
While saving, Close, Escape, backdrop, and mobile swipe dismissals are
blocked together.
</LayerDialog.Description>
<LayerDialog.Body>
<Text variant="secondary">
Programmatic closes still work, so a successful save can dismiss the
dialog through `actionsRef` or a controlled `open` prop.
</Text>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary
loading={pending}
onClick={() => {
setPending(true);
window.setTimeout(() => setPending(false), 1500);
}}
>
Save changes
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Cleanup
Put cleanup in onOpenChange, rather than in a dismissal click handler, so it runs for X, Close, Escape, backdrop, and swipe.
import { useState } from "react";
import { Button, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogCleanupDemo() {
const [open, setOpen] = useState(false);
const [cleanupCount, setCleanupCount] = useState(0);
return (
<LayerDialog.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) setCleanupCount((count) => count + 1);
setOpen(nextOpen);
}}
>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open draft</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Draft settings</LayerDialog.Title>
<LayerDialog.Body>
<Text variant="secondary">
Cleanup has run {cleanupCount} time{cleanupCount === 1 ? "" : "s"}.
</Text>
</LayerDialog.Body>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Localization
The dialog renders two strings of its own. Everything else comes from your content, so translating a dialog means passing two props:
closeLabelonContentnames the automatic X button for assistive technology. Default: “Close dialog”.dismissLabelonActionsis the footer button’s text. Default: “Close”, or “Cancel” inside anAlert.
<LayerDialog.Content closeLabel="Dialog schließen">
...
<LayerDialog.Actions dismissLabel="Abbrechen">Desktop width
Desktop dialogs use size="base" (576px) by default. Use sm (448px), lg (672px), or xl (768px) when the content needs a different width. Mobile dialogs remain full-width at every size.
import { useState } from "react";
import { Button, Input, LayerDialog, KumoLayerDialogSize } from "@cloudflare/kumo";
export function LayerDialogSizeDemo() {
const [open, setOpen] = useState(false);
const [size, setSize] = useState<KumoLayerDialogSize>("base");
const openAtSize = (nextSize: KumoLayerDialogSize) => {
setSize(nextSize);
setOpen(true);
};
return (
<>
<div className="flex flex-wrap gap-2">
<Button onClick={() => openAtSize("sm")}>Small</Button>
<Button onClick={() => openAtSize("base")}>Default</Button>
<Button onClick={() => openAtSize("lg")}>Large</Button>
<Button onClick={() => openAtSize("xl")}>Extra large</Button>
</div>
<LayerDialog.Root open={open} onOpenChange={setOpen}>
<LayerDialog.Content size={size}>
<LayerDialog.Title>Review deployment configuration</LayerDialog.Title>
<LayerDialog.Description>
Confirm the service details and routing configuration before this
deployment is created.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="grid gap-5 sm:grid-cols-2">
<Input label="Service name" defaultValue="production-api" />
<Input label="Environment" defaultValue="Production" />
<Input label="Hostname" defaultValue="api.example.com" />
<Input label="Compatibility date" defaultValue="2026-09-09" />
</div>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary>
Create deployment
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
</>
);
}Desktop placement
Desktop dialogs center by default. Use the narrow verticalAlign="top" override only when its content benefits from top alignment; mobile remains a bottom sheet.
import { Button, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogTopAlignDemo() {
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open top-aligned dialog</Button>}
/>
<LayerDialog.Content verticalAlign="top">
<LayerDialog.Title>Top-aligned dialog</LayerDialog.Title>
<LayerDialog.Body>
<Text variant="secondary">Mobile dialogs remain bottom sheets.</Text>
</LayerDialog.Body>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Maximum height
Height is derived from content and capped by the viewport. Following Base UI’s inside-scroll pattern, the viewport reserves the vertical breathing room for each placement and the popup fills it, so a top-aligned dialog can never extend past the bottom edge. Past the cap, only the body scrolls while the title frame and actions stay pinned. Mobile sheets cap at 85% of the viewport instead. There are no height props: consumers control height only through the content they render.
import { useState } from "react";
import { Button, LayerDialog, KumoLayerDialogVerticalAlign } from "@cloudflare/kumo";
export function LayerDialogMaxHeightDemo() {
const [verticalAlign, setVerticalAlign] =
useState<KumoLayerDialogVerticalAlign>("center");
const [open, setOpen] = useState(false);
const openAt = (align: KumoLayerDialogVerticalAlign) => {
setVerticalAlign(align);
setOpen(true);
};
return (
<>
<div className="flex flex-wrap gap-2">
<Button onClick={() => openAt("center")}>Centered, tall content</Button>
<Button onClick={() => openAt("top")}>Top-aligned, tall content</Button>
</div>
<LayerDialog.Root open={open} onOpenChange={setOpen}>
<LayerDialog.Content verticalAlign={verticalAlign}>
<LayerDialog.Title>Audit log</LayerDialog.Title>
<LayerDialog.Description>
The dialog grows with its content until it reaches the viewport cap,
then only the body scrolls.
</LayerDialog.Description>
<LayerDialog.Body>
<ol className="flex flex-col gap-2">
{Array.from({ length: 40 }, (_, index) => (
<li
key={index}
className="rounded-lg border border-kumo-line px-3 py-2 text-kumo-subtle"
>
Entry {index + 1}
</li>
))}
</ol>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary>
Export log
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
</>
);
}API Reference
LayerDialog.Root
Controls the open state. Accepts every Base UI Drawer root prop, including
open, defaultOpen, onOpenChange, modal, and actionsRef. Doesn’t
render its own HTML element.
| Prop | Type | Default | Description |
|---|---|---|---|
| dismissDisabled | boolean | false | Blocks X, Close, Escape, backdrop, and swipe dismissal while work is pending. Programmatic closes are never blocked. |
LayerDialog.Alert
Same props as LayerDialog.Root. Forces modal, blocks pointer dismissal,
renders role="alertdialog", and requires LayerDialog.Actions.
LayerDialog.Trigger
A button that opens the dialog when clicked.
| Prop | Type | Default |
|---|
No component-specific props. Accepts standard HTML attributes.
LayerDialog.Content
Portals the backdrop and popup and validates the composition.
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactNode | - | - |
| container | PortalContainer | - | Container element for the portal. Overrides `KumoPortalProvider` context. |
| size | KumoLayerDialogSize | - | Desktop-only width. Mobile dialogs always remain full-width. |
| verticalAlign | KumoLayerDialogVerticalAlign | - | Desktop-only positioning. Mobile dialogs always remain bottom sheets. |
| closeLabel | string | - | Accessible name of the automatic X button. Translate it for non-English products. |
LayerDialog.Title
A heading that labels the dialog for accessibility.
| Prop | Type | Default |
|---|---|---|
| children* | ReactNode | - |
LayerDialog.Description
Optional supporting copy beneath the title. Becomes the dialog’s accessible description.
| Prop | Type | Default |
|---|---|---|
| children* | ReactNode | - |
LayerDialog.Body
The scrollable content region.
| Prop | Type | Default |
|---|---|---|
| children* | ReactNode | - |
LayerDialog.Actions
A footer with an automatic dismiss button and exactly one
LayerDialog.Actions.Primary.
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactElement<LayerDialogPrimaryProps> | - | - |
| dismissLabel | string | - | Text of the automatic dismiss button. Say "Cancel" only when the workflow has a real cancel outcome. Translate it for non-English products. |
LayerDialog.Actions.Primary
The single primary action. Renders a Kumo Button and accepts standard
button attributes such as onClick, disabled, and type.
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | ”primary” | “destructive" | "primary” | Visual emphasis of the action. Use |
| loading | boolean | false | Shows a spinner and disables the action while work is pending. |