Popover
A shadcn-style popover component built with Ark UI primitives.
Send us feedback
Let us know how we can improve.
import { Button } from "@/components/ui/button";
import { Field } from "@/components/ui/field";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
const PopoverBasicDemo = () => (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Open popover
</Button>
</PopoverTrigger>
<PopoverPopup className="w-80 max-w-[min(20rem,var(--available-width,20rem))]">
<div className="mb-4 flex flex-col gap-1">
<PopoverTitle className="text-base">Send us feedback</PopoverTitle>
<PopoverDescription>Let us know how we can improve.</PopoverDescription>
</div>
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
}}
>
<Field>
<Textarea
aria-label="Send feedback"
id="popover-feedback"
placeholder="How can we improve?"
/>
</Field>
<Button type="submit">Send feedback</Button>
</form>
</PopoverPopup>
</Popover>
);
export default PopoverBasicDemo;
Installation
npx shadcn@latest add @ark-cn/popoverInstall the dependency required by this primitive:
npm install @ark-ui/reactCopy the component source into your app:
TSXcomponents/ui/popover.tsx
"use client";
import {
Popover as PopoverPrimitive,
usePopover,
usePopoverContext,
} from "@ark-ui/react/popover";
import { Portal } from "@ark-ui/react/portal";
import { cn } from "@/lib/utils";
export type PopoverProps = PopoverPrimitive.RootProps;
export const Popover = ({ positioning, ...props }: PopoverProps) => (
<PopoverPrimitive.Root
data-slot="popover"
positioning={{ sizeMiddleware: false, ...positioning }}
{...props}
/>
);
export type PopoverPopupProps = PopoverPrimitive.ContentProps & {
arrowClassName?: string;
arrowTipClassName?: string;
disablePortal?: boolean;
positionerClassName?: string;
showArrow?: boolean;
};
export const PopoverPopup = ({
arrowClassName,
arrowTipClassName,
children,
className,
disablePortal,
positionerClassName,
showArrow = true,
...contentProps
}: PopoverPopupProps) => {
const inner = (
<PopoverPrimitive.Content
className={cn(
"relative z-[calc(50+var(--layer-index,0))] w-[min(20rem,var(--available-width,20rem))] max-w-[min(20rem,var(--available-width,20rem))] rounded-xl border border-border/80 bg-popover p-4 text-popover-foreground shadow-md outline-none ring-1 ring-border/20",
"origin-(--transform-origin) transition-opacity duration-150 data-[state=closed]:opacity-0 data-[state=open]:opacity-100",
className,
)}
data-slot="popover-content"
{...contentProps}
>
{children}
{showArrow ? (
<PopoverPrimitive.Arrow
className={cn(
"[--arrow-background:var(--popover)] [--arrow-size:10px] [--arrow-shadow-color:var(--border)]",
arrowClassName,
)}
data-slot="popover-arrow"
>
<PopoverPrimitive.ArrowTip
className={cn("border-border border-t border-l", arrowTipClassName)}
data-slot="popover-arrow-tip"
/>
</PopoverPrimitive.Arrow>
) : null}
</PopoverPrimitive.Content>
);
return disablePortal ? (
inner
) : (
<Portal>
<PopoverPrimitive.Positioner
className={cn(!disablePortal && "z-50", positionerClassName)}
data-slot="popover-positioner"
>
{inner}
</PopoverPrimitive.Positioner>
</Portal>
);
};
export type PopoverTriggerProps = PopoverPrimitive.TriggerProps;
export const PopoverTrigger = ({ ...props }: PopoverTriggerProps) => (
<PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
);
export type PopoverAnchorProps = PopoverPrimitive.AnchorProps;
export const PopoverAnchor = ({ className, ...props }: PopoverAnchorProps) => (
<PopoverPrimitive.Anchor
className={cn("inline-flex", className)}
data-slot="popover-anchor"
{...props}
/>
);
export type PopoverTitleProps = PopoverPrimitive.TitleProps;
export const PopoverTitle = ({ className, ...props }: PopoverTitleProps) => (
<PopoverPrimitive.Title
className={cn(
"font-semibold text-foreground text-sm leading-none",
className,
)}
data-slot="popover-title"
{...props}
/>
);
export type PopoverDescriptionProps = PopoverPrimitive.DescriptionProps;
export const PopoverDescription = ({
className,
...props
}: PopoverDescriptionProps) => (
<PopoverPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
data-slot="popover-description"
{...props}
/>
);
export type PopoverCloseTriggerProps = PopoverPrimitive.CloseTriggerProps;
export const PopoverCloseTrigger = ({
className,
...props
}: PopoverCloseTriggerProps) => (
<PopoverPrimitive.CloseTrigger
className={cn(className)}
data-slot="popover-close-trigger"
{...props}
/>
);
export const PopoverClose = PopoverCloseTrigger;
export type PopoverArrowProps = PopoverPrimitive.ArrowProps;
export const PopoverArrow = ({ className, ...props }: PopoverArrowProps) => (
<PopoverPrimitive.Arrow
className={cn(className)}
data-slot="popover-arrow-raw"
{...props}
/>
);
export type PopoverArrowTipProps = PopoverPrimitive.ArrowTipProps;
export const PopoverArrowTip = ({
className,
...props
}: PopoverArrowTipProps) => (
<PopoverPrimitive.ArrowTip
className={cn(className)}
data-slot="popover-arrow-tip-raw"
{...props}
/>
);
export type PopoverPositionerProps = PopoverPrimitive.PositionerProps;
export const PopoverPositioner = ({
className,
...props
}: PopoverPositionerProps) => (
<PopoverPrimitive.Positioner
className={cn(className)}
data-slot="popover-positioner-raw"
{...props}
/>
);
export type PopoverContentProps = PopoverPrimitive.ContentProps;
export const PopoverContent = ({
className,
...props
}: PopoverContentProps) => (
<PopoverPrimitive.Content
className={cn(className)}
data-slot="popover-content-raw"
{...props}
/>
);
export type PopoverIndicatorProps = PopoverPrimitive.IndicatorProps;
export const PopoverIndicator = ({
className,
...props
}: PopoverIndicatorProps) => (
<PopoverPrimitive.Indicator
className={cn(className)}
data-slot="popover-indicator"
{...props}
/>
);
export const PopoverContext = PopoverPrimitive.Context;
export type PopoverRootProviderProps = PopoverPrimitive.RootProviderProps;
export const PopoverRootProvider = (props: PopoverRootProviderProps) => (
<PopoverPrimitive.RootProvider data-slot="popover-root-provider" {...props} />
);
export { usePopover, usePopoverContext };
Update import aliases to match your project setup.
Usage
import * as Popover from "@/components/ui/popover"Read exported parts in src/components/ui/popover.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Close button
Notifications
You are all caught up. Good job!
import { XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverCloseTrigger,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverCloseButtonDemo = () => (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Notifications
</Button>
</PopoverTrigger>
<PopoverPopup className="relative w-80 max-w-[min(20rem,var(--available-width,20rem))]">
<PopoverCloseTrigger
aria-label="Close"
asChild
className="absolute inset-e-2 top-2"
>
<Button size="icon" type="button" variant="ghost">
<XIcon className="size-4" />
</Button>
</PopoverCloseTrigger>
<div className="pe-8">
<PopoverTitle className="text-base">Notifications</PopoverTitle>
<PopoverDescription>
You are all caught up. Good job!
</PopoverDescription>
</div>
<PopoverCloseTrigger asChild className="mt-3">
<Button type="button" variant="outline">
Close
</Button>
</PopoverCloseTrigger>
</PopoverPopup>
</Popover>
);
export default PopoverCloseButtonDemo;
Controlled
Open is synced
false
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverControlledDemo = () => {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col gap-3">
<Button
onClick={() => setOpen((o) => !o)}
size="sm"
type="button"
variant="outline"
>
Toggle from outside
</Button>
<Popover onOpenChange={(d) => setOpen(d.open)} open={open}>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Controlled trigger
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Open is synced</PopoverTitle>
<PopoverDescription>
<span className="font-medium text-foreground">{String(open)}</span>
</PopoverDescription>
</PopoverPopup>
</Popover>
</div>
);
};
export default PopoverControlledDemo;
Placement
Placement
Root
positioning.placement is set to right.import { Button } from "@/components/ui/button";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverPlacementDemo = () => (
<Popover positioning={{ gutter: 12, placement: "right" }}>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Opens to the right
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Placement</PopoverTitle>
<PopoverDescription>
Root <code className="text-foreground">positioning.placement</code> is
set to <code className="text-foreground">right</code>.
</PopoverDescription>
</PopoverPopup>
</Popover>
);
export default PopoverPlacementDemo;
Close behavior
Custom dismiss
Outside click and Escape are disabled - use the button to close.
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverCloseTrigger,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverCloseBehaviorDemo = () => (
<Popover closeOnEscape={false} closeOnInteractOutside={false}>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Stays open
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Custom dismiss</PopoverTitle>
<PopoverDescription>
Outside click and Escape are disabled - use the button to close.
</PopoverDescription>
<PopoverCloseTrigger asChild className="mt-3">
<Button size="sm" type="button" variant="secondary">
Close popover
</Button>
</PopoverCloseTrigger>
</PopoverPopup>
</Popover>
);
export default PopoverCloseBehaviorDemo;
Modal
Modal layer
Focus is trapped, outside scroll is blocked, and background is inert while open.
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverModalDemo = () => (
<Popover modal>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Modal popover
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Modal layer</PopoverTitle>
<PopoverDescription>
Focus is trapped, outside scroll is blocked, and background is inert
while open.
</PopoverDescription>
</PopoverPopup>
</Popover>
);
export default PopoverModalDemo;
Anchor
Anchor
Content is positioned relative to the anchor element, not only the trigger.
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverAnchor,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverAnchorDemo = () => (
<Popover positioning={{ placement: "bottom" }}>
<div className="flex flex-wrap items-center gap-2">
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Open
</Button>
</PopoverTrigger>
<PopoverAnchor>
<Input
aria-label="Anchor reference"
className="max-w-48"
placeholder="Anchor positions to this field"
/>
</PopoverAnchor>
</div>
<PopoverPopup>
<PopoverTitle>Anchor</PopoverTitle>
<PopoverDescription>
Content is positioned relative to the anchor element, not only the
trigger.
</PopoverDescription>
</PopoverPopup>
</Popover>
);
export default PopoverAnchorDemo;
Same width
Same width
positioning.sameWidth on Root.import { Button } from "@/components/ui/button";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverSameWidthDemo = () => (
<Popover positioning={{ sameWidth: true }}>
<PopoverTrigger asChild>
<Button className="w-56 justify-center" type="button" variant="outline">
Same width as trigger
</Button>
</PopoverTrigger>
<PopoverPopup className="w-(--reference-width)! max-w-none! min-w-0 p-3">
<PopoverTitle className="text-xs">Same width</PopoverTitle>
<PopoverDescription className="text-xs">
<code className="text-foreground">positioning.sameWidth</code> on Root.
</PopoverDescription>
</PopoverPopup>
</Popover>
);
export default PopoverSameWidthDemo;
Lazy mount
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverLazyMountDemo = () => (
<Popover lazyMount unmountOnExit>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Lazy mount
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Lazy mount</PopoverTitle>
<PopoverDescription>
Content mounts on first open and unmounts on close when{" "}
<code className="text-foreground">unmountOnExit</code> is set.
</PopoverDescription>
</PopoverPopup>
</Popover>
);
export default PopoverLazyMountDemo;
Nested
Outer
Open the inner popover from here.
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverNestedDemo = () => (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Nested
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Outer</PopoverTitle>
<PopoverDescription>Open the inner popover from here.</PopoverDescription>
<Popover
lazyMount
positioning={{ gutter: 8, placement: "right-start" }}
unmountOnExit
>
<PopoverTrigger asChild className="mt-3">
<Button size="sm" type="button" variant="secondary">
Advanced
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Inner</PopoverTitle>
<PopoverDescription>
Nested popovers keep separate open state and layering.
</PopoverDescription>
</PopoverPopup>
</Popover>
</PopoverPopup>
</Popover>
);
export default PopoverNestedDemo;
In dialog
Edit profile
Dialog stacking with a popover inside (lazy mount + unmount on exit).
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogPanel,
DialogPopup,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverDialogDemo = () => (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline">
Open dialog
</Button>
</DialogTrigger>
<DialogPopup>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Dialog stacking with a popover inside (lazy mount + unmount on exit).
</DialogDescription>
<DialogPanel>
<Popover lazyMount unmountOnExit>
<PopoverTrigger asChild>
<Button size="sm" type="button" variant="secondary">
More options
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Additional settings</PopoverTitle>
<PopoverDescription>
Renders above the dialog layer when portalled.
</PopoverDescription>
</PopoverPopup>
</Popover>
</DialogPanel>
</DialogPopup>
</Dialog>
);
export default PopoverDialogDemo;
Context
Context open:false
Context + indicator
PopoverContext provides state; chevron sits in PopoverIndicator.import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContext,
PopoverDescription,
PopoverIndicator,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverContextDemo = () => (
<Popover>
<PopoverContext>
{(ctx) => (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span>Context open:</span>
<span className="font-medium text-foreground">
{String(ctx.open)}
</span>
</div>
<PopoverTrigger asChild>
<Button className="gap-1" type="button" variant="outline">
Toggle
<PopoverIndicator>
{ctx.open ? (
<ChevronUpIcon aria-hidden className="size-3.5" />
) : (
<ChevronDownIcon aria-hidden className="size-3.5" />
)}
</PopoverIndicator>
</Button>
</PopoverTrigger>
</div>
)}
</PopoverContext>
<PopoverPopup>
<PopoverTitle>Context + indicator</PopoverTitle>
<PopoverDescription>
<code className="text-foreground">PopoverContext</code> provides state;
chevron sits in{" "}
<code className="text-foreground">PopoverIndicator</code>.
</PopoverDescription>
</PopoverPopup>
</Popover>
);
export default PopoverContextDemo;
Indicator only
Min. 8 characters.
import { InfoIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover";
const PopoverIndicatorOnlyDemo = () => (
<Popover positioning={{ placement: "top" }}>
<PopoverTrigger asChild>
<Button
aria-label="Password requirements"
size="icon-xs"
type="button"
variant="ghost"
>
<InfoIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverPopup className="max-w-xs p-3">
<p className="text-foreground text-sm">Min. 8 characters.</p>
</PopoverPopup>
</Popover>
);
export default PopoverIndicatorOnlyDemo;
No arrow
No arrow
Pass
showArrow=false to PopoverPopup.import { Button } from "@/components/ui/button";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverNoArrowDemo = () => (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
No arrow
</Button>
</PopoverTrigger>
<PopoverPopup showArrow={false}>
<PopoverTitle>No arrow</PopoverTitle>
<PopoverDescription>
Pass <code className="text-foreground">showArrow=false</code> to{" "}
<code className="text-foreground">PopoverPopup</code>.
</PopoverDescription>
</PopoverPopup>
</Popover>
);
export default PopoverNoArrowDemo;
Initial focus
Focus target
Second button receives focus when opened.
import { useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverInitialFocusDemo = () => {
const secondRef = useRef<HTMLButtonElement>(null);
return (
<Popover initialFocusEl={() => secondRef.current}>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
Initial focus
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Focus target</PopoverTitle>
<PopoverDescription>
Second button receives focus when opened.
</PopoverDescription>
<div className="mt-3 flex gap-2">
<Button size="sm" type="button" variant="outline">
First
</Button>
<Button ref={secondRef} size="sm" type="button" variant="secondary">
Second (focused)
</Button>
</div>
</PopoverPopup>
</Popover>
);
};
export default PopoverInitialFocusDemo;
With field
Field + popover
Trigger sits beside FieldInput; useful for inline hints.
import { InfoIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Field,
FieldDescription,
FieldInput,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const PopoverWithFieldDemo = () => (
<Field className="max-w-xs">
<FieldLabel htmlFor="popover-field-note">Note</FieldLabel>
<Popover>
<div className="flex gap-2">
<FieldInput asChild className="flex-1">
<Input id="popover-field-note" placeholder="Focus the input..." />
</FieldInput>
<PopoverTrigger asChild>
<Button size="icon" type="button" variant="outline">
<InfoIcon className="size-4" />
</Button>
</PopoverTrigger>
</div>
<PopoverPopup className="w-72 max-w-[min(18rem,var(--available-width,18rem))]">
<PopoverTitle>Field + popover</PopoverTitle>
<PopoverDescription>
Trigger sits beside FieldInput; useful for inline hints.
</PopoverDescription>
</PopoverPopup>
</Popover>
<FieldDescription>Pattern aligned with Field composition.</FieldDescription>
</Field>
);
export default PopoverWithFieldDemo;
usePopoverContext hook
usePopoverContext: false
Inside content
Call
usePopoverContext in descendants of Root.import { Button } from "@/components/ui/button";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
usePopoverContext,
} from "@/components/ui/popover";
const Inner = () => {
const ctx = usePopoverContext();
return (
<p className="text-muted-foreground text-xs">
usePopoverContext:{" "}
<span className="font-medium text-foreground">{String(ctx.open)}</span>
</p>
);
};
const PopoverUseContextHookDemo = () => (
<Popover>
<Inner />
<PopoverTrigger asChild className="mt-2">
<Button type="button" variant="outline">
Open
</Button>
</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Inside content</PopoverTitle>
<PopoverDescription>
Call <code className="text-foreground">usePopoverContext</code> in
descendants of Root.
</PopoverDescription>
</PopoverPopup>
</Popover>
);
export default PopoverUseContextHookDemo;
API reference
This component mirrors the upstream Ark UI primitive. All props and DOM behavior are defined by Ark unless you see an ark-cn-only row below.
PopoverPopup
| Prop | Type | Description |
|---|---|---|
| arrowClassName? | string | Class on the arrow element. |
| arrowTipClassName? | string | Class on the arrow tip. |
| disablePortal? | boolean | Renders content in-place instead of portaled. |
| positionerClassName? | string | Extra class on the positioner wrapper. |
| showArrow? | boolean | Toggles the arrow (default true). |
See the ARK UI documentation for the full API.
Accessibility
See the Ark UI documentation for clarification.