A release-notes feed with a sticky date and version column, change-type labels, optional media, filter chips with counts and show-more for long releases.
Automations and a faster inbox
Run tasks when things change, and move through notifications twice as fast.
- NewAutomations: trigger actions when a task changes status.
- NewKeyboard shortcuts for every inbox action.
- ImprovedThe inbox loads in about half the time on large workspaces.
- ImprovedClearer empty states across projects and reports.
Reliability and polish
- FixedFixed a rare crash when reordering tasks quickly.
- ImprovedSmoother scrolling in long project lists.
- RemovedRemoved the legacy import tool. Use Import from the project menu.
Reports, redesigned
Charts are easier to read and to share.
- NewShare a report with a public link.
- ImprovedNew color palette that works in light and dark.
import { ChangelogFeed, type ChangelogEntry } from "@/components/ballmac/changelog-feed";
const entries: ChangelogEntry[] = [
{
id: "2-4",
version: "2.4.0",
date: "2026-09-24",
title: "Automations and a faster inbox",
summary: "Run tasks when things change, and move through notifications twice as fast.",
changes: [
{ type: "new", text: "Automations: trigger actions when a task changes status." },
{ type: "new", text: "Keyboard shortcuts for every inbox action." },
{ type: "improved", text: "The inbox loads in about half the time on large workspaces." },
{ type: "improved", text: "Clearer empty states across projects and reports." },
{ type: "fixed", text: "Comments no longer lose formatting when edited." },
{ type: "fixed", text: "Dates in exported reports now match your timezone." },
],
},
{
id: "2-3",
version: "2.3.2",
date: "2026-09-09",
title: "Reliability and polish",
changes: [
{ type: "fixed", text: "Fixed a rare crash when reordering tasks quickly." },
{ type: "improved", text: "Smoother scrolling in long project lists." },
{ type: "removed", text: "Removed the legacy import tool. Use Import from the project menu." },
],
},
{
id: "2-3-0",
version: "2.3.0",
date: "2026-08-20",
title: "Reports, redesigned",
summary: "Charts are easier to read and to share.",
changes: [
{ type: "new", text: "Share a report with a public link." },
{ type: "improved", text: "New color palette that works in light and dark." },
],
},
];
export default function ChangelogFeedDemo() {
return <ChangelogFeed entries={entries} className="max-w-3xl" />;
}Installation
$ pnpm dlx shadcn@latest add @ballmac/changelog-feedInstall the dependencies.
$ pnpm add motion@^12 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presets @ballmac/i18nCopy the source into your project.
components/ballmac/changelog-feed.tsx// Ballmac UI: Changelog Feed. https://ui.ballmac.com/components/changelog-feed "use client"; import * as React from "react"; import { Bug, ChevronDown, Sparkles, Trash2, Wand2 } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { spring } from "@/lib/ballmac/motion"; import { cn } from "@/lib/utils"; import { useLocale, useMessages, defineMessage, type Message } from "@/lib/ballmac/i18n"; type ChangeType = "new" | "improved" | "fixed" | "removed"; type ChangelogChange = { type: ChangeType; /** One sentence describing the change. */ text: string; }; type ChangelogEntry = { /** Unique id. */ id: string; /** Release version, for example "2.4.0". */ version?: string; /** ISO date (YYYY-MM-DD). */ date: string; /** Headline of the release. */ title: string; /** One or two sentences about the release. */ summary?: string; /** The individual changes. */ changes: ChangelogChange[]; /** An image, video or illustration shown under the summary. */ media?: React.ReactNode; }; type ChangelogFeedProps = Omit<React.ComponentProps<"div">, "children"> & { /** Releases, newest first. */ entries: ChangelogEntry[]; /** Show filter chips with counts. */ filterable?: boolean; /** Changes shown before "Show more". */ collapsedCount?: number; /** Locale for dates. Fixed by default so server and browser match. */ locale?: string; }; const TYPES: Record<ChangeType, { label: Message; icon: typeof Sparkles; chip: string; tone: string }> = { new: { label: defineMessage("changelog-feed.TYPES.new", "New"), icon: Sparkles, chip: "bg-chart-2/12 text-foreground", tone: "text-chart-2" }, improved: { label: defineMessage("changelog-feed.TYPES.improved", "Improved"), icon: Wand2, chip: "bg-chart-1/12 text-foreground", tone: "text-chart-1" }, fixed: { label: defineMessage("changelog-feed.TYPES.fixed", "Fixed"), icon: Bug, chip: "bg-chart-3/15 text-foreground", tone: "text-chart-3" }, removed: { label: defineMessage("changelog-feed.TYPES.removed", "Removed"), icon: Trash2, chip: "bg-muted text-foreground", tone: "text-muted-foreground" }, }; function EntryCard({ entry, filter, collapsedCount, locale, latest, }: { entry: ChangelogEntry; filter: ChangeType | "all"; collapsedCount: number; locale: string; latest: boolean; }) { const msg = useMessages() const reduce = useReducedMotion(); const [expanded, setExpanded] = React.useState(false); const listId = React.useId(); const changes = filter === "all" ? entry.changes : entry.changes.filter((c) => c.type === filter); const visible = expanded ? changes : changes.slice(0, collapsedCount); const hidden = changes.length - visible.length; const date = new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(new Date(`${entry.date}T00:00:00Z`)); const titleId = `${listId}-title`; return ( <motion.article layout={!reduce} initial={reduce ? false : { opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }} transition={spring.gentle} aria-labelledby={titleId} className="relative grid gap-4 md:grid-cols-[9.5rem_minmax(0,1fr)] md:gap-8" > <div className="md:sticky md:top-6 md:self-start"> <time dateTime={entry.date} className="text-sm font-medium tabular-nums"> {date} </time> <div className="mt-2 flex flex-wrap items-center gap-1.5"> {entry.version && ( <span className="rounded-md border bg-muted/60 px-1.5 py-0.5 font-mono text-xs font-medium">v{entry.version}</span> )} {latest && ( <span className="rounded-md bg-primary px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"> {msg("changelog-feed.latest", "Latest")} </span> )} </div> </div> <div className="rounded-2xl border bg-card p-5 text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04)] sm:p-6"> <h3 id={titleId} className="text-lg font-semibold tracking-tight text-balance"> {entry.title} </h3> {entry.summary && <p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{entry.summary}</p>} {entry.media && <div className="mt-4 overflow-hidden rounded-xl border bg-muted/40">{entry.media}</div>} <ul id={listId} className="mt-4 grid gap-2.5"> {visible.map((change, i) => { const t = TYPES[change.type]; const Icon = t.icon; return ( <li key={`${change.type}-${i}`} className="flex items-start gap-3 text-sm leading-relaxed"> <span className={cn("mt-0.5 inline-flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 text-[11px] font-semibold", t.chip)}> <Icon aria-hidden="true" className={cn("size-3", t.tone)} /> {msg.of(t.label)} </span> <span>{change.text}</span> </li> ); })} </ul> {(hidden > 0 || expanded) && changes.length > collapsedCount && ( <button type="button" aria-expanded={expanded} aria-controls={listId} onClick={() => setExpanded((v) => !v)} className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {expanded ? "Show fewer changes" : `Show ${hidden} more change${hidden === 1 ? "" : "s"}`} <ChevronDown aria-hidden="true" className={cn("size-4 transition-transform duration-200 motion-reduce:transition-none", expanded && "rotate-180")} /> </button> )} </div> </motion.article> ); } /** * A release-notes feed: a sticky date and version column beside a card per release, with change-type labels that pair * an icon and a word, optional media, filter chips with counts, and "show more" for long releases. */ function ChangelogFeed({ entries, filterable = true, collapsedCount = 4, locale, className, ...props }: ChangelogFeedProps) { const defaultLocale = useLocale() locale ??= defaultLocale const msg = useMessages() const [filter, setFilter] = React.useState<ChangeType | "all">("all"); const counts = entries.reduce( (acc, e) => { e.changes.forEach((c) => (acc[c.type] += 1)); return acc; }, { new: 0, improved: 0, fixed: 0, removed: 0 } as Record<ChangeType, number>, ); const shown = entries.filter((e) => filter === "all" || e.changes.some((c) => c.type === filter)); const types = (Object.keys(TYPES) as ChangeType[]).filter((t) => counts[t] > 0); return ( <div data-slot="changelog-feed" className={cn("grid gap-8", className)} {...props}> {filterable && ( <div role="group" aria-label={msg("changelog-feed.filterChanges", "Filter changes")} className="flex flex-wrap gap-2"> {(["all", ...types] as const).map((t) => { const active = filter === t; return ( <button key={t} type="button" aria-pressed={active} onClick={() => setFilter(t)} className="inline-flex h-8 items-center gap-2 rounded-full border px-3.5 text-[13px] font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground" > {t === "all" ? msg("changelog-feed.all", "All") : msg.of(TYPES[t].label)} <span className="text-xs opacity-70 tabular-nums">{t === "all" ? entries.reduce((n, e) => n + e.changes.length, 0) : counts[t]}</span> </button> ); })} </div> )} <div className="grid gap-10 md:gap-12" aria-live="polite"> <AnimatePresence initial={false} mode="popLayout"> {shown.map((entry) => ( <EntryCard key={entry.id} entry={entry} filter={filter} collapsedCount={collapsedCount} locale={locale} latest={entry.id === entries[0]?.id} /> ))} </AnimatePresence> {shown.length === 0 && <p className="text-sm text-muted-foreground">{msg("changelog-feed.noReleasesMatchThisFilter", "No releases match this filter.")}</p>} </div> </div> ); } export { ChangelogFeed, type ChangelogFeedProps, type ChangelogEntry, type ChangelogChange, type ChangeType };Update the import paths to match your project setup.
Usage
import { ChangelogFeed } from "@/components/ballmac/changelog-feed"The full example is in the Code tab above.
Examples
Single release with media
Hello, world
The first public release.
- NewProjects, tasks and comments.
import { Sparkles } from "lucide-react";
import { ChangelogFeed } from "@/components/ballmac/changelog-feed";
export default function ChangelogFeedStates() {
return (
<ChangelogFeed
filterable={false}
className="max-w-2xl"
entries={[
{
id: "1",
version: "1.0.0",
date: "2026-07-01",
title: "Hello, world",
summary: "The first public release.",
media: (
<div className="flex h-28 items-center justify-center bg-gradient-to-br from-chart-1/20 via-chart-4/15 to-chart-2/20">
<Sparkles aria-hidden="true" className="size-8 text-foreground/60" />
</div>
),
changes: [{ type: "new", text: "Projects, tasks and comments." }],
},
]}
/>
);
}API reference
| Prop | Type | Default |
|---|---|---|
entries*Releases, newest first. | ChangelogEntry[] | — |
filterableShow filter chips with counts. | boolean | true |
collapsedCountChanges shown before "Show more". | number | 4 |
localeLocale for dates. Fixed by default so server and browser match. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Filter chips and show-more are buttons with aria-pressed / aria-expanded |
| Screen readers | Each release is an article with a time element; change types have text labels |
Use with AI
entries[] with {id,version,date (ISO),title,summary,changes:[{type,text}],media}. Types: new | improved | fixed | removed. Dates format in UTC. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Changelog Feed (@ballmac/changelog-feed) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Product changelog pages
- What's-new surfaces
Not for
- Activity of people on a record; use activity-feed
- A single status message; use banner
Registry JSON: https://ui.ballmac.com/r/changelog-feed.json
Credits
Free to use in personal and commercial projects.
Pairs well with
Timeline
A composable sequence of milestones with current and complete states, dates, and connectors.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Badge
A small pill label in four variants with an optional status dot and success, warning and error tones. Style links with badgeVariants().
Billing Card
A subscription summary with plan and price, status, next charge, payment method, seat usage and recent invoices, including failed-payment and trial states.