import { Popover, Button } from "@cloudflare/kumo";
import { BellIcon } from "@phosphor-icons/react";

export function PopoverHeroDemo() {
  return (
    <Popover.Root>
      <Popover.Trigger
        render={
          <Button shape="square" icon={BellIcon} aria-label="Notifications" />
        }
      />
      <Popover.Portal>
        <Popover.Positioner>
          <Popover.Popup>
            <Popover.Arrow />
            <Popover.Title>Notifications</Popover.Title>
            <Popover.Description>
              You are all caught up. Good job!
            </Popover.Description>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

Installation

Barrel

import { Popover } from "@cloudflare/kumo";

Granular

import { Popover } from "@cloudflare/kumo/components/popover";

Usage

import { Popover, Button } from "@cloudflare/kumo";

export default function Example() {
  return (
    <Popover.Root>
      <Popover.Trigger render={<Button />}>Open</Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner>
          <Popover.Popup>
            <Popover.Arrow />
            <Popover.Title>Popover Title</Popover.Title>
            <Popover.Description>Popover content goes here.</Popover.Description>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

Popover vs Tooltip

While popovers can be triggered on hover (using openOnHover), they serve a different purpose than tooltips. Understanding when to use each is important for accessibility and user experience.

TooltipPopover
Purpose

Short, non-interactive text labels for identification

Rich, interactive content containers

ContentPlain text only

Any content: links, buttons, forms, images

TriggerHover or focusClick (default) or hover
ARIA Role

role="tooltip"

aria-haspopup

KeyboardNot focusable

Focus moves inside, traps when open

Use a Tooltip when you need to label an icon button or provide a brief explanation. Use a Popover when users need to interact with the content inside, such as clicking links, filling out forms, or dismissing with a button.

Examples

Basic Popover

import { Popover, Button } from "@cloudflare/kumo";

export function PopoverBasicDemo() {
  return (
    <Popover.Root>
      <Popover.Trigger render={<Button />}>Open Popover</Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner>
          <Popover.Popup>
            <Popover.Arrow />
            <Popover.Title>Popover Title</Popover.Title>
            <Popover.Description>
              This is a basic popover with a title and description.
            </Popover.Description>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

Deprecated Content compatibility

Popover.Content is deprecated, but remains available so existing popovers do not break. It combines Portal, Positioner, Popup, and Arrow into one component and fades in smoothly using opacity only, without a scale or transform animation. New popovers should compose the individual parts as shown in the basic example.

import { Popover, Button } from "@cloudflare/kumo";

export function PopoverLegacyContentDemo() {
  return (
    <Popover>
      <Popover.Trigger render={<Button variant="secondary" />}>
        Open legacy Content
      </Popover.Trigger>
      <Popover.Content className="w-72">
        <Popover.Title>Compatibility mode</Popover.Title>
        <Popover.Description>
          This legacy wrapper fades in without scaling or transforming.
        </Popover.Description>
      </Popover.Content>
    </Popover>
  );
}

Scrollable content

When the arrow and heading should remain fixed, place the overflowing content in an inner scroll container. Existing overflow styles on the deprecated Popover.Content remain supported by its fade-only transition.

import { Popover, Button } from "@cloudflare/kumo";

export function PopoverOverflowDemo() {
  return (
    <Popover.Root>
      <Popover.Trigger render={<Button variant="secondary" />}>
        Open scrollable Popover
      </Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner>
          <Popover.Popup className="w-72">
            <Popover.Arrow />
            <Popover.Title>Recent notifications</Popover.Title>
            <Popover.Description>
              The list scrolls while the heading and arrow stay in place.
            </Popover.Description>
            <div
              aria-label="Recent notifications"
              className="mt-3 max-h-40 overflow-y-auto rounded-md border border-kumo-hairline outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand"
              tabIndex={0}
            >
              {Array.from({ length: 10 }, (_, index) => (
                <div
                  key={index}
                  className="border-b border-kumo-hairline px-3 py-2 last:border-b-0"
                >
                  Notification {index + 1}
                </div>
              ))}
            </div>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

CSS variables

Base UI exposes the space between the trigger and the viewport edge as --available-height. This example gives the popup a preferred height of 320px, then uses the variable as its maximum height so the popup shrinks and its inner content scrolls when less space is available.

import { Popover, Button } from "@cloudflare/kumo";

export function PopoverCssVariablesDemo() {
  return (
    <Popover.Root>
      <Popover.Trigger render={<Button />}>Open tall Popover</Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner>
          <Popover.Popup className="h-80 max-h-[var(--available-height)] w-72">
            <Popover.Arrow />
            <Popover.Title>Available-height popup</Popover.Title>
            <Popover.Description>
              The popup is 320px tall unless the viewport provides less space.
            </Popover.Description>
            <div
              aria-label="Popover rows"
              className="mt-3 min-h-0 flex-1 overflow-y-auto rounded-md border border-kumo-hairline outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand"
              tabIndex={0}
            >
              {Array.from({ length: 12 }, (_, index) => (
                <div
                  key={index}
                  className="border-b border-kumo-hairline px-3 py-2 last:border-b-0"
                >
                  Row {index + 1}
                </div>
              ))}
            </div>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

With Close Button

import { Popover, Button } from "@cloudflare/kumo";

export function PopoverWithCloseDemo() {
  return (
    <Popover.Root>
      <Popover.Trigger render={<Button />}>Open Settings</Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner>
          <Popover.Popup>
            <Popover.Arrow />
            <Popover.Title>Settings</Popover.Title>
            <Popover.Description>
              Configure your preferences below.
            </Popover.Description>
            <div className="mt-3">
              <Popover.Close render={<Button variant="secondary" size="sm" />}>
                Close
              </Popover.Close>
            </div>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

Positioning

Use the side prop on Popover.Positioner to control where the popover appears relative to the trigger.

import { Popover, Button } from "@cloudflare/kumo";

export function PopoverPositionDemo() {
  return (
    <div className="flex flex-wrap gap-4">
      <Popover.Root>
        <Popover.Trigger render={<Button variant="secondary" />}>
          Bottom
        </Popover.Trigger>
        <Popover.Portal>
          <Popover.Positioner side="bottom">
            <Popover.Popup>
              <Popover.Arrow />
              <Popover.Title>Bottom</Popover.Title>
              <Popover.Description>
                Popover on bottom (default).
              </Popover.Description>
            </Popover.Popup>
          </Popover.Positioner>
        </Popover.Portal>
      </Popover.Root>

      <Popover.Root>
        <Popover.Trigger render={<Button variant="secondary" />}>
          Top
        </Popover.Trigger>
        <Popover.Portal>
          <Popover.Positioner side="top">
            <Popover.Popup>
              <Popover.Arrow />
              <Popover.Title>Top</Popover.Title>
              <Popover.Description>Popover on top.</Popover.Description>
            </Popover.Popup>
          </Popover.Positioner>
        </Popover.Portal>
      </Popover.Root>

      <Popover.Root>
        <Popover.Trigger render={<Button variant="secondary" />}>
          Left
        </Popover.Trigger>
        <Popover.Portal>
          <Popover.Positioner side="left">
            <Popover.Popup>
              <Popover.Arrow />
              <Popover.Title>Left</Popover.Title>
              <Popover.Description>Popover on left.</Popover.Description>
            </Popover.Popup>
          </Popover.Positioner>
        </Popover.Portal>
      </Popover.Root>

      <Popover.Root>
        <Popover.Trigger render={<Button variant="secondary" />}>
          Right
        </Popover.Trigger>
        <Popover.Portal>
          <Popover.Positioner side="right">
            <Popover.Popup>
              <Popover.Arrow />
              <Popover.Title>Right</Popover.Title>
              <Popover.Description>Popover on right.</Popover.Description>
            </Popover.Popup>
          </Popover.Positioner>
        </Popover.Portal>
      </Popover.Root>
    </div>
  );
}

Custom Content

Popovers can contain any content, including custom layouts with avatars, buttons, and more.

import { Popover, Button } from "@cloudflare/kumo";

export function PopoverCustomContentDemo() {
  return (
    <Popover.Root>
      <Popover.Trigger render={<Button />}>User Profile</Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner>
          <Popover.Popup className="w-64">
            <Popover.Arrow />
            <div className="flex items-center gap-3">
              <div className="size-10 rounded-full bg-kumo-recessed" />
              <div>
                <Popover.Title>Jane Doe</Popover.Title>
                <p className="text-sm text-kumo-subtle">jane@example.com</p>
              </div>
            </div>
            <div className="mt-3 flex gap-2 border-t border-kumo-hairline pt-3">
              <Button variant="secondary" size="sm" className="flex-1">
                Profile
              </Button>
              <Popover.Close
                render={<Button variant="ghost" size="sm" className="flex-1" />}
              >
                Sign Out
              </Popover.Close>
            </div>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

Open on Hover

Use openOnHover on the trigger to open the popover when the user hovers over it. You can also specify a delay in milliseconds before the popover appears.

import { Popover, Button } from "@cloudflare/kumo";

export function PopoverOpenOnHoverDemo() {
  return (
    <Popover.Root>
      <Popover.Trigger
        openOnHover
        delay={200}
        render={<Button variant="secondary" />}
      >
        Hover Me
      </Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner>
          <Popover.Popup>
            <Popover.Arrow />
            <Popover.Title>Hover Triggered</Popover.Title>
            <Popover.Description>
              This popover opens on hover with a 200ms delay. It can still
              contain interactive content like buttons and links.
            </Popover.Description>
            <div className="mt-3">
              <Popover.Close render={<Button variant="secondary" size="sm" />}>
                Got it
              </Popover.Close>
            </div>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

Virtual Anchor

Use the anchor prop on Popover.Positioner to position the popover against an element other than the trigger, or against a virtual point (e.g., a DOMRect from getBoundingClientRect()). This is useful when the trigger and the desired anchor are in different component trees.

NameStatus
api-gatewayActive
auth-serviceActive
worker-prodPaused
import { useState, useRef } from "react";
import { Popover, Button } from "@cloudflare/kumo";
import { DotsThree } from "@phosphor-icons/react";

/** Popover anchored to a virtual element instead of a trigger. */
export function PopoverVirtualAnchorDemo() {
  const [selectedRow, setSelectedRow] = useState<string | null>(null);
  const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
  const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map());

  const rows = [
    { id: "1", name: "api-gateway", status: "Active" },
    { id: "2", name: "auth-service", status: "Active" },
    { id: "3", name: "worker-prod", status: "Paused" },
  ];

  const handleEdit = (id: string) => {
    const row = rowRefs.current.get(id);
    if (row) {
      setAnchorRect(row.getBoundingClientRect());
      setSelectedRow(id);
    }
  };

  return (
    <div className="w-full">
      <div className="overflow-hidden rounded-lg border border-kumo-hairline">
        <table className="w-full text-sm">
          <thead className="bg-kumo-elevated">
            <tr>
              <th className="px-4 py-2 text-left font-medium">Name</th>
              <th className="px-4 py-2 text-left font-medium">Status</th>
              <th className="w-12 px-4 py-2"></th>
            </tr>
          </thead>
          <tbody className="divide-y divide-kumo-hairline">
            {rows.map((row) => (
              <tr
                key={row.id}
                ref={(el) => {
                  if (el) rowRefs.current.set(row.id, el);
                }}
                className={
                  selectedRow === row.id ? "bg-kumo-recessed" : "bg-kumo-base"
                }
              >
                <td className="px-4 py-2 font-mono">{row.name}</td>
                <td className="px-4 py-2 text-kumo-subtle">{row.status}</td>
                <td className="px-4 py-2">
                  <Button
                    size="xs"
                    variant="ghost"
                    shape="square"
                    icon={DotsThree}
                    aria-label={`Actions for ${row.name}`}
                    onClick={() => handleEdit(row.id)}
                  />
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      <Popover.Root
        open={!!selectedRow}
        onOpenChange={(open) => !open && setSelectedRow(null)}
      >
        <Popover.Portal>
          <Popover.Positioner
            side="left"
            anchor={
              anchorRect
                ? { getBoundingClientRect: () => anchorRect }
                : undefined
            }
          >
            <Popover.Popup>
              <Popover.Arrow />
              <Popover.Title>
                Edit {rows.find((r) => r.id === selectedRow)?.name}
              </Popover.Title>
              <Popover.Description>
                The popover anchors to the selected row, not the icon button.
              </Popover.Description>
              <div className="mt-3">
                <Popover.Close
                  render={<Button size="sm" variant="secondary" />}
                >
                  Close
                </Popover.Close>
              </div>
            </Popover.Popup>
          </Popover.Positioner>
        </Popover.Portal>
      </Popover.Root>
    </div>
  );
}

API Reference

Popover.Root

The root component that manages the popover’s open state.

PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right""bottom"Which side of the trigger the popover appears on. - `"top"` — Above the trigger - `"bottom"` — Below the trigger - `"left"` — Left of the trigger - `"right"` — Right of the trigger

Popover.Trigger

A button that opens the popover when clicked. Use render to render your own element.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Portal

Renders the popover outside the application DOM hierarchy. It uses the container from KumoPortalProvider by default and accepts a container prop to override it.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Backdrop

An optional backdrop rendered behind the popover.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Positioner

Positions the popup relative to its trigger or an anchor. Use side, align, and their offset props to control placement.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Popup

The styled popup container. Place Popover.Arrow and the popover content directly inside it.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Arrow

An optional arrow that points toward the popover’s anchor.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Viewport

A viewport for animating content changes when one popup is shared by multiple triggers. It is not required for ordinary popovers or scrolling content.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Content

A deprecated compatibility wrapper that combines Portal, Positioner, Popup, and Arrow. Existing usages remain supported, but new code should compose the individual parts shown above.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Title

A heading that labels the popover for accessibility.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Description

A paragraph providing additional context about the popover content.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

Popover.Close

A button that closes the popover when clicked. Use render to render your own element.

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.