Propel

Popover

A floating panel anchored to a trigger for arbitrary controls and content.

Show code
import { Badge } from "@makeplane/propel/components/badge";
import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import {
  Popover,
  PopoverBody,
  PopoverClose,
  PopoverContent,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";
import { TextArea, TextAreaGroup } from "@makeplane/propel/components/text-area";
import { Timer } from "lucide-react";

export default function BasicDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger />}
        label="Log time"
      />
      <PopoverContent positionMethod="fixed" aria-label="Log time">
        <PopoverBody>
          <div className="flex w-full flex-col gap-3">
            <Badge size="xs" variant="neutral" startIcon={<Icon icon={Timer} />} label="0h 0m" />
            <div className="flex gap-3">
              <div className="min-w-0 flex-1">
                <InputGroup size="lg">
                  <Input size="lg" aria-label="Hours" placeholder="Hours" />
                </InputGroup>
              </div>
              <div className="min-w-0 flex-1">
                <InputGroup size="lg">
                  <Input size="lg" aria-label="Minutes" placeholder="Minutes" />
                </InputGroup>
              </div>
            </div>
            <TextAreaGroup>
              <TextArea
                size="lg"
                surface="field"
                aria-label="Description"
                placeholder="Description"
                rows={3}
              />
            </TextAreaGroup>
          </div>
        </PopoverBody>
        <div className="flex shrink-0 justify-end gap-2">
          <Button
            variant="secondary"
            size="sm"
            stretch="auto"
            label="Cancel"
            render={<PopoverClose />}
          />
          <Button
            variant="primary"
            size="sm"
            stretch="auto"
            label="Save"
            render={<PopoverClose />}
          />
        </div>
      </PopoverContent>
    </Popover>
  );
}

Installation

import {
  Popover,
  PopoverTrigger,
  PopoverContent,
  PopoverBody,
  PopoverTitle,
  PopoverDescription,
  PopoverClose,
} from "@makeplane/propel/components/popover";

Usage

A popover is a trigger-anchored panel for arbitrary content — a form, a property sheet, or a short explanation — not a menu. Compose PopoverTrigger onto any control via render, then put the panel in PopoverContent. Tall rich stacks go in PopoverBody. The panel positions relative to the trigger that opened it.

import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

export default function UsageDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger />}
        label="About"
      />
      <PopoverContent variant="text">
        <PopoverTitle>Heading text</PopoverTitle>
        <PopoverDescription>
          A popup that displays information related to an element.
        </PopoverDescription>
      </PopoverContent>
    </Popover>
  );
}

Anatomy

Part Role
Popover Root context (open state, modality, handle, payload)
PopoverTrigger / PopoverClose Behavior grafts onto Button / IconButton via render. Trigger: click, click+hover (openOnHover), or hover only (openOnHover + openOnClick={false})
PopoverContent Portal + positioner + popup. variant is rich (default) or text
PopoverBody Scrollable slot for tall rich content. Title and actions stay outside so they stay pinned
PopoverTitle / PopoverDescription Accessible name + supporting copy (especially on variant="text")

rich is a 16px-padded, 12px-radius card with a 320px minimum. It grows with wider content; wrap children only to force a different width. The panel caps at the height available next to the trigger — put forms and long stacks in PopoverBody so they scroll; keep PopoverTitle and actions as siblings. When the body can overflow, pass tabIndex={0}. text is a compact 296px card with tight title + description padding.

Positioning lives on PopoverContent: side (default bottom), align (default start), plus the usual offset, collision, and anchor props. Pass a different trigger through PopoverTrigger’s render; the panel always anchors to the control that opened it.

Examples

Text

variant="text" is the compact title + description card. PopoverTitle and PopoverDescription wire aria-labelledby / aria-describedby on the panel.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

export default function TextDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger />}
        label="About"
      />
      <PopoverContent positionMethod="fixed" variant="text">
        <PopoverTitle>Heading text</PopoverTitle>
        <PopoverDescription>
          A popup that displays information related to an element.
        </PopoverDescription>
      </PopoverContent>
    </Popover>
  );
}

Open on hover

openOnHover (with a tuned delay) opens the panel on hover in addition to click. Keyboard and touch still work.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

