Skip to content
UI Kit
Accent color

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.

4 variantsOne of 2 components in Changelog and roadmap

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

Now, next, laterNew in 1.2

Three columns with counts, cards with area, progress and a vote button that counts. Filter by area at the top, the columns rise in one after another.

Across quartersNew in 1.2

Bars across the quarters grouped by area, a line for today and a legend for the states. The bars draw open when in view, on phones the plan scrolls sideways with the area column fixed.

Idea boardNew in 1.2

Ideas sorted 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.

MilestonesNew in 1.2

Milestones alternate on a vertical line that fills while you scroll, up to the one in progress. Shipped ones are ticked, the current one glows, later ones wait.

Details

What Roadmap brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/roadmap.tsx
Brings along
utilskitmotionbuttoniconsection
npm packages
None
Languages
English and German, 31 texts of its own
Studio
Adjust in code

Props

The RoadmapProps interface from components/ui/roadmap.tsx, with the comments from the code.

  • variantoptional
    RoadmapVariant

    now-next-laterquartersvote-boardmilestones

  • eyebrowoptional
    React.ReactNode
  • titlerequired
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • itemsrequired
    RoadmapItem[]
  • quartersoptional
    string[]

    quarters: column labels, e.g. Q4 2026

  • todayoptional
    number

    quarters: where today is, in quarters from the start (1.4 = 40 % into the second)

  • onVoteoptional
    (item: RoadmapItem, voted: boolean) => void

    Called 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
nowEnglish NowGerman Jetzt
nextEnglish NextGerman Als Nächstes
laterEnglish LaterGerman Später
doneEnglish ShippedGerman Erschienen
consideringEnglish ConsideringGerman In Prüfung
plannedEnglish PlannedGerman Geplant
progressEnglish In progressGerman In Arbeit
nowHintEnglish Being built this monthGerman Wird diesen Monat gebaut
nextHintEnglish Starting in the coming weeksGerman Startet in den nächsten Wochen
laterHintEnglish On our list, open to changeGerman Auf unserer Liste, offen für Änderungen
allEnglish All areasGerman Alle Bereiche
areaEnglish Filter by areaGerman Nach Bereich filtern
voteEnglish Vote for {t}German Für {t} stimmen
votedEnglish You voted for {t}German Du hast für {t} gestimmt
votesEnglish {n} votesGerman {n} Stimmen
todayEnglish TodayGerman Heute
statusEnglish StatusGerman Status
topEnglish Most wantedGerman Meistgewünscht
suggestEnglish Suggest an ideaGerman Idee vorschlagen
ideaTitleEnglish Your idea in one lineGerman Deine Idee in einer Zeile
ideaTitlePhEnglish Export reports as a spreadsheetGerman Berichte als Tabelle exportieren
ideaDetailEnglish What would it help you with?German Wobei würde es dir helfen?
ideaDetailHintEnglish A sentence or two, a real example helps most.German Ein, zwei Sätze, ein echtes Beispiel hilft am meisten.
sendEnglish Send ideaGerman Idee senden
sendingEnglish SendingGerman Wird gesendet
tooShortEnglish Please describe the idea in at least a few words.German Bitte beschreib die Idee mit mindestens ein paar Worten.
failedEnglish 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.
thanksEnglish 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.
emptyEnglish Nothing here right now.German Gerade nichts hier.
completeEnglish {n} doneGerman {n} fertig
sidewaysEnglish Swipe to see the later quartersGerman 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
components/ui/roadmap.tsx
'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.

Often searched

Jump to

Copied to the clipboard