Popover
An accessible popup anchored to a button. Built with Base UI and Tailwind CSS. Copy-paste ready.
"use client";
import { Button } from "@/components/ui/button";
import { Field } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
export default function Particle() {
return (
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
Open Popover
</PopoverTrigger>
<PopoverPopup className="w-80">
<div className="mb-4">
<PopoverTitle className="text-base">Send us feedback</PopoverTitle>
<PopoverDescription>
Let us know how we can improve.
</PopoverDescription>
</div>
<Form className="flex w-full flex-col gap-4">
<Field>
<Textarea
aria-label="Send feedback"
id="feedback"
placeholder="How can we improve?"
/>
</Field>
<Button type="submit">Send feedback</Button>
</Form>
</PopoverPopup>
</Popover>
);
}
Installation
pnpm dlx shadcn@latest add @cnippet/popover
Usage
import {
Popover,
PopoverClose,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover"<Popover>
<PopoverTrigger>Open Popover</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Popover Title</PopoverTitle>
<PopoverDescription>Popover Description</PopoverDescription>
<PopoverClose>Close</PopoverClose>
</PopoverPopup>
</Popover>Examples
With Close Button
"use client";
import { XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverClose,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
export default function Particle() {
return (
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
Open Popover
</PopoverTrigger>
<PopoverPopup className="w-80">
<PopoverClose
aria-label="Close"
className="absolute inset-e-2 top-2"
render={<Button size="icon" variant="ghost" />}
>
<XIcon />
</PopoverClose>
<div className="mb-2">
<PopoverTitle className="text-base">Notifications</PopoverTitle>
<PopoverDescription>
You are all caught up. Good job!
</PopoverDescription>
</div>
<PopoverClose render={<Button variant="outline" />}>Close</PopoverClose>
</PopoverPopup>
</Popover>
);
}
Tooltip Style
Use the tooltipStyle prop to make a popover look like a tooltip. This is recommended when you have an info icon button whose only purpose is to show additional information. See the tooltip accessibility guidelines for best practices.
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput
aria-label="Choose a username"
placeholder="Choose a username"
type="text"
/>
<InputGroupAddon align="inline-end">
<InputGroupText>@ui.cnippet.dev</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
}
Animated Popovers
You can create animated popovers that smoothly transition between different triggers using detached triggers. This pattern allows multiple triggers to share a single popover popup, with automatic animations for position, size, and content changes.
To create detached triggers:
- Create a handle using
PopoverCreateHandle - Attach the same handle to multiple
PopoverTriggercomponents - Each trigger provides a
payloadprop containing the content component - Use a single
Popovercomponent with the handle to render the popup
"use client";
import { InfoIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Popover,
PopoverPopup,
PopoverTrigger,
} from "@/components/ui/popover";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput
aria-label="Password"
placeholder="Password"
type="password"
/>
<InputGroupAddon align="inline-end">
<Popover>
<PopoverTrigger
openOnHover
render={
<Button
aria-label="Password requirements"
size="icon-xs"
variant="ghost"
/>
}
>
<InfoIcon />
</PopoverTrigger>
<PopoverPopup side="top" tooltipStyle>
<p>Min. 8 characters</p>
</PopoverPopup>
</Popover>
</InputGroupAddon>
</InputGroup>
);
}
Shared Popover with Multiple Triggers
Use a single Popover with a shared handle to let multiple buttons open the same popup with different content. Ideal for toolbar actions like notifications and profile where the popup position and content swap seamlessly.
"use client";
import { BellIcon, UserIcon } from "lucide-react";
import type { ComponentType } from "react";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverCreateHandle,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const popoverHandle = PopoverCreateHandle<ComponentType>();
const NotificationsContent = () => {
return (
<>
<PopoverTitle className="text-base">Notifications</PopoverTitle>
<PopoverDescription>
You have no new notifications at this time.
</PopoverDescription>
</>
);
};
const ProfileContent = () => {
return (
<div className="w-48">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage
alt="Mark Andersson"
src="https://images.unsplash.com/photo-1543610892-0b1f7e6d8ac1?w=128&h=128&dpr=2&q=80"
/>
<AvatarFallback>MA</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h4 className="line-clamp-1 font-medium text-sm">Mark Andersson</h4>
<div className="flex items-center gap-3 text-muted-foreground text-xs">
Product Designer
</div>
</div>
</div>
<Button className="mt-3 w-full" size="sm" variant="outline">
Log out
</Button>
</div>
);
};
export default function Particle() {
return (
<div className="flex gap-2">
<PopoverTrigger
handle={popoverHandle}
payload={NotificationsContent}
render={
<Button aria-label="Notifications" size="icon" variant="outline" />
}
>
<BellIcon aria-hidden="true" />
</PopoverTrigger>
<PopoverTrigger
handle={popoverHandle}
payload={ProfileContent}
render={<Button aria-label="Profile" size="icon" variant="outline" />}
>
<UserIcon aria-hidden="true" />
</PopoverTrigger>
<Popover handle={popoverHandle}>
{({ payload: Payload }) => (
<PopoverPopup className="min-w-none">
{Payload !== undefined && <Payload />}
</PopoverPopup>
)}
</Popover>
</div>
);
}
Placement
Use the side prop on PopoverContent to control which side of the trigger the popover appears on. Combine with align to fine-tune positioning for any layout.
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const sides = [
"inline-start",
"left",
"top",
"bottom",
"right",
"inline-end",
] as const;
export function Pattern() {
return (
<div className="grid grid-cols-3 gap-2">
{sides.map((side) => (
<Popover key={side}>
<PopoverTrigger
render={<Button className="w-full capitalize" variant="outline" />}
>
{side.replace("-", " ")}
</PopoverTrigger>
<PopoverContent className="w-40" side={side}>
<p>Popover on {side.replace("-", " ")}</p>
</PopoverContent>
</Popover>
))}
</div>
);
}
User Profile Card
Trigger a rich profile card from an inline mention or avatar button. A common pattern in collaboration tools — shows the user's name, handle, bio, and follower stats with a follow action.
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function Pattern() {
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger
render={
<Button className="h-auto justify-start py-7" variant="outline">
<Avatar className="size-8">
<AvatarImage
alt="Marcus Chen"
src="https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=96&h=96&dpr=2&q=80"
/>
<AvatarFallback>MC</AvatarFallback>
</Avatar>
<div className="space-y-0.5 text-left">
<p className="font-medium leading-none">Marcus Chen</p>
<p className="text-muted-foreground">@mchen_design</p>
</div>
</Button>
}
/>
<PopoverContent className="w-64">
<div className="flex flex-col gap-2.5">
<div className="flex items-start justify-between">
<Avatar className="size-12">
<AvatarImage
alt="Marcus Chen"
src="https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=112&h=112&dpr=2&q=80"
/>
<AvatarFallback>MC</AvatarFallback>
</Avatar>
<Button size="sm" variant="outline">
Follow
</Button>
</div>
<div className="space-y-1">
<h4 className="font-semibold leading-none">Marcus Chen</h4>
<p className="text-muted-foreground">@mchen_design</p>
</div>
<p className="leading-relaxed">
Product Designer specializing in design systems.
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="font-semibold tabular-nums">1.2k</span>
<span className="text-muted-foreground">Followers</span>
</div>
<div className="flex items-center gap-1">
<span className="font-semibold tabular-nums">482</span>
<span className="text-muted-foreground">Following</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
Timestamp Detail
Display a relative time label (e.g. "2 hours ago") as a trigger and reveal exact UTC and local timestamps in the popover. Useful in audit logs, activity feeds, and deployment dashboards.
Last deployed by CI/CD pipeline.
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function Pattern() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// Static reference time for the "last deployed" example
const referenceTime = new Date(now.getTime() - 1000 * 60 * 120); // 2 hours ago
return (
<div className="flex min-h-25 items-center justify-center">
<p className="text-muted-foreground text-sm">
Last deployed{" "}
<Popover>
<PopoverTrigger
render={
<button className="cursor-default text-foreground underline decoration-1 decoration-dashed underline-offset-4 outline-hidden" />
}
>
{formatDistanceToNow(referenceTime, { addSuffix: true })}
</PopoverTrigger>
<PopoverContent align="start" className="w-auto max-w-86 gap-0 p-0">
<p className="border-b px-2 py-1 font-medium text-foreground">
{formatDistanceToNow(referenceTime, { addSuffix: true })}
</p>
<div className="px-2 py-1.5">
<table>
<tbody>
<tr>
<td className="pr-4 pb-1.5">
<Badge variant="outline">UTC</Badge>
</td>
<td className="pr-6 pb-1.5">
{format(referenceTime, "MMM d, yyyy")}
</td>
<td className="pb-1.5 text-muted-foreground">
{format(referenceTime, "hh:mm:ss a")}
</td>
</tr>
<tr>
<td className="pr-4">
<span className="rounded bg-muted px-1.5 py-0.5 font-medium">
{Intl.DateTimeFormat()
.resolvedOptions()
.timeZone.split("/")
.pop()
?.replace("_", " ") || "Local"}
</span>
</td>
<td className="pr-6">{format(now, "MMM d, yyyy")}</td>
<td className="w-28 text-muted-foreground">
{format(now, "hh:mm:ss a")}
</td>
</tr>
</tbody>
</table>
</div>
</PopoverContent>
</Popover>{" "}
by CI/CD pipeline.
</p>
</div>
);
}
Quick Settings
Surface frequently adjusted preferences — dark mode, notifications, and volume — directly in a compact popover. Reduces trips to a full settings page for common toggles.
"use client";
import { SettingsIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
export function Pattern() {
const [volume, setVolume] = useState([75]);
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger render={<Button size="icon" variant="outline" />}>
<SettingsIcon aria-hidden="true" />
</PopoverTrigger>
<PopoverContent align="end" className="w-72 gap-0 p-0">
<div className="border-b p-3">
<h4 className="m-0 font-medium">Quick Settings</h4>
<p className="text-muted-foreground">Adjust your preferences.</p>
</div>
<div className="space-y-3 p-3 pb-4">
<div className="flex items-center justify-between">
<label htmlFor="qs-dark">Dark Mode</label>
<Switch id="qs-dark" />
</div>
<div className="flex items-center justify-between">
<label htmlFor="qs-notif">Notifications</label>
<Switch defaultChecked id="qs-notif" />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label>Volume</label>
<span className="text-muted-foreground">{volume[0]}%</span>
</div>
<Slider
max={100}
onValueChange={(v) => setVolume(v as number[])}
step={1}
value={volume}
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
Feature Tour
Walk users through onboarding steps with a multi-step popover. Step counter and prev/next navigation let users move at their own pace without leaving the current page.
"use client";
import { ArrowLeftIcon, ArrowRightIcon, CompassIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const steps = [
{
description:
"Add team members by email to collaborate on projects in real time. Assign roles and manage permissions from the team settings.",
title: "Invite Your Team",
},
{
description:
"Set up your first project with a name, description, and timeline. Choose from templates or start from scratch.",
title: "Create a Project",
},
{
description:
"Link tools like GitHub, Slack, and Figma to streamline your workflow and keep everything in sync.",
title: "Connect Integrations",
},
{
description:
"Customize which events trigger alerts — mentions, due dates, status changes, and deployment updates.",
title: "Set Up Notifications",
},
];
export function Pattern() {
const [currentStep, setCurrentStep] = useState(0);
const handleNext = () => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
}
};
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const isFirst = currentStep === 0;
const isLast = currentStep === steps.length - 1;
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<CompassIcon aria-hidden="true" />
Feature Tour
</PopoverTrigger>
<PopoverContent
className="w-72 gap-2 px-3 pt-3 pb-2"
side="top"
sideOffset={8}
>
<div className="space-y-2">
<p className="font-medium leading-tight">
{steps[currentStep]?.title}
</p>
<p className="text-muted-foreground">
{steps[currentStep]?.description}
</p>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{currentStep + 1} of {steps.length}
</span>
<div className="flex gap-0.5">
<Button
aria-label="Previous step"
className="size-6"
disabled={isFirst}
onClick={handlePrev}
size="icon"
variant="ghost"
>
<ArrowLeftIcon aria-hidden="true" className="size-3.5" />
</Button>
<Button
aria-label="Next step"
className="size-6"
disabled={isLast}
onClick={handleNext}
size="icon"
variant="ghost"
>
<ArrowRightIcon aria-hidden="true" className="size-3.5" />
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
Share Link
A share popover with a one-click copy link row and contextual share-via options. The copy button transitions to a check icon on success, providing clear feedback without a toast.
"use client";
import {
CheckIcon,
CopyIcon,
GlobeIcon,
LinkIcon,
MailIcon,
MessageSquareIcon,
Share2Icon,
} from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const PAGE_URL = "https://cnippet.ui/components/popover";
const shareChannels = [
{ badge: undefined, icon: MailIcon, label: "Email" },
{ badge: undefined, icon: MessageSquareIcon, label: "Direct Message" },
{ badge: "Pro", icon: GlobeIcon, label: "Publish to Web" },
] as const;
export function Pattern() {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(PAGE_URL).catch(() => null);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<Share2Icon aria-hidden="true" />
Share
</PopoverTrigger>
<PopoverContent align="center" className="w-72 gap-0 p-0" side="bottom">
<div className="border-b px-0 py-3">
<h4 className="font-semibold">Share this page</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
Anyone with the link can view.
</p>
</div>
<div className="space-y-3 px-0 py-3">
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2">
<LinkIcon
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate text-muted-foreground text-sm">
{PAGE_URL}
</span>
<Button
aria-label={copied ? "Copied!" : "Copy link"}
className="size-6 shrink-0"
onClick={handleCopy}
size="icon"
variant="ghost"
>
{copied ? (
<CheckIcon
aria-hidden="true"
className="size-3.5 text-emerald-500"
/>
) : (
<CopyIcon aria-hidden="true" className="size-3.5" />
)}
</Button>
</div>
<div>
<p className="mb-2 font-medium text-muted-foreground text-xs uppercase tracking-wide">
Share via
</p>
<div className="space-y-0.5">
{shareChannels.map(({ label, icon: Icon, badge }) => (
<button
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-muted"
key={label}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded border bg-background">
<Icon aria-hidden="true" className="size-3.5" />
</span>
<span className="flex-1 text-left">{label}</span>
{badge && (
<Badge className="h-4 px-1 text-[10px]" variant="outline">
{badge}
</Badge>
)}
</button>
))}
</div>
</div>
</div>
<div className="border-t px-0 py-2.5">
<p className="text-muted-foreground text-xs">
Link expires in{" "}
<span className="font-medium text-foreground">7 days</span>.
</p>
</div>
</PopoverContent>
</Popover>
</div>
);
}
Accent Color Picker
Let users choose a theme accent from a grid of color swatches. The selected color is highlighted with a checkmark and reflected in the trigger button for instant visual feedback.
"use client";
import { CheckIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const colors = [
{ bg: "bg-slate-500", name: "Slate" },
{ bg: "bg-rose-500", name: "Rose" },
{ bg: "bg-orange-500", name: "Orange" },
{ bg: "bg-amber-500", name: "Amber" },
{ bg: "bg-emerald-500", name: "Emerald" },
{ bg: "bg-cyan-500", name: "Cyan" },
{ bg: "bg-blue-500", name: "Blue" },
{ bg: "bg-violet-500", name: "Violet" },
{ bg: "bg-fuchsia-500", name: "Fuchsia" },
{ bg: "bg-pink-500", name: "Pink" },
] as const;
export function Pattern() {
const [selected, setSelected] = useState<string>("Blue");
const active =
colors.find((c) => c.name === selected) ??
({ bg: "bg-blue-500", name: "Blue" } as const);
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<span className={`size-3.5 rounded-full ${active.bg}`} />
Accent Color
</PopoverTrigger>
<PopoverContent align="start" className="w-52 gap-0 p-0">
<div className="border-b px-0 py-2.5">
<h4 className="font-semibold text-sm">Accent Color</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
Customize your theme accent.
</p>
</div>
<div className="grid grid-cols-5 gap-2 p-3">
{colors.map((color) => (
<button
aria-label={color.name}
className={`relative flex size-8 items-center justify-center rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${color.bg}`}
key={color.name}
onClick={() => setSelected(color.name)}
title={color.name}
>
{selected === color.name && (
<CheckIcon
aria-hidden="true"
className="size-4 text-white drop-shadow"
/>
)}
</button>
))}
</div>
<div className="flex items-center gap-2 border-t px-0 py-2">
<span className={`size-2.5 rounded-full ${active.bg}`} />
<p className="text-muted-foreground text-xs">
Selected:{" "}
<span className="font-medium text-foreground">{selected}</span>
</p>
</div>
</PopoverContent>
</Popover>
</div>
);
}
Keyboard Shortcuts
Surface a grouped shortcut reference inline without navigating away. Pairs well with a ? or keyboard icon trigger and keeps the reference close to where users need it.
import { KeyboardIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const groups = [
{
label: "Navigation",
shortcuts: [
{ description: "Go to home", keys: ["G", "H"] },
{ description: "Go to projects", keys: ["G", "P"] },
{ description: "Go to settings", keys: ["G", "S"] },
],
},
{
label: "Actions",
shortcuts: [
{ description: "Open command menu", keys: ["⌘", "K"] },
{ description: "Create new item", keys: ["⌘", "N"] },
{ description: "Toggle shortcuts", keys: ["⌘", "/"] },
],
},
] as const;
export function Pattern() {
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<KeyboardIcon aria-hidden="true" />
Shortcuts
</PopoverTrigger>
<PopoverContent align="center" className="w-64 gap-0 p-0">
<div className="border-b px-0 py-3">
<h4 className="font-semibold text-sm">Keyboard Shortcuts</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
Speed up your workflow.
</p>
</div>
<div className="divide-y">
{groups.map((group) => (
<div className="space-y-2 px-0 py-3" key={group.label}>
<p className="font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
{group.label}
</p>
<div className="space-y-2">
{group.shortcuts.map(({ keys, description }) => (
<div
className="flex items-center justify-between gap-4"
key={description}
>
<span className="text-muted-foreground text-sm">
{description}
</span>
<div className="flex shrink-0 items-center gap-1">
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="border-t px-0 py-2.5">
<p className="flex items-center gap-1 text-muted-foreground text-xs">
Press <Kbd>⌘</Kbd> <Kbd>/</Kbd> to open anytime.
</p>
</div>
</PopoverContent>
</Popover>
</div>
);
}
Filter Panel
A structured filter popover with grouped checkbox options and a live badge count on the trigger. Clear all and Apply actions give users explicit control over when filters take effect.
"use client";
import { SlidersHorizontalIcon } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const statusOptions = ["Active", "Draft", "Archived", "Pending"] as const;
const typeOptions = ["Article", "Video", "Podcast", "Newsletter"] as const;
function toggle(list: string[], value: string): string[] {
return list.includes(value)
? list.filter((v) => v !== value)
: [...list, value];
}
export function Pattern() {
const [statuses, setStatuses] = useState<string[]>(["Active"]);
const [types, setTypes] = useState<string[]>([]);
const total = statuses.length + types.length;
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<SlidersHorizontalIcon aria-hidden="true" />
Filter
{total > 0 && (
<Badge className="ml-0.5 h-4 px-1 text-[10px]">{total}</Badge>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-52 gap-0 p-0">
<div className="border-b px-0 py-2.5">
<h4 className="font-semibold text-sm">Filter</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
Narrow down results.
</p>
</div>
<div className="divide-y">
<div className="space-y-2 px-0 py-3">
<p className="font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
Status
</p>
<div className="space-y-2">
{statusOptions.map((s) => (
<label
className="flex cursor-pointer items-center gap-2.5"
key={s}
>
<Checkbox
checked={statuses.includes(s)}
onCheckedChange={() => setStatuses(toggle(statuses, s))}
/>
<span className="text-sm">{s}</span>
</label>
))}
</div>
</div>
<div className="space-y-2 px-0 py-3">
<p className="font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
Type
</p>
<div className="space-y-2">
{typeOptions.map((t) => (
<label
className="flex cursor-pointer items-center gap-2.5"
key={t}
>
<Checkbox
checked={types.includes(t)}
onCheckedChange={() => setTypes(toggle(types, t))}
/>
<span className="text-sm">{t}</span>
</label>
))}
</div>
</div>
</div>
<div className="flex gap-2 border-t px-0 py-2.5">
<Button
className="flex-1"
onClick={() => {
setStatuses([]);
setTypes([]);
}}
size="sm"
variant="ghost"
>
Clear all
</Button>
<Button className="flex-1" size="sm">
Apply
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}
Set a Reminder
Quick-pick reminder intervals presented as a scannable list with a secondary option to choose a custom date and time. The selected slot is visually confirmed with a dot indicator.
"use client";
import { CalendarIcon, ClockIcon } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const reminders = [
{ description: "Quick follow-up", label: "In 30 minutes" },
{ description: "Later today", label: "In 1 hour" },
{ description: "9:00 AM", label: "Tomorrow morning" },
{ description: "Monday, 9:00 AM", label: "Next week" },
] as const;
export function Pattern() {
const [selected, setSelected] = useState<string | null>(null);
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<ClockIcon aria-hidden="true" />
Remind me
{selected && (
<Badge className="ml-0.5 h-4 px-1 text-[10px]" variant="secondary">
1
</Badge>
)}
</PopoverTrigger>
<PopoverContent align="center" className="w-64 gap-0 p-0" side="top">
<div className="border-b px-0 py-3">
<h4 className="font-semibold text-sm">Set a Reminder</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
We'll notify you at the right time.
</p>
</div>
<div className="space-y-1 py-2">
{reminders.map(({ label, description }) => (
<button
className={`flex w-full items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors hover:bg-muted ${
selected === label ? "bg-muted ring-1 ring-ring/40" : ""
}`}
key={label}
onClick={() => setSelected(label)}
>
<ClockIcon
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
<div className="flex-1 text-left">
<p className="font-medium leading-none">{label}</p>
<p className="mt-0.5 text-muted-foreground text-xs">
{description}
</p>
</div>
{selected === label && (
<span className="size-1.5 shrink-0 rounded-full bg-primary" />
)}
</button>
))}
</div>
<div className="border-t px-0 py-2.5">
<button className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
<CalendarIcon aria-hidden="true" className="size-3.5 shrink-0" />
Pick a custom date & time
</button>
</div>
</PopoverContent>
</Popover>
</div>
);
}
System Status
Display real-time service health for each backend service with latency figures and status labels. The trigger dot pulses amber when any service is degraded, giving users an at-a-glance indicator.
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
type Status = "degraded" | "operational" | "outage";
const services: { latency: string; name: string; status: Status }[] = [
{ latency: "42ms", name: "API Gateway", status: "operational" },
{ latency: "8ms", name: "Database", status: "operational" },
{ latency: "12ms", name: "CDN", status: "operational" },
{ latency: "340ms", name: "Auth Service", status: "degraded" },
{ latency: "91ms", name: "Storage", status: "operational" },
];
const statusConfig: Record<Status, { dot: string; label: string }> = {
degraded: { dot: "bg-amber-500", label: "Degraded" },
operational: { dot: "bg-emerald-500", label: "Operational" },
outage: { dot: "bg-red-500", label: "Outage" },
};
const allOperational = services.every((s) => s.status === "operational");
export function Pattern() {
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<span
className={`size-2 animate-pulse rounded-full ${allOperational ? "bg-emerald-500" : "bg-amber-500"}`}
/>
System Status
</PopoverTrigger>
<PopoverContent align="center" className="w-72 gap-0 p-0">
<div className="border-b px-0 py-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-sm">System Status</h4>
<Badge
className="text-xs"
variant={allOperational ? "default" : "outline"}
>
{allOperational ? "All Operational" : "Issues Detected"}
</Badge>
</div>
<p className="mt-0.5 text-muted-foreground text-xs">
Real-time service health.
</p>
</div>
<div className="divide-y">
{services.map(({ name, status, latency }) => {
const cfg = statusConfig[status];
return (
<div className="flex items-center gap-3 px-2 py-2.5" key={name}>
<span className={`size-2 shrink-0 rounded-full ${cfg.dot}`} />
<span className="flex-1 text-sm">{name}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{latency}
</span>
<span className="w-20 text-right text-muted-foreground text-xs">
{cfg.label}
</span>
</div>
);
})}
</div>
<div className="border-t px-0 py-2.5">
<p className="text-muted-foreground text-xs">
Last checked{" "}
<span className="font-medium text-foreground">just now</span>.
</p>
</div>
</PopoverContent>
</Popover>
</div>
);
}
Emoji Picker
A 5-column emoji grid in a compact popover — clicking an emoji selects it and renders a reaction badge below the trigger.
"use client";
import { SmileIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const EMOJIS = [
"👍",
"❤️",
"😂",
"🎉",
"🔥",
"👏",
"🙌",
"💡",
"✅",
"⭐",
"🚀",
"💯",
"🤔",
"👀",
"💪",
"🌟",
"😊",
"🎯",
"💎",
"🦄",
];
export function Pattern() {
const [selected, setSelected] = useState<string | null>(null);
return (
<div className="flex items-center gap-3">
<Popover>
<PopoverTrigger
aria-label="Add reaction"
render={<Button size="icon" variant="outline" />}
>
<SmileIcon aria-hidden="true" className="size-4" />
</PopoverTrigger>
<PopoverContent className="w-auto p-2">
<div className="grid grid-cols-5 gap-0.5">
{EMOJIS.map((emoji) => (
<button
aria-label={`React with ${emoji}`}
className="flex size-8 items-center justify-center rounded text-lg transition-colors hover:bg-muted"
key={emoji}
onClick={() => setSelected(emoji)}
type="button"
>
{emoji}
</button>
))}
</div>
</PopoverContent>
</Popover>
{selected && (
<div className="flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-sm">
<span>{selected}</span>
<span className="text-muted-foreground text-xs">1</span>
</div>
)}
</div>
);
}
Confirmation
Replaces a full Dialog for low-stakes destructive actions: a "Delete item?" prompt with Cancel and Delete buttons inside the popover itself.
"use client";
import { TrashIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function Pattern() {
const [deleted, setDeleted] = useState(false);
if (deleted) {
return <p className="text-muted-foreground text-sm">Item deleted.</p>;
}
return (
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<TrashIcon aria-hidden="true" />
Delete item
</PopoverTrigger>
<PopoverContent className="w-64 gap-3" side="bottom">
<div>
<p className="font-semibold text-sm">Delete this item?</p>
<p className="mt-0.5 text-muted-foreground text-xs">
This action cannot be undone.
</p>
</div>
<div className="flex gap-2">
<PopoverClose
render={<Button className="flex-1" size="sm" variant="outline" />}
>
Cancel
</PopoverClose>
<Button
className="flex-1"
onClick={() => setDeleted(true)}
size="sm"
variant="destructive"
>
Delete
</Button>
</div>
</PopoverContent>
</Popover>
);
}
Tag Editor
An inline tag editor with Badge chips, a × remove button per tag, a text input for new entries, and suggested tags — all inside a popover anchored to a + icon button.
"use client";
import { PlusIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const SUGGESTIONS = ["design", "engineering", "product", "research", "growth"];
export function Pattern() {
const [tags, setTags] = useState(["react", "typescript"]);
const [input, setInput] = useState("");
function addTag(tag: string) {
const trimmed = tag.trim().toLowerCase();
if (trimmed && !tags.includes(trimmed)) setTags((p) => [...p, trimmed]);
setInput("");
}
return (
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => (
<Badge className="gap-1 pr-1" key={tag} variant="secondary">
{tag}
<button
aria-label={`Remove ${tag}`}
className="rounded-full hover:bg-foreground/10"
onClick={() => setTags((p) => p.filter((t) => t !== tag))}
type="button"
>
<XIcon className="size-3" />
</button>
</Badge>
))}
<Popover>
<PopoverTrigger
aria-label="Add tag"
render={<Button className="size-6" size="icon" variant="ghost" />}
>
<PlusIcon className="size-3.5" />
</PopoverTrigger>
<PopoverContent className="w-52 gap-2" side="bottom">
<Input
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag(input);
}
}}
placeholder="Add tag…"
value={input}
/>
<div className="space-y-0.5">
{SUGGESTIONS.filter((s) => !tags.includes(s))
.slice(0, 3)
.map((s) => (
<button
className="flex w-full items-center rounded px-2 py-1 text-sm hover:bg-muted"
key={s}
onClick={() => addTag(s)}
type="button"
>
{s}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
}
Notification Panel
A bell icon trigger with an unread count badge opens a list of notifications. Clicking an item marks it read; "Mark all read" clears the badge count at once.
"use client";
import { BellIcon } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const initialNotifications = [
{
id: 1,
read: false,
text: "Jamie left a comment on your post.",
time: "2m ago",
},
{
id: 2,
read: false,
text: "Your export is ready to download.",
time: "15m ago",
},
{
id: 3,
read: true,
text: "Alex invited you to join the workspace.",
time: "1h ago",
},
];
export function Pattern() {
const [items, setItems] = useState(initialNotifications);
const unread = items.filter((n) => !n.read).length;
return (
<div className="flex min-h-25 items-center justify-center">
<Popover>
<PopoverTrigger
aria-label="Notifications"
render={<Button size="icon" variant="outline" />}
>
<span className="relative">
<BellIcon aria-hidden="true" className="size-4" />
{unread > 0 && (
<span className="absolute -top-1 -right-1 flex size-3.5 items-center justify-center rounded-full bg-destructive font-bold text-[9px] text-destructive-foreground">
{unread}
</span>
)}
</span>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 gap-0 p-0">
<div className="flex items-center justify-between border-b px-3 py-2.5">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-sm">Notifications</h4>
{unread > 0 && <Badge size="sm">{unread}</Badge>}
</div>
<button
className="text-muted-foreground text-xs hover:text-foreground"
onClick={() =>
setItems((p) => p.map((n) => ({ ...n, read: true })))
}
type="button"
>
Mark all read
</button>
</div>
<div className="divide-y">
{items.map((n) => (
<button
className={`flex w-full gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted ${n.read ? "opacity-60" : ""}`}
key={n.id}
onClick={() =>
setItems((p) =>
p.map((item) =>
item.id === n.id ? { ...item, read: true } : item,
),
)
}
type="button"
>
<span
className={`mt-1.5 size-1.5 shrink-0 rounded-full ${n.read ? "bg-transparent" : "bg-primary"}`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm">{n.text}</p>
<p className="text-muted-foreground text-xs">{n.time}</p>
</div>
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
}
Inline Text Edit
A pencil icon button anchored next to a display value opens a popover with an Input pre-filled with the current value and Save / Cancel actions — a common inline edit pattern.
Team: Acme Corporation
"use client";
import { PencilIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function Pattern() {
const [name, setName] = useState("Acme Corporation");
const [draft, setDraft] = useState(name);
return (
<div className="flex items-center gap-2">
<p className="text-sm">
Team: <span className="font-medium">{name}</span>
</p>
<Popover
onOpenChange={(open) => {
if (open) setDraft(name);
}}
>
<PopoverTrigger
aria-label="Edit team name"
render={<Button className="size-6" size="icon" variant="ghost" />}
>
<PencilIcon className="size-3.5" />
</PopoverTrigger>
<PopoverContent className="w-64 gap-3">
<p className="font-medium text-sm">Edit team name</p>
<Input onChange={(e) => setDraft(e.target.value)} value={draft} />
<div className="flex gap-2">
<PopoverClose
render={<Button className="flex-1" size="sm" variant="outline" />}
>
Cancel
</PopoverClose>
<PopoverClose
render={
<Button
className="flex-1"
onClick={() => setName(draft)}
size="sm"
/>
}
>
Save
</PopoverClose>
</div>
</PopoverContent>
</Popover>
</div>
);
}
On This Page

