Glrk UI

Popover

A wrapper for Base UI Popover with a convenience API for title, description, hover triggers, arrows, and controlled state.

Trigger behaviour
Click — default, toggles on click
Hover — opens immediately on pointer enter, no click needed
Hover + delay — 800 ms open delay, 500 ms close grace period
Render prop — trigger element and label reflect open state
Controlled
External state — button outside PopoverWrapper drives open/close
Detached triggers
Row "details" links are outside Popover.Root — all open the same anchored panel
Alice Chen
Bob Martinez
Carol Smith
Arrow
showArrow — directional arrow points back at the trigger
Modal
modal — blocks scroll + pointer outside, traps focus, requires Close button
Positioning
side — top / right / bottom / left

Installation

npx shadcn@latest add @glrk-ui/popover

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into your project.

ui/popover.tsx
'use client'

import * as React from 'react'
import { Popover as PopoverPrimitive } from '@base-ui/react/popover'

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

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

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

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

function PopoverArrow({ className, ...props }: PopoverPrimitive.Arrow.Props) {
  return (
    <PopoverPrimitive.Arrow
      data-slot="popover-arrow"
      className={cn(
        'flex data-[side=bottom]:top-[-10px] data-[side=left]:right-[-14px] data-[side=left]:rotate-90 data-[side=right]:left-[-14px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-10px] data-[side=top]:rotate-180',
        className,
      )}
      {...props}
    >
      <svg width="20" height="10" viewBox="0 0 20 10" className="block">
        <path d="M 0 10 L 10 0 L 20 10 Z" className="fill-popover" />
        <path
          d="M 0 10 L 10 0 L 20 10"
          className="fill-none stroke-foreground/10"
          strokeWidth="1"
          strokeLinejoin="round"
        />
      </svg>
    </PopoverPrimitive.Arrow>
  )
}

type popoverContentType = PopoverPrimitive.Popup.Props &
  Pick<
    PopoverPrimitive.Positioner.Props,
    | 'align'
    | 'alignOffset'
    | 'side'
    | 'sideOffset'
    | 'arrowPadding'
    | 'collisionAvoidance'
    | 'collisionBoundary'
    | 'collisionPadding'
  > & {
    showArrow?: boolean
    arrowClassName?: string
  }

function PopoverContent({
  className,
  align = 'center',
  alignOffset = 0,
  side = 'bottom',
  sideOffset = 4,
  arrowPadding,
  collisionAvoidance,
  collisionBoundary,
  collisionPadding,
  showArrow,
  arrowClassName,
  children,
  ...props
}: popoverContentType) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Positioner
        side={side}
        align={align}
        sideOffset={sideOffset}
        alignOffset={alignOffset}
        arrowPadding={arrowPadding}
        collisionPadding={collisionPadding}
        collisionBoundary={collisionBoundary}
        collisionAvoidance={collisionAvoidance}
      >
        <PopoverPrimitive.Popup
          data-slot="popover-content"
          className={cn(
            'flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
            className,
          )}
          {...props}
        >
          {children}
          {showArrow && <PopoverArrow className={arrowClassName} />}
        </PopoverPrimitive.Popup>
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  )
}

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

function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
  return (
    <PopoverPrimitive.Title
      data-slot="popover-title"
      className={cn('font-medium', className)}
      {...props}
    />
  )
}

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

type PopoverWrapperProps = {
  trigger?: React.ReactNode
  content?: React.ReactNode
  title?: React.ReactNode
  description?: React.ReactNode
  triggerCls?: string
  triggerProps?: Omit<PopoverPrimitive.Trigger.Props, 'className' | 'children'>
  contentCls?: string
  contentProps?: Omit<popoverContentType, 'className'>
} & Omit<React.ComponentProps<typeof PopoverPrimitive.Root>, 'children'>

