Popover

Displays rich content in a portal, triggered by a button.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

const styles = stylex.create({
  content: { width: "20rem" },
  label: { flexGrow: 0, flexShrink: 0, flexBasis: "6rem" },
})

const dimensions = [
  { id: "popover-demo-width", label: "Width", value: "100%" },
  { id: "popover-demo-max-width", label: "Max. width", value: "300px" },
  { id: "popover-demo-height", label: "Height", value: "25px" },
  { id: "popover-demo-max-height", label: "Max. height", value: "none" },
]

export default function PopoverDemo() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="outline" />}>Open popover</PopoverTrigger>
      <PopoverContent xstyle={styles.content}>
        <PopoverHeader>
          <PopoverTitle>Dimensions</PopoverTitle>
          <PopoverDescription>Set the dimensions for the layer.</PopoverDescription>
        </PopoverHeader>
        <FieldGroup gap={2}>
          {dimensions.map((dimension) => (
            <Field key={dimension.id} orientation="horizontal">
              <FieldLabel xstyle={styles.label}>{dimension.label}</FieldLabel>
              <Input id={dimension.id} defaultValue={dimension.value} />
            </Field>
          ))}
        </FieldGroup>
      </PopoverContent>
    </Popover>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. states.ts holds the shared opaque surface that the popup box, the dialog and the hover card all use. state-styles.ts reads the popup's transition state.

Copy and paste the following code into your project.

