# Popover

> Click-triggered popover with side and alignment options; closes on outside click or Esc and restores focus.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/popover
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/popover.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/popover-demo.tsx"
"use client"

import { SlidersHorizontal } from "lucide-react"

import { Popover } from "@/components/popover"

const field = "h-8 w-24 rounded-md border bg-background px-2 text-sm outline-none focus:border-ring focus:ring-3 focus:ring-ring/20"

export default function PopoverDemo() {
  return (
    <div className="flex min-h-72 items-start justify-center pt-12">
      <Popover
        panelLabel="Dimensions"
        width={300}
        trigger={
          <>
            <SlidersHorizontal className="size-4" /> Dimensions
          </>
        }
      >
        <div className="grid gap-3">
          <div>
            <p className="text-sm font-medium">Dimensions</p>
            <p className="text-xs text-muted-foreground">Set the size of the layer.</p>
          </div>
          {[
            ["Width", "100%"],
            ["Max width", "300px"],
            ["Height", "25px"],
          ].map(([label, value]) => (
            <label key={label} className="flex items-center justify-between gap-3 text-sm">
              {label}
              <input defaultValue={value} className={field} />
            </label>
          ))}
        </div>
      </Popover>
    </div>
  )
}
```

## Source

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

import * as React from "react"

import { cn } from "@/lib/utils"

export interface PopoverProps extends Omit<React.ComponentProps<"div">, "children"> {
  trigger: React.ReactNode
  /** Panel content */
  children: React.ReactNode
  side?: "top" | "bottom"
  align?: "start" | "center" | "end"
  /** Panel width in px */
  width?: number
  /** Accessible name of the panel */
  panelLabel?: string
  triggerClassName?: string
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
}

const sides = { top: "bottom-full mb-2 origin-bottom", bottom: "top-full mt-2 origin-top" } as const
const aligns = { start: "left-0", center: "left-1/2 -translate-x-1/2", end: "right-0" } as const

export function Popover({
  trigger,
  children,
  side = "bottom",
  align = "center",
  width = 288,
  panelLabel,
  triggerClassName,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  className,
  ...props
}: PopoverProps) {
  const [inner, setInner] = React.useState(defaultOpen)
  const open = openProp ?? inner
  const root = React.useRef<HTMLDivElement>(null)
  const button = React.useRef<HTMLButtonElement>(null)
  const id = React.useId()

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setInner(next)
      onOpenChange?.(next)
    },
    [openProp, onOpenChange]
  )

  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return
      setOpen(false)
      button.current?.focus()
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onKeyDown)
    }
  }, [open, setOpen])

  return (
    <div ref={root} className={cn("relative inline-block", className)} {...props}>
      <button
        ref={button}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls={open ? id : undefined}
        onClick={() => setOpen(!open)}
        className={cn(
          "inline-flex h-9 items-center gap-2 rounded-lg border bg-background px-3 text-sm font-medium shadow-xs transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-expanded:bg-accent",
          triggerClassName
        )}
      >
        {trigger}
      </button>
      {open && (
        <div
          id={id}
          role="dialog"
          aria-label={panelLabel}
          style={{ width }}
          className={cn(
            "absolute z-20 animate-in rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg duration-150 fade-in zoom-in-95",
            sides[side],
            aligns[align]
          )}
        >
          {children}
        </div>
      )}
    </div>
  )
}
```
