What's New
Overlays

What's New

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.

Install

npx shadcn@latest add @paragon/whats-new

Also installs: button, popover

whats-new.tsx

"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&apos;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>
  );
}