# Popover

> A floating panel anchored to a trigger that resizes smoothly with its content and follows the trigger’s direction.

Docs: https://hextaui.com/docs/popover
Markdown: https://hextaui.com/docs/popover.md

```tsx title="components/examples/popover/demo.tsx"
import { IconAdjustmentsHorizontal } from "@tabler/icons-react"

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

const field =
  "h-8 w-full min-w-0 rounded-md border bg-transparent px-2 text-sm outline-none focus-visible:outline-hidden focus-visible:ring-3 focus-visible:ring-focus-ring pointer-coarse:h-11 pointer-coarse:text-lg"

export function PopoverDemo() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="outline" />}>
        <IconAdjustmentsHorizontal />
        Dimensions
      </PopoverTrigger>
      <PopoverContent>
        <PopoverHeader>
          <PopoverTitle>Dimensions</PopoverTitle>
          <PopoverDescription>
            Set the dimensions for the layer.
          </PopoverDescription>
        </PopoverHeader>
        <div className="grid grid-cols-[5rem_minmax(0,1fr)] items-center gap-2">
          <label htmlFor="popover-width" className="text-sm">
            Width
          </label>
          <input id="popover-width" className={field} defaultValue="100%" />
          <label htmlFor="popover-height" className="text-sm">
            Height
          </label>
          <input id="popover-height" className={field} defaultValue="25px" />
        </div>
        <div className="flex justify-end gap-2">
          <PopoverClose render={<Button variant="ghost" size="sm" />}>
            Cancel
          </PopoverClose>
          <PopoverClose render={<Button size="sm" />}>Apply</PopoverClose>
        </div>
      </PopoverContent>
    </Popover>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/popover.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/popover.tsx"
"use client"

import * as React from "react"
import {
  DirectionProvider,
  useDirection,
  type TextDirection,
} from "@base-ui/react/direction-provider"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "cn"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

function readDirection(element: Element | null | undefined) {
  if (!element || !element.isConnected) {
    return undefined
  }
  return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr"
}

function Popover<Payload>({
  onOpenChange,
  children,
  ...props
}: PopoverPrimitive.Root.Props<Payload>) {
  const inherited = useDirection()
  const [triggerDirection, setTriggerDirection] =
    React.useState<TextDirection>()
  const direction = triggerDirection === "rtl" ? "rtl" : inherited

  return (
    <DirectionProvider direction={direction}>
      <PopoverPrimitive.Root
        onOpenChange={(open, eventDetails) => {
          onOpenChange?.(open, eventDetails)
          if (open && !eventDetails.isCanceled && eventDetails.trigger) {
            setTriggerDirection(readDirection(eventDetails.trigger))
          }
        }}
        {...props}
      >
        {children}
      </PopoverPrimitive.Root>
    </DirectionProvider>
  )
}

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

function PopoverPortal(props: PopoverPrimitive.Portal.Props) {
  return <PopoverPrimitive.Portal data-slot="popover-portal" {...props} />
}

const tracking = 80

function useAnimatedHeight() {
  return React.useCallback((sizer: HTMLDivElement | null) => {
    const popup = sizer?.parentElement
    if (!sizer || !popup || typeof ResizeObserver === "undefined") {
      return
    }
    const positioner = popup.parentElement
    if (positioner && !positioner.hasAttribute("dir")) {
      const trigger = popup.id
        ? popup.ownerDocument.querySelector(
            `[aria-controls="${CSS.escape(popup.id)}"]`
          )
        : null
      if (readDirection(trigger) === "rtl") {
        positioner.setAttribute("dir", "rtl")
      }
    }
    let last = 0
    let settle: ReturnType<typeof setTimeout> | undefined
    let sized: ReturnType<typeof setTimeout> | undefined
    const endSizing = (event?: TransitionEvent) => {
      if (
        event &&
        (event.target !== popup || event.propertyName !== "height")
      ) {
        return
      }
      clearTimeout(sized)
      delete popup.dataset.sizing
    }
    popup.addEventListener("transitionend", endSizing)
    popup.addEventListener("transitioncancel", endSizing)
    const observer = new ResizeObserver(([entry]) => {
      const now = performance.now()
      const continuous = now - last < tracking
      if (continuous) {
        popup.dataset.resizing = ""
      }
      last = now
      clearTimeout(settle)
      settle = setTimeout(() => {
        delete popup.dataset.resizing
      }, tracking)
      if (popup.style.height && !continuous) {
        popup.dataset.sizing = ""
        clearTimeout(sized)
        sized = setTimeout(endSizing, 300)
      }
      const style = getComputedStyle(popup)
      const chrome =
        parseFloat(style.paddingTop) +
        parseFloat(style.paddingBottom) +
        parseFloat(style.borderTopWidth) +
        parseFloat(style.borderBottomWidth)
      const height =
        entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height
      popup.style.height = `${height + chrome}px`
    })
    observer.observe(sizer)
    return () => {
      observer.disconnect()
      clearTimeout(settle)
      clearTimeout(sized)
      popup.removeEventListener("transitionend", endSizing)
      popup.removeEventListener("transitioncancel", endSizing)
      delete popup.dataset.resizing
      delete popup.dataset.sizing
      popup.style.height = ""
    }
  }, [])
}

type PopoverContentProps = PopoverPrimitive.Popup.Props &
  Pick<
    PopoverPrimitive.Positioner.Props,
    | "side"
    | "align"
    | "sideOffset"
    | "alignOffset"
    | "anchor"
    | "collisionPadding"
    | "collisionAvoidance"
    | "collisionBoundary"
    | "sticky"
    | "positionMethod"
  > & {
    portalProps?: Omit<PopoverPrimitive.Portal.Props, "children">
  }

function PopoverContent({
  className,
  children,
  side = "bottom",
  align = "center",
  sideOffset = 6,
  alignOffset = 0,
  anchor,
  collisionPadding = 8,
  collisionAvoidance,
  collisionBoundary,
  sticky,
  positionMethod,
  portalProps,
  ...props
}: PopoverContentProps) {
  const sizerRef = useAnimatedHeight()
  const direction = useDirection()

  return (
    <PopoverPrimitive.Portal {...portalProps}>
      <PopoverPrimitive.Positioner
        data-slot="popover-positioner"
        dir={direction === "rtl" ? "rtl" : undefined}
        side={side}
        align={align}
        sideOffset={sideOffset}
        alignOffset={alignOffset}
        anchor={anchor}
        collisionPadding={collisionPadding}
        collisionAvoidance={collisionAvoidance}
        collisionBoundary={collisionBoundary}
        sticky={sticky}
        positionMethod={positionMethod}
        className="isolate z-50 outline-none focus-visible:outline-hidden"
      >
        <PopoverPrimitive.Popup
          data-slot="popover-content"
          className={mergeClassName(
            "relative flex max-h-(--available-height) w-72 max-w-(--available-width) origin-(--transform-origin) flex-col gap-2.5 overflow-x-hidden overflow-y-auto overscroll-none rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-(length:--hairline) ring-foreground/10 transition-[opacity,scale,height] duration-150 ease-out-quint outline-none focus-visible:outline-hidden data-ending-style:opacity-0 data-ending-style:duration-100 data-ending-style:data-instant:transition-none data-starting-style:opacity-0 data-[resizing]:transition-[opacity,scale] data-[sizing]:overflow-y-hidden motion-safe:data-ending-style:scale-96 motion-safe:data-starting-style:scale-96 motion-reduce:transition-opacity forced-colors:border",
            className
          )}
          {...props}
        >
          <div
            ref={sizerRef}
            data-slot="popover-content-sizer"
            className="flex min-w-0 shrink-0 flex-col gap-[inherit]"
          >
            {children}
          </div>
        </PopoverPrimitive.Popup>
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  )
}

function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="popover-header"
      className={cn("flex min-w-0 flex-col gap-0.5 text-sm", className)}
      {...props}
    />
  )
}

function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
  return (
    <PopoverPrimitive.Title
      data-slot="popover-title"
      className={mergeClassName(
        "text-sm font-medium text-pretty wrap-anywhere",
        className
      )}
      {...props}
    />
  )
}

function PopoverDescription({
  className,
  ...props
}: PopoverPrimitive.Description.Props) {
  return (
    <PopoverPrimitive.Description
      data-slot="popover-description"
      className={mergeClassName(
        "text-sm text-pretty wrap-anywhere text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

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

const createPopoverHandle = PopoverPrimitive.createHandle

export {
  Popover,
  PopoverTrigger,
  PopoverPortal,
  PopoverContent,
  PopoverHeader,
  PopoverTitle,
  PopoverDescription,
  PopoverClose,
  createPopoverHandle,
}
export type { PopoverContentProps }
```

