Changelog popover with an unread dot that pings once and clears on open, staggered entry reveals, version and tag chips, and a view-all footer.
npx shadcn@latest add @paragon/whats-newAlso installs: button, popover
"use client";
import * as React from "react";
import { ArrowRight, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/paragon/ui/popover";
export interface WhatsNewItem {
version?: string;
date: string;
title: string;
description?: string;
tag?: "New" | "Improved" | "Fixed";
}
export interface WhatsNewProps {
items?: WhatsNewItem[];
/** Show the unread dot on the trigger until first open. */
unread?: boolean;
/** Runs the first time the panel opens (persist "seen" here). */
onSeen?: () => void;
/** "View all" footer action. */
onViewAll?: () => void;
viewAllLabel?: string;
/** Custom trigger label content; replaces the default. */
children?: React.ReactNode;
/** Per-entry stagger in ms. */
stagger?: number;
side?: React.ComponentProps<typeof PopoverContent>["side"];
align?: React.ComponentProps<typeof PopoverContent>["align"];
}
const whatsNewStyles = `
@keyframes pg-whats-new-item {
from { opacity: 0; translate: 0 8px; filter: blur(4px); }
}
@keyframes pg-whats-new-dot {
from { opacity: 0.6; scale: 1; }
to { opacity: 0; scale: 2.5; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes pg-whats-new-item { from { opacity: 0; } }
@keyframes pg-whats-new-dot { from, to { opacity: 0; scale: 1; } }
}
`;
const tagClass: Record<NonNullable<WhatsNewItem["tag"]>, string> = {
New: "bg-success/10 text-success",
Improved: "bg-blue-600/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400",
Fixed: "bg-secondary text-secondary-foreground",
};
const defaultItems: WhatsNewItem[] = [
{
version: "v2.14",
date: "Jul 2",
title: "Audit log export",
description: "Stream every workspace event to S3 or your SIEM.",
tag: "New",
},
{
version: "v2.13",
date: "Jun 24",
title: "Faster dashboard loads",
description: "Initial paint is now 2.1× quicker on large workspaces.",
tag: "Improved",
},
{
version: "v2.13",
date: "Jun 24",
title: "SAML session drops",
description: "Fixed re-authentication loops on some IdP configurations.",
tag: "Fixed",
},
];
/**
* Changelog popover: a trigger with an unread dot (one-shot ping on mount)
* that clears the moment the panel opens, entries that stagger in with the
* house enter, version/date chips, and a "view all" footer. The stagger is
* decorative — content is present and interactive immediately. Unread state
* can be controlled and persisted via `unread`/`onSeen`.
*/
export function WhatsNew({
items = defaultItems,
unread = true,
onSeen,
onViewAll,
viewAllLabel = "View all updates",
children,
stagger = 50,
side = "bottom",
align = "end",
}: WhatsNewProps) {
const [seen, setSeen] = React.useState(false);
const showDot = unread && !seen;
return (
<Popover
onOpenChange={(open) => {
if (open && !seen) {
setSeen(true);
onSeen?.();
}
}}
>
<style href="paragon-whats-new" precedence="paragon">
{whatsNewStyles}
</style>
<PopoverTrigger asChild>
<Button variant="outline" className="relative">
<Sparkles aria-hidden />
{children ?? "What's new"}
{showDot && (
<span
aria-hidden
className="absolute -top-0.5 -right-0.5 flex size-2"
>
<span
className="absolute inset-0 rounded-full bg-primary"
style={{
animation:
"pg-whats-new-dot 1200ms var(--ease-out) 300ms 1 both",
}}
/>
<span className="relative size-2 rounded-full bg-primary ring-2 ring-background" />
</span>
)}
{showDot && <span className="sr-only">(unread updates)</span>}
</Button>
</PopoverTrigger>
<PopoverContent side={side} align={align} className="w-80 p-0">
<div className="flex items-center justify-between border-b px-4 py-3">
<p className="text-sm font-semibold">What's new</p>
<p className="text-xs text-muted-foreground tabular-nums">
{items.length} update{items.length === 1 ? "" : "s"}
</p>
</div>
<ul className="max-h-80 overflow-y-auto py-1">
{items.map((item, index) => (
<li
key={`${item.title}-${index}`}
style={{
animation: `pg-whats-new-item 250ms var(--ease-out) ${index * stagger}ms both`,
}}
className="px-2"
>
<div className="rounded-lg px-2 py-2.5 transition-colors duration-150 hover:bg-accent/50">
<div className="flex items-center gap-2">
{item.version && (
<span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 font-mono text-[11px] font-medium text-secondary-foreground tabular-nums">
{item.version}
</span>
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{item.title}
</span>
{item.tag && (
<span
className={cn(
"shrink-0 rounded-full px-1.5 py-0.5 text-[11px] font-medium",
tagClass[item.tag],
)}
>
{item.tag}
</span>
)}
</div>
{item.description && (
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{item.description}
</p>
)}
<p className="mt-1 text-[11px] text-muted-foreground/80 tabular-nums">
{item.date}
</p>
</div>
</li>
))}
</ul>
<button
type="button"
onClick={onViewAll}
className="group flex h-10 w-full items-center justify-between border-t px-4 text-[13px] font-medium text-muted-foreground outline-none transition-colors duration-150 hover:bg-accent/50 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
{viewAllLabel}
<ArrowRight
aria-hidden
className="size-3.5 transition-[translate] duration-150 ease-[var(--ease-out)] group-hover:translate-x-0.5 motion-reduce:group-hover:translate-x-0"
/>
</button>
</PopoverContent>
</Popover>
);
}