Meowland docs
Workbench

Component reference

Popover

A floating panel anchored to a trigger, in the top layer.

Preview

Child controls and modal closing

packages/meowtail/stories/popover.stories.tsxline 22tsx
render() {
  let dialog: HTMLDialogElement | undefined;
  const childOpen = signal(false);
  const mounted = signal(true);
  const parentKeys = signal(0);
  const childKeys = signal(0);
  let closeFromCallback = false;
  return (
    <Stack gap="normal">
      <div>
        <Button onClick={() => dialog?.showModal()}>Open popover dialog</Button>
      </div>
      <Dialog
        id="escape-dialog"
        labelledBy="escape-title"
        ref={(node: HTMLDialogElement) => {
          dialog = node;
        }}
      >
        <DialogPanel
          header={
            <DialogHeader
              title="Local editing and returning"
              titleId="escape-title"
              actions={
                <Button variant="quiet" onClick={() => dialog?.close()}>
                  Close dialog
                </Button>
              }
            />
          }
        >
          <Stack gap="normal">
            <p>
              Input controls handle their own Escape first; if it is unhandled, the current
              popover closes.
            </p>
            <Popover
              id="escape-parent"
              onKeyDown={(event) => {
                if (event.key === "Escape") parentKeys.value++;
              }}
              trigger={(bindings) => <Button {...bindings}>Open parent popover</Button>}
            >
              <Stack gap="normal">
                <Field label="Local draft">
                  <Input aria-label="Local draft" />
                </Field>
                {mapSignal(mounted, (shown) =>
                  shown ? (
                    <Popover
                      id="escape-child"
                      open={childOpen}
                      onOpenChange={(next) => {
                        childOpen.value = next;
                      }}
                      onKeyDown={(event) => {
                        if (event.key === "Escape") {
                          childKeys.value++;
                          if (closeFromCallback) childOpen.value = false;
                        }
                      }}
                      trigger={(bindings) => <Button {...bindings}>Open child popover</Button>}
                    >
                      <Stack gap="normal">
                        <Input aria-label="Child popover draft" />
                        <Button
                          onClick={() => {
                            closeFromCallback = true;
                          }}
                        >
                          Close child popover via callback
                        </Button>
                        <Button
                          onClick={() => {
                            childOpen.value = false;
                          }}
                        >
                          Close child popover
                        </Button>
                        <Button
                          onClick={() => {
                            mounted.value = false;
                          }}
                        >
                          Remove child popover
                        </Button>
                      </Stack>
                    </Popover>
                  ) : (
                    <Button
                      onClick={() => {
                        childOpen.value = false;
                        mounted.value = true;
                      }}
                    >
                      Restore child popover
                    </Button>
                  ),
                )}
              </Stack>
            </Popover>
            <output id="escape-callbacks">
              Parent {parentKeys} · Child {childKeys}
            </output>
          </Stack>
        </DialogPanel>
      </Dialog>
    </Stack>
  );
}

Installation

bun add meowtail

Usage

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

Examples

The other stories that show this component.

In-place editing and returning

Expands content next to the current action; it can nest, open and close under control, and Escape returns to the previous layer.