Update the import paths to match your project setup.

## Usage

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

```tsx
<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

```text
Popover
├── PopoverTrigger
└── PopoverContent
    ├── PopoverHeader
    │   ├── PopoverTitle
    │   └── PopoverDescription
    └── PopoverClose
```

## Examples

### Content that changes size

When the content grows or shrinks, the popup animates its height instead of jumping. Continuous changes, like typing, follow the content directly so nothing lags behind.

```tsx title="components/examples/popover/resizing.tsx"
"use client"

import * as React from "react"
import { IconBell } from "@tabler/icons-react"

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

export function PopoverResizing() {
  const [rows, setRows] = React.useState(1)
  const [loading, setLoading] = React.useState(false)
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)

  React.useEffect(() => () => clearTimeout(timer.current), [])

  return (
    <Popover
      onOpenChange={(open) => {
        if (open) {
          setRows(1)
          setLoading(true)
          clearTimeout(timer.current)
          timer.current = setTimeout(() => setLoading(false), 700)
        }
      }}
    >
      <PopoverTrigger render={<Button variant="outline" />}>
        <IconBell />
        Notifications
      </PopoverTrigger>
      <PopoverContent>
        <PopoverHeader>
          <PopoverTitle>Notifications</PopoverTitle>
          <PopoverDescription>
            The height animates as content loads and grows.
          </PopoverDescription>
        </PopoverHeader>
        {loading ? (
          <Skeleton className="h-10 w-full" />
        ) : (
          <ul className="flex flex-col gap-2">
            {Array.from({ length: rows }, (_, index) => (
              <li
                key={index}
                className="rounded-md bg-muted px-2.5 py-2 text-sm"
              >
                Deploy #{1200 + index} finished in {12 + index}s
              </li>
            ))}
          </ul>
        )}
        <div className="flex gap-2">
          <Button
            variant="outline"
            size="sm"
            disabled={loading}
            onClick={() => setRows(Math.min(rows + 2, 12))}
          >
            Load more
          </Button>
          <Button
            variant="ghost"
            size="sm"
            disabled={loading || rows === 1}
            onClick={() => setRows(1)}
          >
            Collapse
          </Button>
        </div>
      </PopoverContent>
    </Popover>
  )
}
```

### Controlled

Pass `open` and `onOpenChange` to drive it from your own state. The second argument tells you why it changed, such as `trigger-press`, `outside-press` or `escape-key`.

```tsx title="components/examples/popover/controlled.tsx"
"use client"