components/ui/popover.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import type { CSSProperties } from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { stateStyles } from "@/lib/state-styles"
import { states } from "@/theme/states"
import { color, duration, ease, layer, leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  hidden: { opacity: 0 },
  content: {
    zIndex: layer.popup,
    display: "flex",
    flexDirection: "column",
    gap: space.s2_5,
    width: "18rem",
    maxWidth: "var(--available-width)",
    borderRadius: radius.xl,
    padding: space.s2_5,
    fontSize: text.sm,
    lineHeight: leading.sm,
    transformOrigin: "var(--transform-origin)",
    transitionProperty: "opacity, transform",
    transitionDuration: {
      default: duration.fade,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
  header: { display: "grid", rowGap: space.s0_5 },
  title: { fontWeight: 500 },
  description: { color: color.mutedForeground },
})

const slide = stylex.create({
  top: { transform: `scale(0.95) translateY(${space.s2})` },
  bottom: { transform: `scale(0.95) translateY(calc(-1 * ${space.s2}))` },
  left: { transform: `scale(0.95) translateX(${space.s2})` },
  right: { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
  "inline-start": { transform: `scale(0.95) translateX(${space.s2})` },
  "inline-end": { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
})

type Overrides = { xstyle?: StyleXStyles; className?: string }
type StateOverrides = Overrides & { style?: CSSProperties }

function Popover<Payload>(props: PopoverPrimitive.Root.Props<Payload>) {
  return <PopoverPrimitive.Root {...props} />
}

function PopoverTrigger<Payload>(props: PopoverPrimitive.Trigger.Props<Payload>) {
  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}

function PopoverClose(props: PopoverPrimitive.Close.Props) {
  return <PopoverPrimitive.Close data-slot="popover-close" {...props} />
}

type PopoverContentProps = Omit<PopoverPrimitive.Popup.Props, "className" | "style"> &
  Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset"> &
  StateOverrides

function PopoverContent({
  xstyle,
  style,
  className,
  align = "center",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  ...props
}: PopoverContentProps) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
        {...stylex.props(states.positioner)}
      >
        <PopoverPrimitive.Popup
          data-slot="popover-content"
          {...props}
          {...stateStyles(
            (state) => [
              states.popupSurface,
              styles.content,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                styles.hidden,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                slide[state.side],
              xstyle,
            ],
            { className, style },
          )}
        />
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  )
}

function PopoverHeader({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(styles.header, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "popover-header",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function PopoverTitle({ xstyle, className, ...props }: PopoverPrimitive.Title.Props & Overrides) {
  const sx = stylex.props(styles.title, xstyle)
  return (
    <PopoverPrimitive.Title
      data-slot="popover-title"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function PopoverDescription({
  xstyle,
  className,
  ...props
}: PopoverPrimitive.Description.Props & Overrides) {
  const sx = stylex.props(styles.description, xstyle)
  return (
    <PopoverPrimitive.Description
      data-slot="popover-description"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

export {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
}

Update the import paths to match your project setup.

Usage

import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
<Popover>
  <PopoverTrigger render={<Button variant="outline" />}>Open</PopoverTrigger>
  <PopoverContent>
    <PopoverHeader>
      <PopoverTitle>Dimensions</PopoverTitle>
      <PopoverDescription>Set the dimensions for the layer.</PopoverDescription>
    </PopoverHeader>
  </PopoverContent>
</Popover>

Composition

Popover
├── PopoverTrigger
└── PopoverContent          portal + positioner + popup
    ├── PopoverHeader
    │   ├── PopoverTitle
    │   └── PopoverDescription
    └── PopoverClose

PopoverTrigger renders a <button>. Pass render to use another element as the trigger, such as a Button variant.

The popup is a column with a 0.625rem gap, so PopoverHeader and whatever follows it need no margins of their own.

Basic

PopoverHeader stacks PopoverTitle over PopoverDescription. Base UI wires both to the popup's aria-labelledby and aria-describedby.

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export default function PopoverBasic() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="outline" />}>Open popover</PopoverTrigger>
      <PopoverContent align="start">
        <PopoverHeader>
          <PopoverTitle>Dimensions</PopoverTitle>
          <PopoverDescription>Set the dimensions for the layer.</PopoverDescription>
        </PopoverHeader>
      </PopoverContent>
    </Popover>
  )
}

Align

Use align on PopoverContent to slide the popup along the trigger's edge, and side to pick which edge it opens from. Base UI flips the side when there is no room.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { HStack } from "@/components/ui/stack"

const styles = stylex.create({
  content: { width: "10rem" },
})

const alignments = ["start", "center", "end"] as const

export default function PopoverAlignments() {
  return (
    <HStack gap={6}>
      {alignments.map((align) => (
        <Popover key={align}>
          <PopoverTrigger render={<Button variant="outline" size="sm" />}>{align}</PopoverTrigger>
          <PopoverContent align={align} xstyle={styles.content}>
            Aligned to {align}
          </PopoverContent>
        </Popover>
      ))}
    </HStack>
  )
}

With form

Put controls straight in the popup. PopoverClose is a button that closes it, so a cancel and a confirm both go through the same part.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
import { HStack } from "@/components/ui/stack"

const styles = stylex.create({
  label: { flexGrow: 0, flexShrink: 0, flexBasis: "4rem" },
  actions: { justifyContent: "flex-end" },
})

export default function PopoverForm() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="outline" />}>Open popover</PopoverTrigger>
      <PopoverContent align="start">
        <PopoverHeader>
          <PopoverTitle>Dimensions</PopoverTitle>
          <PopoverDescription>Set the dimensions for the layer.</PopoverDescription>
        </PopoverHeader>
        <FieldGroup gap={2}>
          <Field orientation="horizontal">
            <FieldLabel xstyle={styles.label}>Width</FieldLabel>
            <Input id="popover-form-width" defaultValue="100%" />
          </Field>
          <Field orientation="horizontal">
            <FieldLabel xstyle={styles.label}>Height</FieldLabel>
            <Input id="popover-form-height" defaultValue="25px" />
          </Field>
          <HStack gap={2} xstyle={styles.actions}>
            <PopoverClose render={<Button variant="ghost" size="sm" />}>Cancel</PopoverClose>
            <PopoverClose render={<Button size="sm" />}>Save</PopoverClose>
          </HStack>
        </FieldGroup>
      </PopoverContent>
    </Popover>
  )
}

A popover is not modal by default. The page keeps scrolling and a click outside closes it. Pass modal to Popover to lock the page instead, and render a PopoverClose inside the popup. Base UI only traps focus when it finds one, so without it a keyboard user can tab back out of a locked page.

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Popover part of the same name and accepts its props. See the Base UI reference for the full list. Each styled part also takes xstyle and className.

Popover

PropTypeDefault
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean, details) => void—
modalboolean | "trap-focus"false
handlePopoverHandle—

PopoverTrigger

PropTypeDefault
openOnHoverbooleanfalse
delaynumber300
closeDelaynumber0
payloadPayload—

delay and closeDelay do nothing without openOnHover.

PopoverContent

PropTypeDefault
side"top" | "bottom" | "left" | "right""bottom"
sideOffsetnumber4
align"start" | "center" | "end""center"
alignOffsetnumber0

The popup is 18rem wide and clamped to --available-width, so it narrows rather than overflowing a phone-width viewport. Change the width through xstyle.

Data attributes

AttributeOnEffect
data-slot="popover-content"the popupPopup surface
data-side, data-alignthe popupWhich way it slides in from
data-starting-style, data-ending-stylethe popupBase UI writes them while the popup animates
data-open, data-closedbothBase UI writes them on the trigger and the popup