Layer Dialog
@cloudflare/kumo
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:

  • closeLabel on Content names the automatic X button for assistive technology. Default: “Close dialog”.
  • dismissLabel on Actions is the footer button’s text. Default: “Close”, or “Cancel” inside an Alert.
<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.

PropTypeDefaultDescription
dismissDisabledbooleanfalse

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.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

LayerDialog.Content

Portals the backdrop and popup and validates the composition.

PropTypeDefaultDescription
children*ReactNode--
containerPortalContainer-Container element for the portal. Overrides `KumoPortalProvider` context.
sizeKumoLayerDialogSize-Desktop-only width. Mobile dialogs always remain full-width.
verticalAlignKumoLayerDialogVerticalAlign-Desktop-only positioning. Mobile dialogs always remain bottom sheets.
closeLabelstring-Accessible name of the automatic X button. Translate it for non-English products.

LayerDialog.Title

A heading that labels the dialog for accessibility.

PropTypeDefault
children*ReactNode-

LayerDialog.Description

Optional supporting copy beneath the title. Becomes the dialog’s accessible description.

PropTypeDefault
children*ReactNode-

LayerDialog.Body

The scrollable content region.

PropTypeDefault
children*ReactNode-

LayerDialog.Actions

A footer with an automatic dismiss button and exactly one LayerDialog.Actions.Primary.

PropTypeDefaultDescription
children*ReactElement<LayerDialogPrimaryProps>--
dismissLabelstring-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.

PropTypeDefaultDescription
variant”primary” | “destructive""primary”

Visual emphasis of the action. Use destructive when confirming an irreversible action such as a delete.

loadingbooleanfalse

Shows a spinner and disables the action while work is pending.