import * as React from "react"

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

export function PopoverControlled() {
  const [open, setOpen] = React.useState(false)
  const [reason, setReason] = React.useState("none")

  return (
    <div className="flex flex-col items-center gap-3">
      <div className="flex flex-wrap justify-center gap-2">
        <Popover
          open={open}
          onOpenChange={(next, details) => {
            setOpen(next)
            setReason(details.reason)
          }}
        >
          <PopoverTrigger render={<Button variant="outline" />}>
            Controlled
          </PopoverTrigger>
          <PopoverContent>
            <PopoverHeader>
              <PopoverTitle>Controlled</PopoverTitle>
              <PopoverDescription>
                The open state lives in the parent.
              </PopoverDescription>
            </PopoverHeader>
          </PopoverContent>
        </Popover>
        <Button variant="ghost" onClick={() => setOpen(!open)}>
          Toggle from outside
        </Button>
      </div>
      <p className="text-sm text-muted-foreground">
        Open: {String(open)} · Last reason: {reason}
      </p>
    </div>
  )
}
```

### Placement

`side` and `align` set the preferred position. When there is no room, the popup flips to the other side and shifts to stay on screen, keeping 8px from the edges.

```tsx title="components/examples/popover/placement.tsx"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

const sides = ["top", "right", "bottom", "left"] as const
const aligns = ["start", "center", "end"] as const

