Sections

Changelog Section

A product changelog with a version rail, date chips, tag badges, grid-rows expandable entries, and a subscribe input with confirmation swap.

Install

npx shadcn@latest add @paragon/changelog-section

Also installs: button

changelog-section.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

/* ------------------------------------------------------------------ */

type Tag = "New" | "Improved" | "Fixed";

const TAG_STYLES: Record<Tag, string> = {
  New: "bg-emerald-600/10 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-400",
  Improved: "bg-sky-600/10 text-sky-700 dark:bg-sky-400/10 dark:text-sky-400",
  Fixed:
    "bg-amber-600/10 text-amber-700 dark:bg-amber-400/10 dark:text-amber-400",
};

interface Entry {
  version: string;
  date: string;
  title: string;
  tags: Tag[];
  summary: string;
  details: string[];
}

const ENTRIES: Entry[] = [
  {
    version: "v2.16",
    date: "Jun 30, 2026",
    title: "Preview environments are generally available",
    tags: ["New", "Improved"],
    summary:
      "Every pull request now gets an isolated, shareable environment with production-parity data seeding.",
    details: [
      "Preview environments now provision in a median of 41 seconds, down from 3 minutes.",
      "Seeded databases are scrubbed of PII automatically before copying.",
      "New environment lifecycle policies: auto-sleep after 48h idle, auto-delete on merge.",
      "The environments list gained per-branch filtering and a status column.",
    ],
  },
  {
    version: "v2.15",
    date: "Jun 16, 2026",
    title: "Faster builds and smarter caching",
    tags: ["Improved", "Fixed"],
    summary:
      "Remote build caching is now content-addressed, cutting warm build times roughly in half.",
    details: [
      "Docker layer caching now survives base-image bumps when lockfiles are unchanged.",
      "Fixed a race where concurrent deploys to the same environment could interleave migrations.",
      "Build logs stream with 4× less latency on large monorepos.",
    ],
  },
  {
    version: "v2.14",
    date: "Jun 2, 2026",
    title: "Audit log export and SCIM provisioning",
    tags: ["New", "Fixed"],
    summary:
      "Enterprise workspaces can stream audit events to S3 or Datadog and manage seats via SCIM 2.0.",
    details: [
      "Audit export supports JSON Lines with per-event HMAC signatures.",
      "SCIM provisioning maps IdP groups to workspace roles.",
      "Fixed timezone drift in scheduled deploy windows for non-UTC workspaces.",
    ],
  },
];

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

/* ------------------------------------------------------------------ */

function EntryCard({ entry, defaultOpen }: { entry: Entry; defaultOpen?: boolean }) {
  const [open, setOpen] = React.useState(!!defaultOpen);
  const bodyId = React.useId();

  return (
    <article className="relative pb-10 pl-8 last:pb-0 sm:pl-10">
      {/* Rail dot */}
      <span
        aria-hidden
        className="absolute top-1.5 left-0 flex size-[13px] items-center justify-center"
      >
        <span className="size-[9px] rounded-full border-2 border-background bg-muted-foreground/60 ring-1 ring-border" />
      </span>

      <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
        <span className="font-mono text-xs font-medium text-muted-foreground">
          {entry.version}
        </span>
        <time className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground tabular-nums">
          {entry.date}
        </time>
        <span className="flex gap-1.5">
          {entry.tags.map((tag) => (
            <span
              key={tag}
              className={cn(
                "rounded-full px-1.5 py-0.5 text-[11px] font-medium",
                TAG_STYLES[tag],
              )}
            >
              {tag}
            </span>
          ))}
        </span>
      </div>

      <h3 className="mt-2 text-base font-semibold tracking-tight">
        {entry.title}
      </h3>
      <p className="mt-1 text-sm text-pretty text-muted-foreground">
        {entry.summary}
      </p>

      {/* Expandable body via grid-template-rows */}
      <div
        id={bodyId}
        className={cn(
          "grid transition-[grid-template-rows] duration-[250ms] [transition-timing-function:var(--ease-out)] motion-reduce:transition-none",
          open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
        )}
      >
        <div className="min-h-0 overflow-hidden">
          <ul
            className={cn(
              "mt-3 space-y-1.5 border-l-2 border-border pl-4 text-sm text-muted-foreground",
              "transition-opacity duration-200 ease-out",
              open ? "opacity-100" : "opacity-0",
            )}
          >
            {entry.details.map((detail) => (
              <li key={detail} className="text-pretty">
                {detail}
              </li>
            ))}
          </ul>
        </div>
      </div>

      <button
        type="button"
        aria-expanded={open}
        aria-controls={bodyId}
        onClick={() => setOpen((o) => !o)}
        className={cn(
          "relative mt-2.5 inline-flex items-center gap-1 rounded-sm text-[13px] font-medium text-muted-foreground",
          "transition-[color] duration-150 ease-out hover:text-foreground",
          "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          "after:absolute after:top-1/2 after:left-1/2 after:h-10 after:w-[calc(100%+16px)] after:-translate-1/2",
        )}
      >
        {open ? "Hide details" : "Show details"}
        <ChevronDown
          aria-hidden
          className={cn(
            "size-3.5 transition-transform duration-200 ease-out",
            open && "rotate-180",
          )}
        />
      </button>
    </article>
  );
}

