浮层组件
Popover 弹出层
点击触发器后浮现的弹出层,支持标题、描述和自定义内容,可配置弹出方向和对齐方式。
安装
bash
npx polyui add popover基础
使用 Popover、PopoverTrigger 和 PopoverContent 组合构建弹出层,PopoverHeader、PopoverTitle、PopoverDescription 提供结构化内容。
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>
)
}定位
通过 side 和 align prop 控制弹出层相对触发器的位置和对齐方式。
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>
)
}对齐
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>
)
}带表单
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>
)
}自定义宽度
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>
)
}下载进度
tsx
export function PopoverDownloadProgress() {
return <PopoverDownloadInner />
}删除确认
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>
)
}反馈
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>
)
}筛选
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>
)
}搜索用户
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>
)
}通知
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>
)
}关于卡片
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>
)
}向左滑入
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>
)
}底部滑入
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>
)
}缩放动画
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>
)
}属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
PopoverContent › side | "top" | "right" | "bottom" | "left" | "bottom" | 弹出层相对触发器的弹出方向。 |
PopoverContent › align | "start" | "center" | "end" | "center" | 弹出层沿触发器轴线的对齐方式。 |
PopoverContent › sideOffset | number | 4 | 弹出层与触发器之间的间距(px)。 |
PopoverContent › alignOffset | number | 0 | 弹出层沿对齐轴的偏移量(px)。 |