export function PopoverPlacement() {
  return (
    <div className="flex flex-col items-center gap-3">
      <div className="flex flex-wrap justify-center gap-2">
        {sides.map((side) => (
          <Popover key={side}>
            <PopoverTrigger render={<Button variant="outline" size="sm" />}>
              {side}
            </PopoverTrigger>
            <PopoverContent side={side} className="w-48">
              <PopoverTitle>Side: {side}</PopoverTitle>
            </PopoverContent>
          </Popover>
        ))}
      </div>
      <div className="flex flex-wrap justify-center gap-2">
        {aligns.map((align) => (
          <Popover key={align}>
            <PopoverTrigger render={<Button variant="outline" size="sm" />}>
              Align {align}
            </PopoverTrigger>
            <PopoverContent align={align} className="w-64">
              <PopoverTitle>Align: {align}</PopoverTitle>
            </PopoverContent>
          </Popover>
        ))}
      </div>
    </div>
  )
}
```

### Open on hover

Set `openOnHover` on the trigger for preview cards. `delay` and `closeDelay` keep it from flickering as the pointer passes over.

```tsx title="components/examples/popover/hover.tsx"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverHover() {
  return (
    <Popover>
      <PopoverTrigger
        openOnHover
        delay={200}
        closeDelay={150}
        render={<Button variant="link" />}
      >
        @preetsuthar
      </PopoverTrigger>
      <PopoverContent align="start">
        <div className="flex items-start gap-3">
          <Avatar>
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
          <PopoverHeader>
            <PopoverTitle>Preet Suthar</PopoverTitle>
            <PopoverDescription>
              Building HextaUI. Opens on hover after 200ms and stays open while
              the pointer is inside.
            </PopoverDescription>
          </PopoverHeader>
        </div>
      </PopoverContent>
    </Popover>
  )
}
```

### Detached triggers

Create a handle with `createPopoverHandle` to share one popover between several triggers anywhere in the tree. Each trigger passes a `payload`, and the popup renders it through a function child.

```tsx title="components/examples/popover/detached.tsx"
"use client"

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

const people = createPopoverHandle<{ name: string; role: string }>()

export function PopoverDetached() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <PopoverTrigger
        handle={people}
        payload={{ name: "Ada Lovelace", role: "Analyst" }}
        render={<Button variant="outline" size="sm" />}
      >
        Ada
      </PopoverTrigger>
      <PopoverTrigger
        handle={people}
        payload={{
          name: "Grace Hopper",
          role: "Rear admiral and the person who popularised the term debugging",
        }}
        render={<Button variant="outline" size="sm" />}
      >
        Grace
      </PopoverTrigger>
      <PopoverTrigger
        handle={people}
        payload={{ name: "Alan Turing", role: "Mathematician" }}
        render={<Button variant="outline" size="sm" />}
      >
        Alan
      </PopoverTrigger>
      <Popover handle={people}>
        {({ payload }) => (
          <PopoverContent>
            <PopoverHeader>
              <PopoverTitle>{payload?.name}</PopoverTitle>
              <PopoverDescription>{payload?.role}</PopoverDescription>
            </PopoverHeader>
          </PopoverContent>
        )}
      </Popover>
    </div>
  )
}
```

### With a calendar

Use `className="w-auto p-0"` to fit content that brings its own padding. The popup follows the calendar as it changes months.

```tsx title="components/examples/popover/calendar.tsx"
"use client"

import * as React from "react"
import { IconCalendar } from "@tabler/icons-react"
import { format } from "date-fns"

