Template · React · HTML
Changelog page
Eine Zeitleiste der Releases mit Filter für Neu, Verbessert und Behoben, einem Versionsindex und Anmeldung.
Vorschau
React
Installiere die Vorlage mit shadcn. Sie landet als normale Komponenten in deinem Projekt, die du selbst anpasst.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/changelog-page.jsonnpx shadcn@latest add @manniche/changelog-pagechangelog-page.tsx
// A changelog page with a timeline. The filter is a row of radio buttons; :has() hides the changes that do not match,
// so it works without JavaScript and the markup can ship as a static page. Colours come from the theme tokens.
import { ArrowRight, Rss, Sparkles, Wrench, Zap } from 'lucide-react'
import { cn } from '@/lib/utils'
type Kind = 'new' | 'improved' | 'fixed'
const kinds: Record<Kind, { label: string; icon: typeof Sparkles; style: string }> = {
new: { label: 'New', icon: Sparkles, style: 'bg-primary/12 text-primary' },
improved: { label: 'Improved', icon: Zap, style: 'bg-success/12 text-success' },
fixed: { label: 'Fixed', icon: Wrench, style: 'bg-muted text-muted-foreground' },
}
// Written out in full so Tailwind can find the classes. A change is hidden when another filter is chosen.
const hideUnless: Record<Kind, string> = {
new: 'group-has-[[value=improved]:checked]/log:hidden group-has-[[value=fixed]:checked]/log:hidden',
improved: 'group-has-[[value=new]:checked]/log:hidden group-has-[[value=fixed]:checked]/log:hidden',
fixed: 'group-has-[[value=new]:checked]/log:hidden group-has-[[value=improved]:checked]/log:hidden',
}
const hideIfNone: Record<Kind, string> = {
new: 'group-has-[[value=new]:checked]/log:hidden',
improved: 'group-has-[[value=improved]:checked]/log:hidden',
fixed: 'group-has-[[value=fixed]:checked]/log:hidden',
}
type Release = { version: string; date: string; iso: string; title: string; summary: string; highlight?: string[]; changes: [Kind, string][] }
const releases: Release[] = [
{
version: '4.2',
date: '1 October 2026',
iso: '2026-10-01',
title: 'Gift cards and a faster checkout',
summary: 'Sell gift cards in any amount, and checkout now loads in half the time on phones.',
highlight: ['Gift card', '€50', 'For Ana, with love'],
changes: [
['new', 'Gift cards in fixed or custom amounts, with an optional message.'],
['new', 'Customers can check a gift card balance from the order page.'],
['improved', 'Checkout loads 48% faster on mobile networks.'],
['fixed', 'Discount codes with lowercase letters were sometimes rejected.'],
],
},
{
version: '4.1',
date: '12 September 2026',
iso: '2026-09-12',
title: 'Stock across warehouses',
summary: 'Keep stock in more than one place and let Quayside pick the nearest warehouse for each order.',
changes: [
['new', 'Warehouses with their own stock levels and opening hours.'],
['improved', 'Low stock alerts now say which warehouse is running out.'],
['improved', 'The product list remembers your filters between visits.'],
['fixed', 'CSV exports used the wrong decimal separator for some languages.'],
],
},
{
version: '4.0',
date: '20 August 2026',
iso: '2026-08-20',
title: 'A calmer admin',
summary: 'A new navigation, a dark mode and keyboard shortcuts for everything you do every day.',
changes: [
['new', 'Dark mode that follows your system setting.'],
['new', 'Keyboard shortcuts. Press ? anywhere to see them.'],
['improved', 'The sidebar is grouped by task instead of by feature.'],
],
},
{
version: '3.9',
date: '29 July 2026',
iso: '2026-07-29',
title: 'Small fixes before the summer sale',
summary: 'A round of fixes reported by shops getting ready for August.',
changes: [
['fixed', 'Shipping labels for parcels over 20 kg printed on two pages.'],
['fixed', 'Order emails showed the wrong time zone for some shops.'],
['improved', 'Search finds orders by the last four digits of a phone number.'],
],
},
]
function Logo() {
return (
<a href="#top" className="flex items-center gap-2 font-semibold tracking-tight">
<svg viewBox="0 0 24 24" className="size-7" aria-hidden>
<rect width="24" height="24" rx="7" className="fill-primary" />
<path d="M6 15c3-2 9-2 12 0M6 10.5c3-2 9-2 12 0" fill="none" strokeWidth="2" strokeLinecap="round" className="stroke-primary-foreground" />
</svg>
Quayside
</a>
)
}
function KindBadge({ kind }: { kind: Kind }) {
const { label, icon: Icon, style } = kinds[kind]
return (
<span className={cn('inline-flex w-24 shrink-0 items-center gap-1.5 self-start rounded-full px-2 py-0.5 text-xs font-medium', style)}>
<Icon className="size-3" aria-hidden />
{label}
</span>
)
}
/** Changelog page: a filterable timeline of releases with a highlight card, a version index and a subscribe form. */
export function ChangelogPage() {
return (
<div id="top" className="group/log min-h-dvh bg-background font-sans text-foreground antialiased">
<header className="border-b">
<div className="mx-auto flex h-16 max-w-5xl items-center gap-6 px-4 sm:px-6">
<Logo />
<span className="text-sm text-muted-foreground">/ Changelog</span>
<a
href="#subscribe"
className="ml-auto inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors duration-150 hover:bg-muted"
>
<Rss className="size-4 text-primary" aria-hidden />
Subscribe
</a>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 sm:px-6">
<div className="py-14 sm:py-20">
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">What’s new in Quayside</h1>
<p className="mt-3 max-w-xl text-lg text-muted-foreground">
New features, improvements and fixes. We ship small changes every week and bigger ones about once a month.
</p>
<fieldset className="mt-8 flex flex-wrap gap-2 text-sm">
<legend className="sr-only">Show</legend>
{(
[
['all', 'Everything'],
['new', 'New'],
['improved', 'Improved'],
['fixed', 'Fixed'],
] as const
).map(([value, label]) => (
<label
key={value}
className="cursor-pointer rounded-full border px-3.5 py-1.5 text-muted-foreground transition-[background-color,color,border-color] duration-150 hover:text-foreground has-checked:border-foreground has-checked:bg-foreground has-checked:text-background has-focus-visible:ring-2 has-focus-visible:ring-ring has-focus-visible:ring-offset-2 has-focus-visible:ring-offset-background"
>
<input type="radio" name="changelog-filter" value={value} defaultChecked={value === 'all'} className="sr-only" />
{label}
</label>
))}
</fieldset>
</div>
<div className="grid gap-12 pb-20 lg:grid-cols-[1fr_12rem]">
<ol className="relative">
{releases.map((r, i) => {
const present = new Set(r.changes.map(([k]) => k))
const missing = (Object.keys(kinds) as Kind[]).filter((k) => !present.has(k))
return (
<li
key={r.version}
id={`v${r.version}`}
className={cn(
'relative grid scroll-mt-6 gap-4 pb-14 pl-8 sm:grid-cols-[9rem_1fr] sm:gap-12 sm:pl-0',
missing.map((k) => hideIfNone[k]),
)}
>
<span aria-hidden className="absolute top-2 bottom-0 left-[5px] w-px bg-border sm:left-[10.5rem]" />
<span
aria-hidden
className={cn(
'absolute top-1.5 left-0 size-[11px] rounded-full border-2 border-background ring-1 sm:left-[calc(10.5rem-5px)]',
i === 0 ? 'bg-primary ring-primary' : 'bg-muted-foreground/40 ring-border',
)}
/>
<div className="sm:pt-0.5">
<p className="font-semibold tabular-nums">v{r.version}</p>
<time dateTime={r.iso} className="text-sm text-muted-foreground">
{r.date}
</time>
</div>
<article className="min-w-0">
{i === 0 && (
<span className="mb-3 inline-block rounded-full bg-primary px-2.5 py-0.5 text-xs font-medium text-primary-foreground">Latest</span>
)}
<h2 className="text-2xl font-semibold tracking-tight">{r.title}</h2>
<p className="mt-2 text-muted-foreground">{r.summary}</p>
{r.highlight && (
<div className="mt-6 overflow-hidden rounded-2xl border bg-muted/50 p-6 sm:p-8">
<div className="mx-auto max-w-xs -rotate-2 rounded-2xl bg-primary p-5 text-primary-foreground shadow-xl shadow-primary/25 transition-transform duration-500 ease-out-quint hover:rotate-0 starting:rotate-6 starting:opacity-0">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{r.highlight[0]}</span>
<span className="opacity-75">Quayside</span>
</div>
<p className="mt-8 text-4xl font-semibold tracking-tight">{r.highlight[1]}</p>
<p className="mt-1 text-sm opacity-80">{r.highlight[2]}</p>
</div>
</div>
)}
<ul className="mt-6 space-y-3">
{r.changes.map(([kind, text]) => (
<li key={text} className={cn('flex gap-3 text-sm', hideUnless[kind])}>
<KindBadge kind={kind} />
<span className="pt-px">{text}</span>
</li>
))}
</ul>
</article>
</li>
)
})}
</ol>
<aside className="hidden lg:block">
<nav aria-label="Versions" className="sticky top-6">
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">Versions</p>
<ul className="mt-3 space-y-1 border-l text-sm">
{releases.map((r) => (
<li key={r.version}>
<a
href={`#v${r.version}`}
className="-ml-px block border-l border-transparent py-1 pl-4 text-muted-foreground transition-colors duration-150 hover:border-foreground hover:text-foreground"
>
v{r.version}
</a>
</li>
))}
</ul>
</nav>
</aside>
</div>
<section id="subscribe" className="mb-20 rounded-3xl border bg-card p-6 sm:p-10">
<div className="grid gap-6 md:grid-cols-[1fr_auto] md:items-center">
<div>
<h2 className="text-xl font-semibold tracking-tight">Get the changelog by email</h2>
<p className="mt-1 text-sm text-muted-foreground">One short email when we ship something worth knowing. Unsubscribe in one click.</p>
</div>
<form action="#" className="flex flex-col gap-2 sm:flex-row">
<label htmlFor="changelog-email" className="sr-only">
Email
</label>
<input
id="changelog-email"
type="email"
required
placeholder="you@shop.com"
className="h-11 rounded-full border bg-background px-4 text-sm outline-none transition-[border-color,box-shadow] focus:border-ring focus:ring-4 focus:ring-ring/15 sm:w-64"
/>
<button
type="submit"
className="group/sub inline-flex h-11 items-center justify-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition-transform duration-150 ease-out active:scale-[0.97]"
>
Subscribe
<ArrowRight className="size-4 transition-transform duration-200 group-hover/sub:translate-x-0.5" aria-hidden />
</button>
</form>
</div>
</section>
</main>
<footer className="border-t">
<p className="mx-auto max-w-5xl px-4 py-8 text-sm text-muted-foreground sm:px-6">© 2026 Quayside. A template from Manniche UI.</p>
</footer>
</div>
)
}changelog-page-demo.tsx
import { ChangelogPage } from '@/components/changelog-page'
export default function ChangelogPageDemo() {
return <ChangelogPage />
}HTML
Die ganze Seite als eine Datei mit eingebautem CSS und ohne JavaScript. Umschalter, Tabs und Filter funktionieren allein mit HTML und CSS.
Die Farbe in deiner eigenen App
Wenn shadcn bereits eingerichtet ist, ergeben diese Variablen dieselbe Farbe. Hell in :root, dunkel in .dark.
:root {
--primary: oklch(0.52 0.2 262);
--primary-foreground: oklch(0.99 0 0);
--accent: oklch(0.95 0.034 262);
--accent-foreground: oklch(0.36 0.140 262);
--ring: oklch(0.52 0.2 262);
}
.dark {
--primary: oklch(0.74 0.150 262);
--primary-foreground: oklch(0.18 0.024 262);
--accent: oklch(0.29 0.050 262);
--accent-foreground: oklch(0.9 0.050 262);
--ring: oklch(0.74 0.150 262);
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/changelog-page.json