Öffentliche Roadmap
Eine öffentliche Roadmap in vier Layouts: jetzt, als Nächstes und später mit Stimmen, Balken über Quartale mit einer Linie für heute, ein Ideen-Board mit Vorschlagsformular und Meilensteine auf einer Linie, die sich füllt.
Zeigen, wohin das Produkt geht
Eine öffentliche Roadmap zeigt Kunden, was kommt, und lädt sie ein, mitzugestalten. Die vier Fassungen hier reichen vom einfachen Jetzt, als Nächstes, später bis zum Plan über Quartale, einem Ideen-Board mit Stimmen und Meilensteinen, die sich beim Scrollen füllen.
„Jetzt, als Nächstes, später“ hat drei Spalten mit Zahlen, Karten mit Bereich, Fortschritt und einem Stimmknopf, der mitzählt, oben nach Bereich gefiltert. „Über Quartale“ legt Balken über die Quartale nach Bereichen, mit einer Linie für heute und einer Legende der Zustände, am Telefon scrollt der Plan seitlich und die Bereichsspalte bleibt stehen. Das „Ideen-Board“ sortiert Ideen nach Stimmen mit Reitern für den Status, großen Stimmknöpfen und einem Formular für neue Vorschläge, das sagt, was fehlt, und nach dem Senden dankt. Die „Meilensteine“ wechseln sich an einer senkrechten Linie ab, die sich beim Scrollen bis zum laufenden füllt, erschienene sind abgehakt, der aktuelle leuchtet.
Versprich Richtungen statt Daten, wenn Pläne sich ändern können, Jetzt, als Nächstes, später ist von Natur aus ehrlich. Schließ den Kreis bei Stimmen, markiere Ideen als erschienen und verlinke das Changelog. Halte Karten kurz, ein Satz dazu, was sich für Kunden ändert.
Variants
Details
Was Roadmap mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Changelog und Roadmap
- Bereich
- Marketing
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/roadmap.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 31 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle RoadmapProps aus components/ui/roadmap.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalRoadmapVariantnow-next-laterquartersvote-boardmilestones
eyebrowoptionalReact.ReactNodetitlePflichtReact.ReactNodedescriptionoptionalReact.ReactNodeitemsPflichtRoadmapItem[]quartersoptionalstring[]quarters: column labels, e.g. Q4 2026
todayoptionalnumberquarters: where today is, in quarters from the start (1.4 = 40 % into the second)
onVoteoptional(item: RoadmapItem, voted: boolean) => voidCalled on every vote, with the new state
onSuggestoptional(idea: { title: string; detail: string }) => void | Promise<void>vote-board: resolve to show success, throw to show the error
ctaoptional{ label: string; href: string }
Eingebaute Texte
Diese Texte schreibt Roadmap selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 31 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| now | Englisch Now | Deutsch Jetzt |
| next | Englisch Next | Deutsch Als Nächstes |
| later | Englisch Later | Deutsch Später |
| done | Englisch Shipped | Deutsch Erschienen |
| considering | Englisch Considering | Deutsch In Prüfung |
| planned | Englisch Planned | Deutsch Geplant |
| progress | Englisch In progress | Deutsch In Arbeit |
| nowHint | Englisch Being built this month | Deutsch Wird diesen Monat gebaut |
| nextHint | Englisch Starting in the coming weeks | Deutsch Startet in den nächsten Wochen |
| laterHint | Englisch On our list, open to change | Deutsch Auf unserer Liste, offen für Änderungen |
| all | Englisch All areas | Deutsch Alle Bereiche |
| area | Englisch Filter by area | Deutsch Nach Bereich filtern |
| vote | Englisch Vote for {t} | Deutsch Für {t} stimmen |
| voted | Englisch You voted for {t} | Deutsch Du hast für {t} gestimmt |
| votes | Englisch {n} votes | Deutsch {n} Stimmen |
| today | Englisch Today | Deutsch Heute |
| status | Englisch Status | Deutsch Status |
| top | Englisch Most wanted | Deutsch Meistgewünscht |
| suggest | Englisch Suggest an idea | Deutsch Idee vorschlagen |
| ideaTitle | Englisch Your idea in one line | Deutsch Deine Idee in einer Zeile |
| ideaTitlePh | Englisch Export reports as a spreadsheet | Deutsch Berichte als Tabelle exportieren |
| ideaDetail | Englisch What would it help you with? | Deutsch Wobei würde es dir helfen? |
| ideaDetailHint | Englisch A sentence or two, a real example helps most. | Deutsch Ein, zwei Sätze, ein echtes Beispiel hilft am meisten. |
| send | Englisch Send idea | Deutsch Idee senden |
| sending | Englisch Sending | Deutsch Wird gesendet |
| tooShort | Englisch Please describe the idea in at least a few words. | Deutsch Bitte beschreib die Idee mit mindestens ein paar Worten. |
| failed | Englisch That did not go through. Your text is still here, please try again. | Deutsch Das hat nicht geklappt. Dein Text ist noch da, bitte versuch es noch einmal. |
| thanks | Englisch Thank you. Your idea is on the board, others can vote for it from today. | Deutsch Danke. Deine Idee steht auf dem Board, ab heute können andere dafür stimmen. |
| empty | Englisch Nothing here right now. | Deutsch Gerade nichts hier. |
| complete | Englisch {n} done | Deutsch {n} fertig |
| sideways | Englisch Swipe to see the later quarters | Deutsch Wischen für spätere Quartale |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Roadmap reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-controls, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, group, status, tab, tablist und tabpanel.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 620 Zeilen
'use client';
import { useEffect, useId, useState } from 'react';
import { cn, formatPercent, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { useScrollFade, useScrollProgress } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
/*
* A public roadmap: what is being built, what comes next and what people
* ask for. Four layouts:
*
* now-next-later three columns, cards with area, progress and a vote button, filter by area
* quarters bars across quarters with a line for today, scrolls sideways on phones
* vote-board ideas sorted by votes, status tabs and a form to suggest one
* milestones a vertical line that fills while scrolling, done, now and later
*/
export type RoadmapVariant = 'now-next-later' | 'quarters' | 'vote-board' | 'milestones';
export type RoadmapStatus = 'considering' | 'planned' | 'progress' | 'done';
export interface RoadmapItem {
title: string;
description?: string;
status: RoadmapStatus;
/** Product area, used for filters and tags */
area?: string;
icon?: IconName;
votes?: number;
/** 0 to 100, for items in progress */
progress?: number;
/** quarters: index of the first quarter and how many it spans (halves allowed) */
start?: number;
span?: number;
/** milestones: a date or quarter as text */
when?: string;
}
export interface RoadmapProps {
variant?: RoadmapVariant;
eyebrow?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
items: RoadmapItem[];
/** quarters: column labels, e.g. Q4 2026 */
quarters?: string[];
/** quarters: where today is, in quarters from the start (1.4 = 40 % into the second) */
today?: number;
/** Called on every vote, with the new state */
onVote?: (item: RoadmapItem, voted: boolean) => void;
/** vote-board: resolve to show success, throw to show the error */
onSuggest?: (idea: { title: string; detail: string }) => void | Promise<void>;
cta?: { label: string; href: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
now: 'Now',
next: 'Next',
later: 'Later',
done: 'Shipped',
considering: 'Considering',
planned: 'Planned',
progress: 'In progress',
nowHint: 'Being built this month',
nextHint: 'Starting in the coming weeks',
laterHint: 'On our list, open to change',
all: 'All areas',
area: 'Filter by area',
vote: 'Vote for {t}',
voted: 'You voted for {t}',
votes: '{n} votes',
today: 'Today',
status: 'Status',
top: 'Most wanted',
suggest: 'Suggest an idea',
ideaTitle: 'Your idea in one line',
ideaTitlePh: 'Export reports as a spreadsheet',
ideaDetail: 'What would it help you with?',
ideaDetailHint: 'A sentence or two, a real example helps most.',
send: 'Send idea',
sending: 'Sending',
tooShort: 'Please describe the idea in at least a few words.',
failed: 'That did not go through. Your text is still here, please try again.',
thanks: 'Thank you. Your idea is on the board, others can vote for it from today.',
empty: 'Nothing here right now.',
complete: '{n} done',
sideways: 'Swipe to see the later quarters',
},
de: {
now: 'Jetzt',
next: 'Als Nächstes',
later: 'Später',
done: 'Erschienen',
considering: 'In Prüfung',
planned: 'Geplant',
progress: 'In Arbeit',
nowHint: 'Wird diesen Monat gebaut',
nextHint: 'Startet in den nächsten Wochen',
laterHint: 'Auf unserer Liste, offen für Änderungen',
all: 'Alle Bereiche',
area: 'Nach Bereich filtern',
vote: 'Für {t} stimmen',
voted: 'Du hast für {t} gestimmt',
votes: '{n} Stimmen',
today: 'Heute',
status: 'Status',
top: 'Meistgewünscht',
suggest: 'Idee vorschlagen',
ideaTitle: 'Deine Idee in einer Zeile',
ideaTitlePh: 'Berichte als Tabelle exportieren',
ideaDetail: 'Wobei würde es dir helfen?',
ideaDetailHint: 'Ein, zwei Sätze, ein echtes Beispiel hilft am meisten.',
send: 'Idee senden',
sending: 'Wird gesendet',
tooShort: 'Bitte beschreib die Idee mit mindestens ein paar Worten.',
failed: 'Das hat nicht geklappt. Dein Text ist noch da, bitte versuch es noch einmal.',
thanks: 'Danke. Deine Idee steht auf dem Board, ab heute können andere dafür stimmen.',
empty: 'Gerade nichts hier.',
complete: '{n} fertig',
sideways: 'Wischen für spätere Quartale',
},
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = RoadmapProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const STATUS_DOT: Record<RoadmapStatus, string> = {
considering: 'border border-dashed border-border-strong bg-transparent',
planned: 'bg-[color-mix(in_oklab,var(--foreground)_35%,transparent)]',
progress: 'bg-primary',
done: 'bg-success',
};
function StatusTag({ status, t }: { status: RoadmapStatus; t: Texts }) {
return (
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-border bg-background px-2.5 text-[12px] font-medium text-muted-foreground">
<span className={cn('relative size-2 rounded-full', STATUS_DOT[status])} aria-hidden>
{status === 'progress' && <span className="absolute inset-0 animate-ping rounded-full bg-primary opacity-50 motion-reduce:animate-none" />}
</span>
{t[status]}
</span>
);
}
function Head({ eyebrow, title, description, center, className }: Pick<RoadmapProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
return (
<div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className={cn('kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={delay(80)}>
{description}
</p>
)}
</div>
);
}
/** Upvote button with a count that rolls, keyboard and screen reader friendly */
function Vote({ item, t, onVote, size = 'md' }: { item: RoadmapItem; t: Texts; onVote?: RoadmapProps['onVote']; size?: 'md' | 'lg' }) {
const locale = useLocale();
const [on, setOn] = useState(false);
const n = (item.votes ?? 0) + (on ? 1 : 0);
const text = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
return (
<button
type="button"
aria-pressed={on}
aria-label={`${fill(on ? t.voted : t.vote, { t: item.title })}, ${fill(t.votes, { n: text })}`}
onClick={() => {
setOn(!on);
onVote?.(item, !on);
}}
className={cn(
'group/vote relative flex shrink-0 cursor-pointer flex-col items-center justify-center rounded-xl border font-semibold tabular-nums transition-[background-color,border-color,color,box-shadow,transform] duration-200 ease-[var(--ease-kit)] active:scale-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary motion-reduce:active:scale-100',
size === 'lg' ? 'h-16 w-14 text-[15px]' : 'h-12 w-11 text-[12.5px]',
on ? 'border-primary bg-primary text-primary-foreground shadow-[0_0_0_4px_var(--glow)]' : 'border-border bg-card text-foreground hover:border-border-strong hover:bg-surface',
)}
>
<Icon name="chevron-up" size={size === 'lg' ? 18 : 15} strokeWidth={2.2} className={cn('transition-transform duration-300 ease-[var(--ease-kit)]', on ? '-translate-y-0.5' : 'group-hover/vote:-translate-y-0.5')} />
<span key={n} className="animate-[rm-roll_.35s_var(--ease-kit)] motion-reduce:animate-none">
{text}
</span>
<style>{'@keyframes rm-roll{from{opacity:0;transform:translateY(40%)}}'}</style>
</button>
);
}
function AreaFilter({ areas, value, onChange, t }: { areas: string[]; value: string | null; onChange: (a: string | null) => void; t: Texts }) {
const [ref, fade] = useScrollFade<HTMLDivElement>();
if (!areas.length) return null;
return (
<div role="group" aria-label={t.area} ref={ref} className={cn('kit-no-scrollbar -mx-6 flex gap-2 overflow-x-auto px-6 sm:mx-0 sm:flex-wrap sm:px-0', fade, 'sm:[mask-image:none]')}>
{[null, ...areas].map((a) => (
<button
key={a ?? 'all'}
type="button"
aria-pressed={value === a}
onClick={() => onChange(a)}
className={cn(
'inline-flex min-h-11 shrink-0 cursor-pointer items-center rounded-full border px-4 text-[14px] font-medium whitespace-nowrap transition-[background-color,border-color,color] duration-200 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary sm:min-h-9',
value === a ? 'border-foreground bg-foreground text-background' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground',
)}
>
{a ?? t.all}
</button>
))}
</div>
);
}
const areasOf = (items: RoadmapItem[]) => [...new Set(items.map((i) => i.area).filter(Boolean))] as string[];
/* ------------------------------------------------------------------ */
/* now-next-later */
/* ------------------------------------------------------------------ */
function NowNextLater(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const uid = useId();
const [area, setArea] = useState<string | null>(null);
const cols: { key: string; label: string; hint: string; status: RoadmapStatus[] }[] = [
{ key: 'now', label: t.now, hint: t.nowHint, status: ['progress'] },
{ key: 'next', label: t.next, hint: t.nextHint, status: ['planned'] },
{ key: 'later', label: t.later, hint: t.laterHint, status: ['considering'] },
];
return (
<Section className={p.className}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<Head {...p} />
<div className="kit-rise lg:max-w-[30rem]" style={delay(120)}>
<AreaFilter areas={areasOf(p.items)} value={area} onChange={setArea} t={t} />
</div>
</div>
<div className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-3">
{cols.map((c, ci) => {
const list = p.items.filter((i) => c.status.includes(i.status) && (!area || i.area === area));
return (
<section key={c.key} aria-labelledby={`${uid}-${c.key}`} className="kit-rise flex flex-col rounded-2xl border border-border bg-surface p-2" style={delay(160 + ci * 90)}>
<header className="flex items-start justify-between gap-3 px-3 pb-3 pt-3">
<div>
<h3 id={`${uid}-${c.key}`} className="flex items-center gap-2 text-[15px] font-semibold text-foreground">
<span className={cn('size-2 rounded-full', STATUS_DOT[c.status[0]])} aria-hidden />
{c.label}
</h3>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{c.hint}</p>
</div>
<span className="grid h-6 min-w-6 place-items-center rounded-full bg-card px-1.5 text-[12px] font-semibold tabular-nums text-muted-foreground ring-1 ring-inset ring-border">{list.length}</span>
</header>
<ul className="grid gap-2">
{list.length === 0 && <li className="rounded-xl border border-dashed border-border px-4 py-8 text-center text-[13px] text-subtle-foreground">{t.empty}</li>}
{list.map((it) => (
<li key={it.title} className="group/card flex gap-3 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[box-shadow,transform] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0">
<div className="min-w-0 flex-1">
{it.area && <p className="text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{it.area}</p>}
<p className="mt-1 text-pretty text-[15px] font-semibold leading-snug text-foreground">{it.title}</p>
{it.description && <p className="mt-1.5 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{it.description}</p>}
{it.status === 'progress' && it.progress !== undefined && (
<div className="mt-3">
<div className="flex justify-between text-[12px] text-muted-foreground">
<span>{fill(t.complete, { n: formatPercent(it.progress, locale) })}</span>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-track">
<span className="block h-full origin-left animate-kit-progress rounded-full bg-primary [--kit-duration:1.4s] motion-reduce:animate-none" style={{ width: `${it.progress}%` }} />
</div>
</div>
)}
</div>
{it.votes !== undefined && <Vote item={it} t={t} onVote={p.onVote} />}
</li>
))}
</ul>
</section>
);
})}
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* quarters */
/* ------------------------------------------------------------------ */
function Quarters(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const qs = p.quarters ?? [];
const n = Math.max(1, qs.length);
const [ref, fade] = useScrollFade<HTMLDivElement>();
const [viewRef, inView] = useInView<HTMLDivElement>();
const today = p.today !== undefined ? round((p.today / n) * 100, 2) : null;
const areas = areasOf(p.items);
return (
<Section background="surface" className={p.className}>
<Head {...p} />
<div className="mt-6 flex flex-wrap gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
{(['done', 'progress', 'planned', 'considering'] as RoadmapStatus[]).map((s) => (
<span key={s} className="flex items-center gap-2">
<span className={cn('h-2 w-4 rounded-full', s === 'considering' ? 'border border-dashed border-border-strong' : STATUS_DOT[s])} aria-hidden />
{t[s]}
</span>
))}
</div>
<div ref={viewRef} className="kit-rise mt-8 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]" style={delay(120)}>
<div ref={ref} tabIndex={0} aria-label={t.sideways} className={cn('kit-no-scrollbar relative overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary', fade)}>
<div className="relative min-w-[78rem] [--lab:6.5rem] sm:min-w-[60rem] sm:[--lab:11rem]">
{/* quarter header */}
<div className="grid border-b border-border" style={{ gridTemplateColumns: `var(--lab) repeat(${n}, minmax(0, 1fr))` }}>
<span className="sticky left-0 z-[2] border-r border-border bg-card" />
{qs.map((q, i) => (
<span key={q} className={cn('px-4 py-3 text-[13px] font-semibold text-foreground', i > 0 && 'border-l border-border')}>
{q}
</span>
))}
</div>
{/* rows grouped by area */}
{(areas.length ? areas : ['']).map((a) => (
<div key={a} className="border-b border-border last:border-b-0">
{p.items
.filter((it) => !areas.length || it.area === a)
.map((it, i) => {
const left = round(((it.start ?? 0) / n) * 100, 2);
const width = round(((it.span ?? 1) / n) * 100, 2);
return (
<div key={it.title} className="grid min-h-14 items-center" style={{ gridTemplateColumns: `var(--lab) minmax(0, 1fr)` }}>
<span className="sticky left-0 z-[2] flex h-full items-center border-r border-border bg-card px-3 text-[12.5px] font-medium text-muted-foreground sm:px-4">{i === 0 ? a : ''}</span>
<div className="relative h-full">
{Array.from({ length: n - 1 }, (_, k) => (
<span key={k} aria-hidden className="absolute inset-y-0 w-px bg-border" style={{ left: `${round(((k + 1) / n) * 100, 2)}%` }} />
))}
<div
className={cn(
'absolute top-1/2 z-[1] flex h-9 -translate-y-1/2 items-center gap-2 overflow-hidden rounded-lg px-3 text-[13px] font-medium transition-[clip-path] duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none',
it.status === 'done' && 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-foreground ring-1 ring-inset ring-[color-mix(in_oklab,var(--success)_35%,transparent)]',
it.status === 'progress' && 'bg-primary text-primary-foreground shadow-[0_0_0_3px_var(--glow)]',
it.status === 'planned' && 'bg-[color-mix(in_oklab,var(--foreground)_9%,var(--card))] text-foreground ring-1 ring-inset ring-border',
it.status === 'considering' && 'border border-dashed border-border-strong text-muted-foreground',
)}
// a short bar grows to fit its title, never past the last quarter
style={{ left: `calc(${left}% + 6px)`, minWidth: `calc(${width}% - 12px)`, width: 'max-content', maxWidth: `calc(${round(100 - left, 2)}% - 12px)`, clipPath: inView ? 'inset(0 0 0 0 round 8px)' : 'inset(0 100% 0 0 round 8px)', transitionDelay: `${i * 80}ms` }}
title={it.description}
>
{it.status === 'done' && <Icon name="check" size={13} strokeWidth={2.4} className="shrink-0 text-success" />}
<span className="truncate">{it.title}</span>
{it.status === 'progress' && it.progress !== undefined && <span className="ml-auto shrink-0 text-[11.5px] tabular-nums opacity-80">{formatPercent(it.progress, locale)}</span>}
</div>
</div>
</div>
);
})}
</div>
))}
{today !== null && (
<div aria-hidden className="pointer-events-none absolute inset-y-0 z-0" style={{ left: `calc(var(--lab) + (100% - var(--lab)) * ${today / 100})` }}>
<span className="absolute inset-y-0 w-px bg-primary" />
<span className="absolute -left-[22px] top-2 rounded-full bg-primary px-2 py-0.5 text-[10.5px] font-semibold text-primary-foreground">{t.today}</span>
</div>
)}
</div>
</div>
</div>
<p className="mt-3 text-[12.5px] text-subtle-foreground md:hidden">{t.sideways}</p>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* vote-board */
/* ------------------------------------------------------------------ */
function Suggest({ t, onSuggest }: { t: Texts; onSuggest?: RoadmapProps['onSuggest'] }) {
const id = useId();
const [state, setState] = useState<'idle' | 'short' | 'sending' | 'failed' | 'done'>('idle');
if (state === 'done')
return (
<p role="status" className="kit-rise flex items-start gap-3 rounded-xl bg-[color-mix(in_oklab,var(--success)_9%,var(--card))] p-4 text-[14px] text-foreground">
<Icon name="check-circle" size={19} className="mt-px shrink-0 text-success" />
{t.thanks}
</p>
);
const short = state === 'short';
return (
<form
noValidate
onSubmit={async (e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
const title = String(f.get('title') ?? '').trim();
const detail = String(f.get('detail') ?? '').trim();
if (title.split(/\s+/).filter(Boolean).length < 2) {
setState('short');
(e.currentTarget.elements.namedItem('title') as HTMLElement | null)?.focus();
return;
}
setState('sending');
try {
await (onSuggest ? onSuggest({ title, detail }) : wait(800));
setState('done');
} catch {
setState('failed');
}
}}
className="grid gap-4"
>
<div>
<label htmlFor={`${id}-t`} className="text-sm font-medium text-foreground">
{t.ideaTitle}
</label>
<input
id={`${id}-t`}
name="title"
placeholder={t.ideaTitlePh}
aria-invalid={short || undefined}
aria-describedby={short ? `${id}-e` : undefined}
onInput={() => short && setState('idle')}
className={cn('mt-2 h-12 w-full rounded-lg border bg-background px-4 text-base text-foreground placeholder:text-subtle-foreground transition-[border-color,box-shadow] hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:text-[15px]', short ? 'border-destructive' : 'border-border')}
/>
{short && (
<p id={`${id}-e`} role="alert" className="mt-2 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{t.tooShort}
</p>
)}
</div>
<div>
<label htmlFor={`${id}-d`} className="text-sm font-medium text-foreground">
{t.ideaDetail}
</label>
<textarea id={`${id}-d`} name="detail" rows={3} aria-describedby={`${id}-h`} className="mt-2 w-full resize-none rounded-lg border border-border bg-background px-4 py-3 text-base leading-relaxed text-foreground transition-[border-color,box-shadow] hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:text-[15px]" />
<p id={`${id}-h`} className="mt-1.5 text-[13px] text-muted-foreground">
{t.ideaDetailHint}
</p>
</div>
<Button type="submit" size="lg" disabled={state === 'sending'} icon={state === 'sending' ? undefined : 'send'} className="w-full">
{state === 'sending' && <Icon name="loader" size={17} className="animate-kit-spin [--kit-duration:0.8s]" />}
{state === 'sending' ? t.sending : t.send}
</Button>
{state === 'failed' && (
<p role="alert" className="text-[13px] font-medium text-destructive">
{t.failed}
</p>
)}
</form>
);
}
function VoteBoard(p: ViewProps) {
const { t } = p;
const id = useId();
const tabs: (RoadmapStatus | 'top')[] = ['top', 'planned', 'progress', 'done'];
const [tab, setTab] = useState<(typeof tabs)[number]>('top');
const [tabRef, tabFade] = useScrollFade<HTMLDivElement>();
const list = (tab === 'top' ? p.items.filter((i) => i.status !== 'done') : p.items.filter((i) => i.status === tab)).slice().sort((a, b) => (b.votes ?? 0) - (a.votes ?? 0));
const label = (k: (typeof tabs)[number]) => (k === 'top' ? t.top : t[k]);
return (
<Section className={p.className}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)] lg:gap-14">
<div className="min-w-0">
<Head {...p} />
<div
role="tablist"
aria-label={t.status}
ref={tabRef}
className={cn('kit-no-scrollbar kit-rise -mx-6 mt-10 flex gap-1 overflow-x-auto border-b border-border px-6 sm:mx-0 sm:px-0', tabFade, 'sm:[mask-image:none]')}
style={delay(120)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
e.preventDefault();
const next = (tabs.indexOf(tab) + d + tabs.length) % tabs.length;
setTab(tabs[next]);
(e.currentTarget.querySelectorAll('[role="tab"]')[next] as HTMLElement | undefined)?.focus();
}}
>
{tabs.map((k) => (
<button
key={k}
type="button"
role="tab"
id={`${id}-${k}`}
aria-selected={tab === k}
aria-controls={`${id}-panel`}
tabIndex={tab === k ? 0 : -1}
onClick={() => setTab(k)}
className={cn(
'relative -mb-px inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-2 border-b-2 px-3 text-[14px] font-medium whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary',
tab === k ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{k !== 'top' && <span className={cn('size-2 rounded-full', STATUS_DOT[k])} aria-hidden />}
{label(k)}
</button>
))}
</div>
<ol id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-${tab}`} className="mt-2">
{list.map((it, i) => (
<li key={`${tab}-${it.title}`} className="kit-rise flex items-start gap-4 border-b border-border py-5" style={delay(i * 50)}>
<Vote item={it} t={t} onVote={p.onVote} size="lg" />
<div className="min-w-0 flex-1 pt-1">
<p className="text-pretty text-[16px] font-semibold leading-snug text-foreground">{it.title}</p>
{it.description && <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{it.description}</p>}
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatusTag status={it.status} t={t} />
{it.area && <span className="text-[12.5px] text-subtle-foreground">{it.area}</span>}
</div>
</div>
</li>
))}
{list.length === 0 && <li className="py-12 text-center text-[14px] text-muted-foreground">{t.empty}</li>}
</ol>
</div>
<aside className="lg:sticky lg:top-24 lg:self-start">
<div className="kit-rise rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-7" style={delay(200)}>
<h3 className="flex items-center gap-2 text-[17px] font-semibold text-foreground">
<Icon name="lightbulb" size={19} className="text-primary" />
{t.suggest}
</h3>
<div className="mt-5">
<Suggest t={t} onSuggest={p.onSuggest} />
</div>
</div>
</aside>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* milestones */
/* ------------------------------------------------------------------ */
function Milestones(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const line = useScrollProgress<HTMLOListElement>({ start: 0.15, end: 0.75 });
const lastDone = p.items.reduce((a, it, i) => (it.status === 'done' || it.status === 'progress' ? i : a), -1);
const share = p.items.length > 1 ? round(Math.max(0, lastDone) / (p.items.length - 1), 3) : 1;
// the filled part ends exactly at the dot of the last reached milestone, measured, since rows differ in height
const [reach, setReach] = useState<number | null>(null);
useEffect(() => {
const ol = line.current;
if (!ol) return;
const measure = () => {
const li = ol.children[Math.max(0, lastDone) + 2] as HTMLElement | undefined;
setReach(li ? Math.max(0, li.offsetTop + 20 - 24) : null);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(ol);
return () => ro.disconnect();
}, [line, lastDone, p.items.length]);
return (
<Section className={p.className}>
<Head {...p} center />
<ol ref={line} className="relative mx-auto mt-16 max-w-[46rem]">
{/* track, the part reached so far fills while scrolling */}
<span aria-hidden className="absolute bottom-6 left-[19px] top-6 w-0.5 rounded-full bg-track sm:left-1/2 sm:-translate-x-1/2" />
<span aria-hidden className="absolute left-[19px] top-6 w-0.5 origin-top rounded-full bg-primary sm:left-1/2 sm:-translate-x-1/2" style={{ height: reach !== null ? `${reach}px` : `calc((100% - 3rem) * ${share})`, transform: 'scaleY(var(--kit-progress, 1))' }} />
{p.items.map((it, i) => {
const right = i % 2 === 1;
return (
<li key={it.title} className="relative grid grid-cols-[40px_minmax(0,1fr)] gap-4 pb-10 last:pb-0 sm:grid-cols-[minmax(0,1fr)_40px_minmax(0,1fr)] sm:gap-8">
<span className={cn('relative z-[1] col-start-1 row-start-1 grid size-10 place-items-center rounded-full border-2 bg-background sm:col-start-2', it.status === 'done' ? 'border-primary bg-primary text-primary-foreground' : it.status === 'progress' ? 'border-primary text-primary shadow-[0_0_0_6px_var(--glow)]' : 'border-border text-subtle-foreground')}>
<Icon name={it.status === 'done' ? 'check' : (it.icon ?? (it.status === 'progress' ? 'activity' : 'circle'))} size={it.status === 'done' ? 17 : 16} strokeWidth={it.status === 'done' ? 2.2 : 1.8} />
</span>
<div className={cn('kit-rise col-start-2 row-start-1 pt-1', right ? 'sm:col-start-3' : 'sm:col-start-1 sm:text-right')} style={delay(i * 60)}>
<p className={cn('flex flex-wrap items-center gap-2 text-[13px] font-medium text-muted-foreground', !right && 'sm:justify-end')}>
{it.when}
<StatusTag status={it.status} t={t} />
</p>
<p className="mt-2 text-balance text-[18px] font-semibold leading-snug text-foreground">{it.title}</p>
{it.description && <p className="mt-1.5 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{it.description}</p>}
{it.status === 'progress' && it.progress !== undefined && (
<div className={cn('mt-3 max-w-[16rem]', !right && 'sm:ml-auto')}>
<div className="h-1.5 overflow-hidden rounded-full bg-track">
<span className="block h-full rounded-full bg-primary" style={{ width: `${it.progress}%` }} />
</div>
<p className="mt-1.5 text-[12px] tabular-nums text-muted-foreground">{fill(t.complete, { n: formatPercent(it.progress, locale) })}</p>
</div>
)}
</div>
</li>
);
})}
</ol>
{p.cta && (
<div className="mt-14 flex justify-center">
<Button href={p.cta.href} size="lg" variant="outline" icon="arrow-right" className="max-sm:w-full">
{p.cta.label}
</Button>
</div>
)}
</Section>
);
}
/* ------------------------------------------------------------------ */
const VIEWS: Record<RoadmapVariant, (p: ViewProps) => React.ReactNode> = {
'now-next-later': NowNextLater,
quarters: Quarters,
'vote-board': VoteBoard,
milestones: Milestones,
};
export function Roadmap({ variant = 'now-next-later', labels, ...rest }: RoadmapProps) {
const t = useTexts(TEXTS, labels);
const View = VIEWS[variant] ?? NowNextLater;
return <View {...rest} variant={variant} t={t} />;
}Häufige Fragen
Wie installiere ich Roadmap?
Mit aiw add @ai-website/roadmap. Der Befehl legt die Datei components/ui/roadmap.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion, button, icon und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Roadmap?
4 Varianten, Jetzt, als Nächstes, später, Über Quartale, Ideen-Board und Meilensteine. Du wählst sie mit der Prop variant, zum Beispiel variant="quarters".
Kann ich die Texte von Roadmap übersetzen?
Ja. Roadmap bringt 31 eigene Texte auf Deutsch und Englisch mit, etwa „Jetzt“, „Als Nächstes“ und „Später“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Roadmap einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Roadmap mit aiw_components und installieren sie mit aiw_add @ai-website/roadmap. Nenn im Prompt die Variante, etwa variant="now-next-later", dann schreibt der Assistent deine Inhalte direkt in die Props.