Overlay
Popover
A floating panel that appears when the trigger is clicked, supporting title, description, and custom content with configurable positioning.
Installation
bash
npx polyui add popoverBasic
Compose Popover, PopoverTrigger, and PopoverContent to build a popover. Use PopoverHeader, PopoverTitle, and PopoverDescription for structured content.
tsx
import { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
export function PopoverBasic() {
return (
<div className="flex flex-wrap gap-3">
<Popover>
<PopoverTrigger render={<Button variant="outline">Open Popover</Button>} />
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Dimensions</PopoverTitle>
<PopoverDescription>Set the dimensions for the layer.</PopoverDescription>
</PopoverHeader>
<div className="grid gap-2 text-sm">
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Width</span>
<span className="font-medium">100%</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Height</span>
<span className="font-medium">auto</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Max width</span>
<span className="font-medium">1280px</span>
</div>
</div>
</PopoverContent>
</Popover>
</div>
)
}Positioning
Control the popover position and alignment relative to the trigger via the side and align props.
tsx
import { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
export function PopoverPositioning() {
return (
<div className="flex flex-wrap gap-3">
{(["top", "right", "bottom", "left"] as const).map((side) => (
<Popover key={side}>
<PopoverTrigger render={<Button variant="outline">{side}</Button>} />
<PopoverContent side={side}>
<PopoverHeader>
<PopoverTitle>Side: {side}</PopoverTitle>
</PopoverHeader>
<p className="text-sm text-muted-foreground">This popover appears on the {side} side.</p>
</PopoverContent>
</Popover>
))}
</div>
)
}Alignment
tsx
import { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
export function PopoverAlignment() {
return (
<div className="flex flex-wrap gap-3">
{(["start", "center", "end"] as const).map((align) => (
<Popover key={align}>
<PopoverTrigger render={<Button variant="outline">align: {align}</Button>} />
<PopoverContent align={align}>
<PopoverHeader>
<PopoverTitle>Align: {align}</PopoverTitle>
</PopoverHeader>
<p className="text-sm text-muted-foreground">Aligned to the {align} of the trigger.</p>
</PopoverContent>
</Popover>
))}
</div>
)
}With Form
tsx
import { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { Input } from "@polyui/react/input"
import { Label } from "@polyui/react/label"
export function PopoverWithForm() {
return (
<div className="flex flex-wrap gap-3">
<Popover>
<PopoverTrigger render={<Button variant="outline">Edit Settings</Button>} />
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Edit Settings</PopoverTitle>
<PopoverDescription>Update your display preferences.</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="pop-name">Display name</Label>
<Input id="pop-name" placeholder="Sarah Chen" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="pop-username">Username</Label>
<Input id="pop-username" placeholder="@sarah" />
</div>
<Button size="sm" className="w-full">
Save changes
</Button>
</div>
</PopoverContent>
</Popover>
</div>
)
}Custom Width
tsx
import { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
export function PopoverCustomWidth() {
return (
<div className="flex flex-wrap gap-3">
<Popover>
<PopoverTrigger render={<Button variant="outline">Narrow</Button>} />
<PopoverContent className="w-48">
<PopoverHeader>
<PopoverTitle>Narrow</PopoverTitle>
</PopoverHeader>
<p className="text-sm text-muted-foreground">A narrower popover panel.</p>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger render={<Button variant="outline">Wide</Button>} />
<PopoverContent className="w-96">
<PopoverHeader>
<PopoverTitle>Wide popover</PopoverTitle>
<PopoverDescription>This popover has a wider content area for more content.</PopoverDescription>
</PopoverHeader>
<p className="text-sm text-muted-foreground">
Extra space for richer content, like a mini dashboard or a list of items.
</p>
</PopoverContent>
</Popover>
</div>
)
}Download Progress
tsx
export function PopoverDownloadProgress() {
return <PopoverDownloadInner />
}Delete Confirm
tsx
import { Popover, PopoverTrigger, PopoverContent } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { FileWarningIcon } from "lucide-react"
export function PopoverDeleteConfirm() {
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="icon">
<FileWarningIcon />
<span className="sr-only">Delete File</span>
</Button>
}
/>
<PopoverContent className="w-80">
<div className="flex flex-col items-center gap-4">
<div className="flex aspect-square size-12 items-center justify-center rounded-full bg-red-500/10">
<FileWarningIcon className="text-destructive size-6" />
</div>
<div className="space-y-2 text-center">
<div className="font-semibold text-balance">Are you sure you want to delete this file?</div>
<p className="text-muted-foreground text-sm">
Deleting this file can affect your project and other files connection so keep in mind before making
decision
</p>
</div>
<div className="grid w-full grid-cols-2 gap-2">
<Button variant="secondary" size="sm">
Cancel
</Button>
<Button variant="destructive" size="sm">
Delete File
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}Feedback
tsx
import { Popover, PopoverTrigger, PopoverContent } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { MessageCircleIcon } from "lucide-react"
export function PopoverFeedback() {
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="icon">
<MessageCircleIcon />
<span className="sr-only">Feedback</span>
</Button>
}
/>
<PopoverContent className="w-80">
<div className="grid gap-2">
<div className="font-medium">Feedback</div>
<textarea
placeholder="Type your message here."
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex max-h-56 min-h-[80px] w-full rounded-lg border px-3 py-2 text-sm transition-colors outline-none focus-visible:ring-3"
/>
<div className="grid w-full grid-cols-2 gap-2">
<Button size="sm">Send</Button>
<Button variant="secondary" size="sm">
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}Filter
tsx
import { useState } from "react"
import { Popover, PopoverTrigger, PopoverContent } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { Label } from "@polyui/react/label"
import { Checkbox } from "@polyui/react/checkbox"
import { Slider } from "@polyui/react/slider"
import { FunnelIcon } from "lucide-react"
export function PopoverFilter() {
const [selected, setSelected] = useState(["Most liked", "Newest"])
const [price, setPrice] = useState([450])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="icon">
<FunnelIcon />
<span className="sr-only">Filter</span>
</Button>
}
/>
<PopoverContent>
<div className="grid gap-4">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">Filter</span>
<Button
variant="secondary"
className="h-7 rounded-full px-2 py-1 text-xs"
onClick={() => {
setSelected(["Most liked", "Newest"])
setPrice([450])
}}
>
Reset all
</Button>
</div>
<div className="flex flex-col gap-2">
{filterOptions.map((label, index) => (
<div key={index} className="flex items-center gap-2">
<Checkbox
id={`filter-${index + 1}`}
checked={selected.includes(label)}
onCheckedChange={(checked) =>
setSelected(checked ? [...selected, label] : selected.filter((item) => item !== label))
}
/>
<Label htmlFor={`filter-${index + 1}`}>{label}</Label>
</div>
))}
</div>
<div className="grid gap-3">
<Label>Price range</Label>
<div className="space-y-2">
<Slider
value={price}
onValueChange={(v) => setPrice(Array.isArray(v) ? [...v] : [v as number])}
step={50}
max={1000}
aria-label="Price range"
/>
<span className="text-muted-foreground flex w-full items-center justify-between gap-1 text-xs font-medium">
<span>0</span>
<span>500</span>
<span>1000</span>
</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}Search Users
tsx
import { useEffect, useState } from "react"
import { Popover, PopoverTrigger, PopoverContent } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { Input } from "@polyui/react/input"
import { Avatar, AvatarFallback, AvatarImage } from "@polyui/react/avatar"
import { LoaderCircleIcon, SearchIcon } from "lucide-react"
export function PopoverSearchUsers() {
const [inputValue, setInputValue] = useState("")
const [isLoading, setIsLoading] = useState(false)
const debouncedSearch = useDebounce(inputValue)
const [filteredUsers, setFilteredUsers] = useState(searchUsers)
useEffect(() => {
setIsLoading(!!inputValue)
}, [inputValue])
useEffect(() => {
if (!debouncedSearch.trim()) {
setFilteredUsers(searchUsers)
setIsLoading(false)
return
}
const term = debouncedSearch.toLowerCase()
setFilteredUsers(searchUsers.filter((u) => u.name.toLowerCase().includes(term)))
setIsLoading(false)
}, [debouncedSearch])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="icon">
<SearchIcon />
<span className="sr-only">Search users</span>
</Button>
}
/>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="relative">
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
<SearchIcon className="size-4" />
</div>
<Input
type="search"
placeholder="Search users"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="peer px-9 [&::-webkit-search-cancel-button]:appearance-none"
/>
{isLoading && (
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 right-0 flex items-center justify-center pr-3">
<LoaderCircleIcon className="size-4 animate-spin" />
</div>
)}
</div>
<ul className="space-y-2">
{filteredUsers.length > 0 ? (
filteredUsers.map((user, index) => (
<li key={index} className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarImage src={user.image} alt={user.name} />
<AvatarFallback className="text-xs">{user.fallback}</AvatarFallback>
</Avatar>
<div className="flex-1 text-sm font-medium">{user.name}</div>
{user.notifications && (
<span className="text-muted-foreground text-xs">{`${user.notifications} Notification${user.notifications > 1 ? "s" : ""}`}</span>
)}
</li>
))
) : (
<li className="py-2 text-center text-sm">No users found</li>
)}
</ul>
</div>
</PopoverContent>
</Popover>
)
}Notifications
tsx
import { useState } from "react"
import { Popover, PopoverTrigger, PopoverContent } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { Avatar, AvatarFallback, AvatarImage } from "@polyui/react/avatar"
import { Separator } from "@polyui/react/separator"
import { BellIcon } from "lucide-react"
export function PopoverNotifications() {
const [readMessages, setReadMessages] = useState([3])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="icon">
<BellIcon />
<span className="sr-only">Notifications</span>
</Button>
}
/>
<PopoverContent className="w-80 p-0">
<div className="grid">
<div className="flex items-center justify-between gap-2 px-4 py-2.5">
<span className="font-medium">Notifications</span>
<Button
variant="secondary"
className="h-7 rounded-full px-2 py-1 text-xs"
onClick={() => setReadMessages(notificationItems.map((i) => i.id))}
>
Mark as all read
</Button>
</div>
<Separator />
<ul className="grid gap-4 p-2">
{notificationItems.map((item) => (
<li
key={item.id}
className="hover:bg-accent flex items-start gap-2 rounded-lg px-2 py-1.5 cursor-pointer"
onClick={() => setReadMessages((prev) => [...prev, item.id])}
>
<Avatar className="rounded-lg">
<AvatarImage src={item.image} alt={item.fallback} />
<AvatarFallback className="rounded-lg text-xs">{item.fallback}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="text-sm font-medium">{item.message}</div>
<p className="text-muted-foreground text-xs">{`${item.time} ago`}</p>
</div>
{!readMessages.includes(item.id) && (
<span className="bg-primary block size-2 self-center rounded-full" />
)}
</li>
))}
</ul>
</div>
</PopoverContent>
</Popover>
)
}About Card
tsx
import { Popover, PopoverTrigger, PopoverContent } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { ChevronRightIcon, MapPinIcon } from "lucide-react"
export function PopoverAboutCard() {
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="icon">
<MapPinIcon />
<span className="sr-only">About Himalayas</span>
</Button>
}
/>
<PopoverContent className="w-80 p-0">
<div className="flex">
<div className="space-y-2 p-4">
<p className="font-medium">About Himalayas</p>
<p className="text-muted-foreground text-xs">
The Great Himalayan mountain ranges in the Indian sub-continent region.
</p>
<a
href="https://en.wikipedia.org/wiki/Himalayas"
target="_blank"
rel="noopener noreferrer"
className="flex w-fit text-xs hover:underline"
>
Read more
<ChevronRightIcon className="size-4" />
</a>
</div>
<img
src="https://lp-cms-production.imgix.net/2021-01/GettyRF_450207051.jpg?height=136"
alt="the himalayas"
className="h-34 w-2/5 rounded-r-md object-cover"
/>
</div>
</PopoverContent>
</Popover>
)
}Slide Left
tsx
import { useState } from "react"
import { Popover, PopoverTrigger, PopoverContent } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { Input } from "@polyui/react/input"
import { Separator } from "@polyui/react/separator"
import { cn } from "@polyui/core/lib/utils"
import { CheckIcon, CopyIcon } from "lucide-react"
export function PopoverSlideLeft() {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText("SUMMER25OFF")
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {}
}
return (
<Popover>
<PopoverTrigger render={<Button variant="outline">Slide-in from left</Button>} />
<PopoverContent className="data-open:slide-in-from-left-20 data-closed:slide-out-to-left-20 data-open:slide-in-from-top-0 data-closed:slide-out-to-top-0 data-closed:zoom-out-100 data-open:zoom-in-100 w-80 duration-400">
<div className="flex flex-col items-center gap-4">
<div className="space-y-1 text-center">
<div className="text-lg font-semibold">Summer Sale Discount</div>
<p className="text-sm">Scan this code at checkout for 25% off</p>
</div>
<div className="aspect-square rounded-xl border p-2">
<img
src="https://cdn.shadcnstudio.com/ss-assets/components/popover/qr-code.png?height=152"
alt="Discount QR Code"
className="size-38 rounded-md"
/>
</div>
<div className="flex w-full items-center gap-1.5">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">or use coupon code</span>
<Separator className="flex-1" />
</div>
<div className="flex w-full gap-2">
<Input type="text" defaultValue="SUMMER25OFF" className="disabled:bg-muted" disabled />
<Button variant="outline" size="icon" className="relative" onClick={handleCopy}>
<span className={cn("transition-all", copied ? "scale-100 opacity-100" : "scale-0 opacity-0")}>
<CheckIcon className="stroke-green-600 dark:stroke-green-400" />
</span>
<span
className={cn(
"absolute left-2.25 transition-all",
copied ? "scale-0 opacity-0" : "scale-100 opacity-100"
)}
>
<CopyIcon />
</span>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}Slide Bottom
tsx
import { useId } from "react"
import { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { Input } from "@polyui/react/input"
import { Label } from "@polyui/react/label"
import { Avatar, AvatarFallback, AvatarImage } from "@polyui/react/avatar"
import { Checkbox } from "@polyui/react/checkbox"
export function PopoverSlideBottom() {
const id = useId()
return (
<Popover>
<PopoverTrigger render={<Button variant="outline">Slide-in from bottom</Button>} />
<PopoverContent className="data-open:slide-in-from-bottom-20 data-closed:slide-out-to-bottom-20 data-closed:zoom-out-100 data-open:zoom-in-100 w-80 duration-400">
<div className="grid gap-4">
<PopoverHeader className="space-y-1">
<PopoverTitle>Share to team members</PopoverTitle>
<PopoverDescription>
Give your team members access to this project and start collaborating in real time
</PopoverDescription>
</PopoverHeader>
<div className="w-full space-y-1.5">
<Label htmlFor={id} className="text-muted-foreground text-xs font-normal">
Email address
</Label>
<div className="flex gap-2">
<Input id={id} type="email" placeholder="example@xyz.com" className="h-8" />
<Button type="submit" size="sm">
Share invite
</Button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs font-normal">Team members</Label>
<ul className="grid gap-2">
{slideMembers.map((member, index) => (
<li key={index} className="flex items-center gap-3">
<Checkbox id={`member-${index + 1}`} />
<Label htmlFor={`member-${index + 1}`} className="flex flex-1 items-center gap-2">
<div className="flex flex-1 items-center gap-2">
<Avatar className="size-6">
<AvatarImage src={member.image} alt={member.name} />
<AvatarFallback className="text-xs">{member.fallback}</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{member.name}</span>
</div>
<span className="text-muted-foreground text-xs">{member.designation}</span>
</Label>
</li>
))}
</ul>
</div>
</div>
</PopoverContent>
</Popover>
)
}Zoom In
tsx
import { Popover, PopoverTrigger, PopoverContent } from "@polyui/react/popover"
import { Button } from "@polyui/react/button"
import { Avatar, AvatarFallback, AvatarImage } from "@polyui/react/avatar"
export function PopoverZoomIn() {
return (
<Popover>
<PopoverTrigger render={<Button variant="outline">Zoom in</Button>} />
<PopoverContent className="data-open:!zoom-in-0 data-closed:!zoom-out-0 origin-center duration-400">
<div className="grid gap-4">
<div className="flex flex-col items-center gap-2">
<Avatar className="size-20">
<AvatarImage src="https://cdn.shadcnstudio.com/ss-assets/avatar/avatar-5.png" alt="Howard Lloyd" />
<AvatarFallback className="text-xs">HL</AvatarFallback>
</Avatar>
<div className="flex flex-col items-center text-center">
<p className="text-lg font-semibold">Howard Lloyd</p>
<span className="text-sm">@iamhoward</span>
</div>
</div>
<div className="from-border/20 via-border to-border/20 mx-auto h-px w-45 bg-gradient-to-r" />
<p className="text-center text-sm italic">
Product Manager @oliviasparks, passionate about building user-centric solutions that solve real problems.
</p>
<div className="flex justify-center gap-2 text-sm">
<div className="font-medium">
512 <span className="text-muted-foreground font-normal">followers</span>
</div>
<div className="font-medium">
128 <span className="text-muted-foreground font-normal">following</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
PopoverContent › side | "top" | "right" | "bottom" | "left" | "bottom" | The side the popover appears relative to the trigger. |
PopoverContent › align | "start" | "center" | "end" | "center" | Alignment of the popover along the trigger's axis. |
PopoverContent › sideOffset | number | 4 | Gap between the popover and the trigger in pixels. |
PopoverContent › alignOffset | number | 0 | Offset along the alignment axis in pixels. |