export default function OpenOnHoverDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger openOnHover delay={100} />}
        label="Details"
      />
      <PopoverContent positionMethod="fixed" variant="text">
        <PopoverTitle>Applied filters</PopoverTitle>
        <PopoverDescription>State: In progress. Assignee: You. Due: this week.</PopoverDescription>
      </PopoverContent>
    </Popover>
  );
}

Hover only

Pass openOnHover with openOnClick={false} when a mouse click should not toggle the panel — glanceable previews, for example. Keyboard (Enter/Space) and touch still open so the surface is not pointer-locked.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

export default function HoverOnlyDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger openOnHover openOnClick={false} delay={100} />}
        label="Preview"
      />
      <PopoverContent positionMethod="fixed" variant="text">
        <PopoverTitle>Hover preview</PopoverTitle>
        <PopoverDescription>Opens on hover. A mouse click does not toggle it.</PopoverDescription>
      </PopoverContent>
    </Popover>
  );
}

Controlled

External state drives the popover via open and onOpenChange, so any control can open or close the panel.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";
import * as React from "react";

export default function ControlledDemo() {
  const [open, setOpen] = React.useState(false);

  return (
    <div className="flex flex-wrap items-center gap-3">
      <Popover open={open} onOpenChange={(next) => setOpen(next)}>
        <Button
          stretch="auto"
          variant="secondary"
          size="lg"
          render={<PopoverTrigger />}
          label="Details"
        />
        <PopoverContent positionMethod="fixed" variant="text">
          <PopoverTitle>Display options</PopoverTitle>
          <PopoverDescription>External state drives this panel.</PopoverDescription>
        </PopoverContent>
      </Popover>
      <Button
        stretch="auto"
        variant="tertiary"
        size="lg"
        onClick={() => setOpen((prev) => !prev)}
        label={open ? "Close panel" : "Open panel"}
      />
    </div>
  );
}

Detached trigger

createPopoverHandle() links a trigger that lives outside the Popover to it via the handle prop, so a button can open a panel declared elsewhere in the tree. The panel still anchors to the trigger that opened it.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  createPopoverHandle,
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

const displayPopoverHandle = createPopoverHandle();

export default function DetachedTriggerDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger handle={displayPopoverHandle} />}
        label="About"
      />
      <Popover handle={displayPopoverHandle}>
        <PopoverContent positionMethod="fixed" variant="text" align="end">
          <PopoverTitle>Display options</PopoverTitle>
          <PopoverDescription>This panel is declared away from its trigger.</PopoverDescription>
        </PopoverContent>
      </Popover>
    </div>
  );
}

Accessibility

The popup is role="dialog". Name it with PopoverTitle (preferred) or aria-label on PopoverContent. PopoverDescription becomes the described-by text. The trigger exposes aria-expanded / aria-haspopup from Base UI.

Keyboard: Enter / Space on the trigger opens the panel; Escape or an outside click dismisses it and returns focus to the trigger. PopoverClose (typically a Cancel/Save Button) also dismisses. openOnHover still leaves click available; openOnClick={false} suppresses only mouse click — keyboard and touch still open.

Default modal={false} leaves the page interactive and does not trap focus. modal={true} locks page scroll and outside pointers; modal="trap-focus" keeps focus in the panel without locking the page. Either trapping mode needs PopoverClose inside the popup so touch screen readers can leave. There is no backdrop — the panel does not dim the page.

API Reference

Popover

PropTypeDefaultDescription
openbooleanWhether the popover is open. Controlled; pair with `onOpenChange`.
defaultOpenbooleanfalseWhether the popover is open on mount. Uncontrolled.
onOpenChange((open: boolean, eventDetails: PopoverRootChangeEventDetails) => void)Called with the next open state when the popover opens or closes.
modalboolean | "trap-focus"falseHow the rest of the page behaves while open. Off by default so the page stays interactive. `true` locks page scroll and outside pointers; `"trap-focus"` keeps focus in the panel without locking the page. Focus trapping in either mode needs `PopoverClose` inside the popup so touch screen readers can leave. There is no backdrop — the panel does not dim the page.
childrenReactNode | PayloadChildRenderFunction<Payload>The popover's trigger and panel (`PopoverTrigger`, `PopoverContent`) and any title, description, or close parts composed inside.