import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverCalendar() {
  const [date, setDate] = React.useState<Date>()
  const [open, setOpen] = React.useState(false)

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger render={<Button variant="outline" />}>
        <IconCalendar />
        {date ? format(date, "PPP") : "Pick a date"}
      </PopoverTrigger>
      <PopoverContent className="w-auto p-0" align="start">
        <Calendar
          mode="single"
          selected={date}
          onSelect={(next) => {
            setDate(next)
            setOpen(false)
          }}
          defaultMonth={date}
        />
      </PopoverContent>
    </Popover>
  )
}
```

### Nested

A popover inside another popover or a sheet layers above its parent. Clicks inside the child keep the parent open, and Escape closes only the topmost layer.

```tsx title="components/examples/popover/nested.tsx"
import { IconInfoCircle } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Sheet,
  SheetBody,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function PopoverNested() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          Popover in popover
        </PopoverTrigger>
        <PopoverContent>
          <PopoverHeader>
            <PopoverTitle>Parent</PopoverTitle>
            <PopoverDescription>
              Clicking inside the child keeps this one open.
            </PopoverDescription>
          </PopoverHeader>
          <Popover>
            <PopoverTrigger render={<Button variant="outline" size="sm" />}>
              <IconInfoCircle />
              More info
            </PopoverTrigger>
            <PopoverContent side="right" className="w-56">
              <PopoverTitle>Child</PopoverTitle>
              <PopoverClose render={<Button size="sm" variant="ghost" />}>
                Close child
              </PopoverClose>
            </PopoverContent>
          </Popover>
        </PopoverContent>
      </Popover>
      <Sheet>
        <SheetTrigger render={<Button variant="outline" />}>
          Popover in a sheet
        </SheetTrigger>
        <SheetContent>
          <SheetHeader>
            <SheetTitle>Sheet</SheetTitle>
            <SheetDescription>
              The popover layers above the sheet, and Escape closes only the
              popover.
            </SheetDescription>
          </SheetHeader>
          <SheetBody>
            <Popover>
              <PopoverTrigger render={<Button variant="outline" />}>
                Open popover
              </PopoverTrigger>
              <PopoverContent>
                <PopoverTitle>Inside a sheet</PopoverTitle>
              </PopoverContent>
            </Popover>
          </SheetBody>
        </SheetContent>
      </Sheet>
    </div>
  )
}
```

### Long content

Unbroken text wraps inside the popup. When the content is taller than the space available, the popup scrolls inside instead of running off screen.

```tsx title="components/examples/popover/long-content.tsx"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverLongContent() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          Unbroken text
        </PopoverTrigger>
        <PopoverContent>
          <PopoverHeader>
            <PopoverTitle>
              Supercalifragilisticexpialidocious-project-archive-2026-final-v3
            </PopoverTitle>
            <PopoverDescription>
              https://example.com/a/really/long/url/without/any/spaces/at/all/in/it
              — مرحبا بالعالم — 日本語のテキスト 👩‍👩‍👧‍👦
            </PopoverDescription>
          </PopoverHeader>
        </PopoverContent>
      </Popover>
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          Taller than the screen
        </PopoverTrigger>
        <PopoverContent>
          <PopoverTitle>Changelog</PopoverTitle>
          {Array.from({ length: 40 }, (_, index) => (
            <p key={index} className="text-sm text-muted-foreground">
              v1.{40 - index}.0 — fixes and improvements
            </p>
          ))}
        </PopoverContent>
      </Popover>
    </div>
  )
}
```

### Modal

With `modal`, page scroll is locked and outside clicks only dismiss the popover. Render a `<PopoverClose />` inside so focus can be trapped and touch screen readers have a way out.

```tsx title="components/examples/popover/modal.tsx"
import { IconX } from "@tabler/icons-react"

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

