Public roadmap
A public roadmap in four layouts: now, next and later with votes, bars across quarters with a line for today, an idea board with a suggestion form and milestones on a line that fills.
Show where the product is going
A public roadmap shows customers what is coming and invites them to shape it. The four versions here go from a simple now, next, later to a plan across quarters, an idea board with votes and milestones that fill while you scroll.
Now, next, later has three columns with counts, cards with area, progress and a vote button that counts, filtered by area at the top. Across quarters lays bars over the quarters grouped by area with a line for today and a legend for the states, on phones the plan scrolls sideways with the area column fixed. Idea board sorts ideas by votes with status tabs, large vote buttons and a form to suggest something new that says what is missing and thanks you when sent. Milestones alternate on a vertical line that fills while you scroll up to the one in progress, shipped ones are ticked and the current one glows.
Promise directions rather than dates when plans may change, now, next and later is honest by design. Close the loop on votes, mark ideas as shipped and link the changelog. Keep cards short, one sentence on what changes for the customer.
Variants
Details
What Roadmap brings along, read from the registry and the source.
- Category
- Changelog and roadmap
- Area
- Marketing
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/roadmap.tsx
- npm packages
- None
- Languages
- English and German, 31 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RoadmapProps interface from components/ui/roadmap.tsx, with the comments from the code.
variantoptionalRoadmapVariantnow-next-laterquartersvote-boardmilestones
eyebrowoptionalReact.ReactNodetitlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodeitemsrequiredRoadmapItem[]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 }
Built-in texts
Roadmap writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 31 texts
| Key | English | German |
|---|---|---|
| now | English Now | German Jetzt |
| next | English Next | German Als Nächstes |
| later | English Later | German Später |
| done | English Shipped | German Erschienen |
| considering | English Considering | German In Prüfung |
| planned | English Planned | German Geplant |
| progress | English In progress | German In Arbeit |
| nowHint | English Being built this month | German Wird diesen Monat gebaut |
| nextHint | English Starting in the coming weeks | German Startet in den nächsten Wochen |
| laterHint | English On our list, open to change | German Auf unserer Liste, offen für Änderungen |
| all | English All areas | German Alle Bereiche |
| area | English Filter by area | German Nach Bereich filtern |
| vote | English Vote for {t} | German Für {t} stimmen |
| voted | English You voted for {t} | German Du hast für {t} gestimmt |
| votes | English {n} votes | German {n} Stimmen |
| today | English Today | German Heute |
| status | English Status | German Status |
| top | English Most wanted | German Meistgewünscht |
| suggest | English Suggest an idea | German Idee vorschlagen |
| ideaTitle | English Your idea in one line | German Deine Idee in einer Zeile |
| ideaTitlePh | English Export reports as a spreadsheet | German Berichte als Tabelle exportieren |
| ideaDetail | English What would it help you with? | German Wobei würde es dir helfen? |
| ideaDetailHint | English A sentence or two, a real example helps most. | German Ein, zwei Sätze, ein echtes Beispiel hilft am meisten. |
| send | English Send idea | German Idee senden |
| sending | English Sending | German Wird gesendet |
| tooShort | English Please describe the idea in at least a few words. | German Bitte beschreib die Idee mit mindestens ein paar Worten. |
| failed | English That did not go through. Your text is still here, please try again. | German Das hat nicht geklappt. Dein Text ist noch da, bitte versuch es noch einmal. |
| thanks | English Thank you. Your idea is on the board, others can vote for it from today. | German Danke. Deine Idee steht auf dem Board, ab heute können andere dafür stimmen. |
| empty | English Nothing here right now. | German Gerade nichts hier. |
| complete | English {n} done | German {n} fertig |
| sideways | English Swipe to see the later quarters | German Wischen für spätere Quartale |
Accessibility
Read from the source of the component.
- Roadmap handles the keys Arrow left, Arrow right and Space itself.
- Sets aria-controls, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles alert, group, status, tab, tablist and tabpanel.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 620 lines
'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} />;
}Frequent questions
How do I install Roadmap?
With aiw add @ai-website/roadmap. The command puts components/ui/roadmap.tsx into your project, the code is yours from then on. It brings along motion, button, icon and section. It needs no further npm packages.
Which variants does Roadmap have?
4 variants, Now, next, later, Across quarters, Idea board and Milestones. You pick one with the variant prop, for example variant="quarters".
Can I translate the texts of Roadmap?
Yes. Roadmap carries 31 texts of its own in English and German, such as “Now”, “Next” and “Later”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Roadmap?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Roadmap with aiw_components and install it with aiw_add @ai-website/roadmap. Name the variant in your prompt, for example variant="now-next-later", and the assistant writes your content straight into the props.