export interface ChangelogSectionProps extends React.ComponentProps<"section"> {
  /** Product name in the heading. */
  productName?: string;
}

/**
 * A product changelog: version entries on a vertical rail with date chips
 * and tag badges, entry bodies that expand via grid-template-rows, and a
 * subscribe input whose button blur-swaps to a confirmation.
 */
export function ChangelogSection({
  productName = "Relay",
  className,
  ...props
}: ChangelogSectionProps) {
  const reducedMotion = useReducedMotion();
  const inputId = React.useId();
  const [email, setEmail] = React.useState("");
  const [subscribed, setSubscribed] = React.useState(false);

  return (
    <section
      aria-label={`${productName} changelog`}
      className={cn("w-full max-w-2xl", className)}
      {...props}
    >
      <header className="flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
        <div>
          <h2 className="text-xl font-semibold tracking-tight">Changelog</h2>
          <p className="mt-1 text-sm text-muted-foreground">
            New features, improvements, and fixes to {productName}.
          </p>
        </div>
        <form
          className="flex w-full items-center gap-2 sm:w-auto"
          onSubmit={(e) => {
            e.preventDefault();
            if (EMAIL_RE.test(email.trim())) setSubscribed(true);
          }}
        >
          <label htmlFor={inputId} className="sr-only">
            Email address
          </label>
          <input
            id={inputId}
            type="email"
            placeholder="you@company.com"
            value={email}
            disabled={subscribed}
            onChange={(e) => setEmail(e.target.value)}
            className={cn(
              "h-8 w-full min-w-0 flex-1 rounded-lg border border-input bg-transparent px-3 text-sm outline-none sm:w-52",
              "transition-[border-color,box-shadow,opacity] duration-150 ease-out",
              "placeholder:text-muted-foreground disabled:opacity-60",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            )}
          />
          <Button
            type="submit"
            size="sm"
            className="w-[6.5rem] shrink-0"
            disabled={subscribed}
          >
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={subscribed ? "done" : "subscribe"}
                initial={
                  reducedMotion
                    ? { opacity: 0 }
                    : { opacity: 0, scale: 0.5, filter: "blur(4px)" }
                }
                animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                exit={
                  reducedMotion
                    ? { opacity: 0 }
                    : { opacity: 0, scale: 0.5, filter: "blur(4px)" }
                }
                transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                className="inline-flex items-center gap-1.5"
              >
                {subscribed ? (
                  <>
                    <Check aria-hidden className="size-3.5" />
                    Subscribed
                  </>
                ) : (
                  "Subscribe"
                )}
              </motion.span>
            </AnimatePresence>
          </Button>
        </form>
      </header>

      {/* Rail */}
      <div className="relative mt-8">
        <span
          aria-hidden
          className="absolute top-1.5 bottom-1.5 left-1.5 w-px bg-border"
        />
        {ENTRIES.map((entry, i) => (
          <EntryCard key={entry.version} entry={entry} defaultOpen={i === 0} />
        ))}
      </div>
    </section>
  );
}