function PopoverWrapper({
  trigger,
  content,
  title,
  description,
  triggerCls,
  triggerProps,
  contentCls,
  contentProps,
  ...props
}: PopoverWrapperProps) {
  return (
    <Popover {...props}>
      <PopoverTrigger className={cn(triggerCls)} {...triggerProps}>
        {trigger}
      </PopoverTrigger>

      <PopoverContent {...contentProps} className={cn(contentCls)}>
        {(title || description) && (
          <PopoverHeader>
            {title && <PopoverTitle>{title}</PopoverTitle>}
            {description && <PopoverDescription>{description}</PopoverDescription>}
          </PopoverHeader>
        )}
        {content}
      </PopoverContent>
    </Popover>
  )
}

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

Usage

Basic

import { PopoverWrapper } from "@/components/ui/popover"

<PopoverWrapper
  trigger="Open"
  triggerCls="px-4 py-1.5 border rounded text-sm"
  title="Popover title"
  description="Supporting description text."
/>

Title and description

Pass title and description directly — renders as PopoverTitle and PopoverDescription inside a PopoverHeader:

<PopoverWrapper
  trigger="Open"
  title="Popover title"
  description="Supporting description text."
/>

For custom or mixed content, use content alongside:

<PopoverWrapper
  trigger="Open"
  title="Popover title"
  description="Some description."
  content={<button className="mt-1 self-end text-xs border rounded px-2 py-1">Action</button>}
/>

Hover trigger

<PopoverWrapper
  trigger="Hover me"
  triggerProps={{ openOnHover: true }}
  title="Popover title"
  description="Opens on pointer enter."
/>

With delay:

<PopoverWrapper
  trigger="Hover (delayed)"
  triggerProps={{ openOnHover: true, delay: 800, closeDelay: 500 }}
  title="Popover title"
  description="800 ms open delay, 500 ms close grace."
/>

Render prop trigger

Use triggerProps.render for state-aware trigger styling — no extra useState needed. trigger can be omitted when render prop is provided:

<PopoverWrapper
  triggerProps={{
    render: (props, state) => (
      <button
        {...props}
        className={state.open ? "bg-foreground text-background rounded px-4 py-1.5 text-sm" : "border rounded px-4 py-1.5 text-sm"}
      >
        {state.open ? "Close ✕" : "Open"}
      </button>
    ),
  }}
  title="Render prop trigger"
  description="Label and style react to open state."
/>

Controlled

const [open, setOpen] = React.useState(false)

<PopoverWrapper
  open={open}
  onOpenChange={setOpen}
  trigger="Anchor"
  title="Controlled"
  description="Driven by external state."
/>

Arrow

<PopoverWrapper
  trigger="Open"
  contentProps={{ showArrow: true, sideOffset: 10 }}
  title="With arrow"
  description="Arrow points toward the trigger."
/>

Control placement with side:

<PopoverWrapper
  trigger="Top"
  contentProps={{ side: "top", showArrow: true, sideOffset: 10 }}
  title="side: top"
  description="Arrow points toward trigger."
/>

When modal, scroll and pointer events outside are blocked and focus is trapped. Add a PopoverClose in content so users can dismiss:

import { PopoverClose, PopoverWrapper } from "@/components/ui/popover"

<PopoverWrapper
  modal
  trigger="Open modal"
  triggerCls="px-4 py-1.5 border rounded text-sm"
  title="Modal popover"
  description="Scroll and pointer events outside are blocked. Focus is trapped inside."
  content={
    <PopoverClose className="mt-1 self-end rounded border px-3 py-1 text-xs">
      Close
    </PopoverClose>
  }
/>

Positioning

<PopoverWrapper trigger="Top"    contentProps={{ side: "top" }}    title="Top" />
<PopoverWrapper trigger="Right"  contentProps={{ side: "right" }}  title="Right" />
<PopoverWrapper trigger="Bottom" contentProps={{ side: "bottom" }} title="Bottom" />
<PopoverWrapper trigger="Left"   contentProps={{ side: "left" }}   title="Left" />

Reference

PopoverWrapper

Prop

Type