PopoverTrigger

The element that opens the popover. Renders a `<button>` by default; pass `render` to project the trigger onto any control (`Button`, `IconButton`, a custom element). The panel anchors to whichever trigger opened it. Interaction: click only (default), click + hover (`openOnHover`), or hover only (`openOnHover` and `openOnClick={false}`).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PopoverTriggerState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.
handlePopoverHandle<Payload>A handle to associate the trigger with a popover.
payloadPayloadA payload to pass to the popover when it is opened.
openOnHoverbooleanfalseOpen the panel when the pointer hovers the trigger. Pair with `openOnClick={false}` for hover-only (a mouse click no longer toggles; keyboard and touch still open).
openOnClickbooleantrueOpen or close the panel when the trigger is clicked with a mouse. Set `false` with `openOnHover` when the panel should open on hover only.
delaynumber300How long to wait, in milliseconds, before opening on hover. Requires `openOnHover`.
closeDelaynumber0How long to wait, in milliseconds, before closing a hover-opened panel. Requires `openOnHover`.

PopoverContent

A trigger-anchored content panel. Composes the popover portal + positioner + styled popup so a consumer only writes the trigger and the panel body. Pass any trigger through `PopoverTrigger`'s `render`; pass `variant="text"` for the compact title + description card. Put tall `rich` content in `PopoverBody` so it scrolls under the available-height cap.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PopoverPopupState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.
variant"text" | "rich""rich"Panel look. `rich` is the padded content panel for forms and arbitrary children; `text` is the compact 296px title + description card.
side"top" | "bottom" | "left" | "right" | "inline-end" | "inline-start"bottomWhich side of the trigger the panel opens toward.
align"center" | "start" | "end"startAlignment of the panel relative to the trigger along `side`.
sideOffsetnumber | OffsetFunction4Distance in px between the trigger and the panel.
alignOffsetnumber | OffsetFunction0Additional offset in px along the align axis — slides the panel along the trigger's edge without changing `align`.
collisionPaddingPadding5Minimum gap in px between the panel and the viewport edge it would otherwise touch.
collisionBoundaryBoundaryThe element or rect the panel is confined to when avoiding collisions. Base UI's clipping ancestors when unset.
collisionAvoidanceCollisionAvoidanceWhat to do when the panel would overflow the boundary, set per axis: flip to the other side, shift along it, or stay put. Base UI flips the side and shifts the alignment when unset.
stickybooleanfalseKeep the panel on screen after the trigger scrolls out of view, instead of following it out.
positionMethod"absolute" | "fixed""absolute"Which CSS `position` the positioner uses. `fixed` escapes overflow-clipping ancestors, e.g. a trigger inside a sticky header. Unset, Base UI uses `absolute`.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullPosition against this element instead of the trigger — an element, a ref, a virtual element, or a function returning one. Geometry only; the trigger keeps its role and interactions.
disableAnchorTrackingbooleanfalseStop tracking layout shifts of the anchor — position once, on open.

PopoverBody

The scrollable slot inside `PopoverContent`. Grows to fill leftover space under the popup's available-height cap and scrolls when the body is taller than that space. Put forms and long stacks here; keep `PopoverTitle` and action rows as siblings so they stay pinned. When the body can overflow, pass `tabIndex={0}` so the scroll region is keyboard-reachable (axe `scrollable-region-focusable`).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PopoverTitle

The accessible title for the popover: Base UI's `Popover.Title` behavior (links to the popup via `aria-labelledby`) grafted onto the family-owned title chrome (`text-body-sm-medium`).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PopoverTitleState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PopoverDescription

Supporting text for the popover: Base UI's `Popover.Description` behavior (wired as the popup's `aria-describedby` target) grafted onto the shared `internal/OverlayDescription` at `md` (body-xs secondary).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PopoverDescriptionState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PopoverClose

The behavior that closes the popover when activated. Use as the `render` target of a `Button` or `IconButton` so the styled primitive's look wins via render-composition: ```tsx <Button variant="secondary" size="sm" stretch="auto" label="Cancel" render={<PopoverClose />} />; ``` Maps 1:1 to `Popover.Close`.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PopoverCloseState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.