Skip to main content
PolyUI/docs

浮层组件

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 › sideOffsetnumber4弹出层与触发器之间的间距(px)。
PopoverContent › alignOffsetnumber0弹出层沿对齐轴的偏移量(px)。