export function PopoverModal() {
  return (
    <Popover modal>
      <PopoverTrigger render={<Button variant="outline" />}>
        Modal
      </PopoverTrigger>
      <PopoverContent>
        <div className="flex items-start justify-between gap-2">
          <PopoverHeader>
            <PopoverTitle>Modal popover</PopoverTitle>
            <PopoverDescription>
              Page scroll is locked and outside clicks only dismiss.
            </PopoverDescription>
          </PopoverHeader>
          <PopoverClose
            aria-label="Close"
            render={<Button variant="ghost" size="icon-sm" />}
          >
            <IconX />
          </PopoverClose>
        </div>
      </PopoverContent>
    </Popover>
  )
}
```

### Disabled

A `disabled` trigger never opens its popover.

```tsx title="components/examples/popover/disabled.tsx"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverDisabled() {
  return (
    <Popover>
      <PopoverTrigger disabled render={<Button variant="outline" />}>
        Disabled
      </PopoverTrigger>
      <PopoverContent>
        <PopoverTitle>Never shown</PopoverTitle>
      </PopoverContent>
    </Popover>
  )
}
```

### Right to left

The popup picks up the direction of the trigger that opened it, even though it renders in a portal. Logical sides like `inline-end` flip with it.

```tsx title="components/examples/popover/rtl.tsx"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverRtl() {
  return (
    <div dir="rtl" className="flex flex-wrap justify-center gap-2">
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          الأبعاد
        </PopoverTrigger>
        <PopoverContent align="start">
          <PopoverHeader>
            <PopoverTitle>الأبعاد</PopoverTitle>
            <PopoverDescription>اضبط أبعاد الطبقة.</PopoverDescription>
          </PopoverHeader>
          <div className="flex justify-end">
            <PopoverClose render={<Button size="sm" />}>تطبيق</PopoverClose>
          </div>
        </PopoverContent>
      </Popover>
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          inline-end
        </PopoverTrigger>
        <PopoverContent side="inline-end" className="w-48">
          <PopoverTitle>يفتح نحو النهاية</PopoverTitle>
        </PopoverContent>
      </Popover>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Enter` `Space` | On the trigger, opens or closes the popover. Focus moves into the popup. |
| `Tab` | Moves through the popup’s content. Tabbing out of a non-modal popover closes it. |
| `Esc` | Closes the popover and returns focus to the trigger. |

## Accessibility

- `<PopoverTitle />` and `<PopoverDescription />` label and describe the popup for screen readers. Include a title whenever the popup contains more than a sentence.
- Focus moves to the first focusable element when it opens and back to the trigger when it closes. Change this with `initialFocus` and `finalFocus`.
- With reduced motion enabled, the popup fades without scaling.

## API reference

Built on the Base UI popover. Every part accepts the props of the primitive it wraps.

### Popover

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` | `false` |  |
| `open` | `boolean` | – |  |
| `onOpenChange` | `(open: boolean, details) => void` | – | details.reason says what caused the change. |
| `onOpenChangeComplete` | `(open: boolean) => void` | – | Called after the open or close animation ends. |
| `modal` | `boolean \| "trap-focus"` | `false` | true locks page scroll and outside interaction. trap-focus only traps focus. |
| `handle` | `PopoverHandle<Payload>` | – | Connects detached triggers. |
| `children` | `ReactNode \| ({ payload }) => ReactNode` | – |  |

### PopoverTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `openOnHover` | `boolean` | `false` |  |
| `delay` | `number` | `300` | Milliseconds before opening on hover. |
| `closeDelay` | `number` | `0` | Milliseconds before closing after hover ends. |
| `handle` | `PopoverHandle<Payload>` | – |  |
| `payload` | `Payload` | – | Passed to the popup when this trigger opens it. |
| `disabled` | `boolean` | `false` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="popover-trigger"` | Target the trigger in CSS. |
| `data-popup-open` | Present while its popover is open. |
| `data-pressed` | Present while the trigger is pressed. |
| `data-disabled` | Present when the trigger is disabled. |

### PopoverContent

Renders the portal, the positioner and the popup in one part.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"top" \| "right" \| "bottom" \| "left" \| "inline-start" \| "inline-end"` | `"bottom"` |  |
| `align` | `"start" \| "center" \| "end"` | `"center"` |  |
| `sideOffset` | `number \| (data) => number` | `6` | Gap between the trigger and the popup. |
| `alignOffset` | `number \| (data) => number` | `0` |  |
| `collisionPadding` | `number \| Rect` | `8` | Space kept from the edges of the viewport. |
| `collisionAvoidance` | `CollisionAvoidance` | – | Whether to flip, shift or neither when space runs out. |
| `collisionBoundary` | `Boundary` | – |  |
| `anchor` | `Element \| RefObject \| VirtualElement \| () => Element` | – | Position against something other than the trigger. |
| `sticky` | `boolean` | `false` |  |
| `positionMethod` | `"absolute" \| "fixed"` | `"absolute"` |  |
| `initialFocus` | `boolean \| RefObject \| (type) => HTMLElement \| boolean` | – | Where focus goes when the popover opens. |
| `finalFocus` | `boolean \| RefObject \| (type) => HTMLElement \| boolean` | – | Where focus goes when the popover closes. |
| `portalProps` | `PortalProps` | – | Props for the portal, such as container. |
| `className` | `string \| (state) => string` | – | The popup is w-72 by default. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="popover-content"` | The popup. |
| `data-slot="popover-positioner"` | The element that positions the popup. |
| `data-open` | Present while the popover is open. |
| `data-starting-style` | Present while the popup animates in. |
| `data-ending-style` | Present while the popup animates out. |
| `data-side` | The side the popup ended up on. |
| `data-align` | The alignment the popup ended up with. |
| `data-instant` | Present when the change should not animate. |
| `--transform-origin` | The point the popup scales from, at the trigger. |
| `--available-width` | Space between the trigger and the viewport edge. |
| `--available-height` | Space between the trigger and the viewport edge. The popup’s max height. |
| `--anchor-width` | The trigger’s width. |
| `--anchor-height` | The trigger’s height. |

### PopoverHeader

A plain `<div>` that stacks the title and description.

| Attribute | Description |
| --- | --- |
| `data-slot="popover-header"` | Target the header in CSS. |

### PopoverTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<h2>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="popover-title"` | Labels the popup. |

### PopoverDescription

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<p>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="popover-description"` | Describes the popup. |

### PopoverClose

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="popover-close"` | Closes the popover when pressed. |

### createPopoverHandle

`createPopoverHandle<Payload>()` returns a handle that connects a `<Popover />` to triggers rendered elsewhere. Create it once, outside your component.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
