Meowland docs
Workbench

Component reference

Modal

A complete native modal dialog over the Dialog primitives: the host owns the open state and every business action, while the Modal owns the native showModal/close mechanics, Escape and backdrop dismissal, focus return and the generated title/description ids.

Preview

Title, body, actions, and focus return

The host owns the open signal: the trigger opens a native modal, Escape, the backdrop and the header close entry ask the host to close it, and focus returns to the trigger.

packages/meowtail/stories/modal.stories.tsxline 15tsx
render() {
  const open = signal(false);
  return (
    <Stack align="start">
      <Button
        onClick={() => {
          open.value = true;
        }}
      >
        Rename workspace
      </Button>
      <p>The dialog edits one name and keeps its draft until the review is done.</p>
      <Modal
        open={open}
        onOpenChange={(next) => {
          open.value = next;
        }}
        title="Rename workspace"
        description="The name appears in the sidebar, shared links and browser titles."
        actions={[
          <Button
            variant="quiet"
            onClick={() => {
              open.value = false;
            }}
          >
            Cancel
          </Button>,
          <Button
            onClick={() => {
              open.value = false;
            }}
          >
            Save name
          </Button>,
        ]}
      >
        <Field label="Workspace name" htmlFor="modal-workspace-name">
          <Input id="modal-workspace-name" value="Meowland workspace" />
        </Field>
      </Modal>
    </Stack>
  );
}

Installation

bun add meowtail

Usage

usage.tsts
import { Modal } from "meowtail";

Examples

The other stories that show this component.

Long body scrolling

The title and close entry stay in the panel header while a long generated review scrolls in the body; the action row sits after the content.

packages/meowtail/stories/modal.stories.tsxline 71tsx
render() {
  const open = signal(false);
  return (
    <Stack align="start">
      <Button
        onClick={() => {
          open.value = true;
        }}
      >
        Review long summary
      </Button>
      <Modal
        open={open}
        onOpenChange={(next) => {
          open.value = next;
        }}
        title="Review generated summary"
        description="The source order and the operator comments stay together in the review."
        actions={[
          <Button
            variant="quiet"
            onClick={() => {
              open.value = false;
            }}
          >
            Close review
          </Button>,
          <Button
            onClick={() => {
              open.value = false;
            }}
          >
            Accept summary
          </Button>,
        ]}
      >
        <Stack gap="compact">
          {Array.from({ length: 24 }, (_, index) => (
            <p>
              Item {index + 1}: The generated summary keeps the source order, notes which files
              changed, and leaves the operator's comments beside the matching paragraph.
            </p>
          ))}
        </Stack>
      </Modal>
    </Stack>
  );
},
});

export const ModalNonDismissibleStory = defineStory({
id: "modal-non-dismissible",
title: "Modal · Explicit close only",
group: "Overlays and feedback",
groupOrder: 30,
component: "Modal",
variant: "Non-dismissible review",
description:
  "dismissible={false} removes the header close entry and ignores Escape and backdrop clicks, so only the host action can finish the review.",
covers: ["dismissible false", "no header close", "Escape ignored", "backdrop ignored"],
render() {
  const open = signal(false);
  return (
    <Stack align="start">
      <Button
        onClick={() => {
          open.value = true;
        }}
      >
        Start final review
      </Button>
      <Modal
        open={open}
        onOpenChange={(next) => {
          open.value = next;
        }}
        title="Final review in progress"
        description="Escape and the backdrop are disabled until the review completes."
        dismissible={false}
        actions={
          <Button
            onClick={() => {
              open.value = false;
            }}
          >
            Mark review complete
          </Button>
        }
      >
        <p>
          Finishing the review records the current summary. The dialog closes only from the
          explicit action so an accidental Escape does not lose the review.
        </p>
      </Modal>
    </Stack>
  );
},
});

Explicit close only

dismissible={false} removes the header close entry and ignores Escape and backdrop clicks, so only the host action can finish the review.

packages/meowtail/stories/modal.stories.tsxline 131tsx
render() {
  const open = signal(false);
  return (
    <Stack align="start">
      <Button
        onClick={() => {
          open.value = true;
        }}
      >
        Start final review
      </Button>
      <Modal
        open={open}
        onOpenChange={(next) => {
          open.value = next;
        }}
        title="Final review in progress"
        description="Escape and the backdrop are disabled until the review completes."
        dismissible={false}
        actions={
          <Button
            onClick={() => {
              open.value = false;
            }}
          >
            Mark review complete
          </Button>
        }
      >
        <p>
          Finishing the review records the current summary. The dialog closes only from the
          explicit action so an accidental Escape does not lose the review.
        </p>
      </Modal>
    </Stack>
  );
}

API reference

Props

PropTypeDefault
openRequiredRequired; the host owns the state. A Signal keeps the dialog in sync after mount.
MaybeSignal<boolean>–
onOpenChangeRequiredCalled with false when the reader dismisses the dialog (Escape, backdrop, header close entry, or a native close). The owner decides whether open changes; a controlled host that ignores the call keeps the dialog open.
(open: boolean) => void–
titleRequiredThe dialog heading and accessible name.
Child–
descriptionOptional supporting line under the heading; it is wired to the dialog description.
Child–
actionsOptional bottom action row; omit it for a body-only dialog.
Child–
dismissibleDefault true; false removes the header close entry and Escape/backdrop dismissal.
booleantrue
closeLabelAccessible name of the header close entry; default "Close".
string"Close"
idId of the transparent wrapper and base for the generated title/description ids. A generated base is used when omitted. Fixed for the mount.
string–
childrenRequired
Child–

Also takes className, children and the attributes and events of its root element.

DOM parts

root

Registry

The component is registered as modal in the meowtail component registry.