packages/meowtail/stories/popover.stories.tsxline 145tsx
render() {
  const controlled = signal(false);
  const retained = signal(false);
  const redirected = signal(false);
  let external: HTMLElement | undefined;
  const paneTitle = signal("Choose settings");
  let inPane = false;
  return (
    <div className="workshop-stack workshop-constrained">
      <header className="workshop-composition-header">
        <h2>Settings kept at hand</h2>
        <p>Change how things are displayed, then return to what you were reading.</p>
      </header>
      <div className="workshop-actions">
        <Popover
          id="display-popover"
          role="dialog"
          labelledBy="display-title"
          trigger={(bindings) => (
            <Button variant="outline" {...bindings}>
              Display settings
            </Button>
          )}
        >
          <div className="workshop-section">
            <header className="workshop-composition-header">
              <h2 id="display-title">Display settings</h2>
              <p>Affects only the current preview.</p>
            </header>
            <Field label="Display name" htmlFor="popover-name">
              <Input id="popover-name" value="Work view" />
            </Field>
            <Popover
              id="nested-popover"
              role="dialog"
              labelledBy="nested-title"
              align="end"
              trigger={(bindings) => (
                <Button variant="quiet" {...bindings}>
                  More settings
                </Button>
              )}
            >
              <div className="workshop-section">
                <h3 id="nested-title" className="workshop-section-title">
                  Reading width
                </h3>
                <Note>Keep space for the body and let supporting actions step aside.</Note>
                <Input aria-label="Width note" placeholder="Write down your preference…" />
              </div>
            </Popover>
          </div>
        </Popover>
        <Popover
          id="controlled-popover"
          open={controlled}
          onOpenChange={(next) => {
            controlled.value = next;
          }}
          role="dialog"
          labelledBy="controlled-title"
          trigger={(bindings) => (
            <Button variant="quiet" {...bindings}>
              Open controlled panel
            </Button>
          )}
        >
          <div className="workshop-section">
            <h3 id="controlled-title" className="workshop-section-title">
              Open and close managed by the host
            </h3>
            <Note>Focus returns to the trigger after closing.</Note>
            <Button
              onClick={() => {
                controlled.value = false;
              }}
            >
              Close controlled panel
            </Button>
          </div>
        </Popover>
        <Button
          variant="quiet"
          ref={(element: HTMLElement) => {
            external = element;
          }}
        >
          External action
        </Button>
        <Popover
          id="retained-popover"
          open={retained}
          onOpenChange={(next) => {
            if (next) retained.value = true;
          }}
          role="dialog"
          labelledBy="retained-title"
          trigger={(bindings) => (
            <Button variant="quiet" {...bindings}>
              Keep controlled panel
            </Button>
          )}
        >
          <h3 id="retained-title">Host keeps the current panel</h3>
          <Input aria-label="Retained panel draft" value="Waiting for the host" />
        </Popover>
        <Popover
          id="redirected-popover"
          open={redirected}
          onOpenChange={(next) => {
            redirected.value = next;
            if (!next) external?.focus();
          }}
          role="dialog"
          labelledBy="redirected-title"
          trigger={(bindings) => (
            <Button variant="quiet" {...bindings}>
              Continue external action after closing
            </Button>
          )}
        >
          <h3 id="redirected-title">Host chooses the next focus</h3>
          <p>Continue to the external action after closing.</p>
        </Popover>
        <Popover
          id="pane-popover"
          role="dialog"
          labelledBy="pane-title"
          onKeyDown={(event) => {
            if (event.key === "Escape" && inPane) {
              event.preventDefault();
              inPane = false;
              paneTitle.value = "Choose settings";
            }
          }}
          onOpenChange={() => {
            inPane = false;
            paneTitle.value = "Choose settings";
          }}
          trigger={(bindings) => (
            <Button variant="quiet" {...bindings}>
              Layered settings
            </Button>
          )}
        >
          <div className="workshop-section">
            <h3 id="pane-title">{paneTitle}</h3>
            <Button
              onClick={() => {
                inPane = true;
                paneTitle.value = "Settings details";
              }}
            >
              Open details
            </Button>
          </div>
        </Popover>
      </div>
    </div>
  );
}

API reference

Props

PropTypeDefault
idRequired
string–
triggerId
string`${id}-trigger`
triggerRequired
(bindings: PopoverTriggerBindings) => Child–
open
MaybeSignal<boolean>–
defaultOpen
booleanfalse
onOpenChange
(open: boolean) => void–
matchTriggerWidth
booleanfalse
align
"start" | "end""start"
placement
"top" | "bottom" | "right""bottom"
anchorRect
() => DOMRect–
anchorElementElement whose layout defines a custom rect; observed by the same autoUpdate.
() => HTMLElement | null–
role
string–
labelledBy
string–
onKeyDownPrevent default on Escape to leave an inner pane before dismissing the layer.
(event: KeyboardEvent) => void–

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

DOM parts

rootpanel

Registry

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