Zum Inhalt springen
UI Kit
Accent

Ö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.

4 VariantsEine von 2 Components in Changelog und Roadmap

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

Jetzt, als Nächstes, späterNeu in 1.2

Drei Spalten mit Zahlen, Karten mit Bereich, Fortschritt und einem Stimmknopf, der mitzählt. Oben nach Bereich filtern, die Spalten steigen nacheinander auf.

Über QuartaleNeu in 1.2

Balken über die Quartale nach Bereichen, eine Linie für heute und eine Legende der Zustände. Die Balken öffnen sich im Blick, auf dem Telefon scrollt der Plan seitlich, die Bereichsspalte bleibt stehen.

Ideen-BoardNeu in 1.2

Ideen nach Stimmen sortiert 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.

MeilensteineNeu in 1.2

Meilensteine wechseln sich an einer senkrechten Linie ab, die sich beim Scrollen bis zum laufenden füllt. Erschienene sind abgehakt, der aktuelle leuchtet, spätere warten.

Details

Was Roadmap mitbringt, aus Registry und Quellcode gelesen.

Bereich
Marketing
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/roadmap.tsx
Bringt mit
utilskitmotionbuttoniconsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 31 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle RoadmapProps aus components/ui/roadmap.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    RoadmapVariant

    now-next-laterquartersvote-boardmilestones

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • itemsPflicht
    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 }

Eingebaute Texte

Diese Texte schreibt Roadmap selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 31 Texte anzeigen
nowEnglisch NowDeutsch Jetzt
nextEnglisch NextDeutsch Als Nächstes
laterEnglisch LaterDeutsch Später
doneEnglisch ShippedDeutsch Erschienen
consideringEnglisch ConsideringDeutsch In Prüfung
plannedEnglisch PlannedDeutsch Geplant
progressEnglisch In progressDeutsch In Arbeit
nowHintEnglisch Being built this monthDeutsch Wird diesen Monat gebaut
nextHintEnglisch Starting in the coming weeksDeutsch Startet in den nächsten Wochen
laterHintEnglisch On our list, open to changeDeutsch Auf unserer Liste, offen für Änderungen
allEnglisch All areasDeutsch Alle Bereiche
areaEnglisch Filter by areaDeutsch Nach Bereich filtern
voteEnglisch Vote for {t}Deutsch Für {t} stimmen
votedEnglisch You voted for {t}Deutsch Du hast für {t} gestimmt
votesEnglisch {n} votesDeutsch {n} Stimmen
todayEnglisch TodayDeutsch Heute
statusEnglisch StatusDeutsch Status
topEnglisch Most wantedDeutsch Meistgewünscht
suggestEnglisch Suggest an ideaDeutsch Idee vorschlagen
ideaTitleEnglisch Your idea in one lineDeutsch Deine Idee in einer Zeile
ideaTitlePhEnglisch Export reports as a spreadsheetDeutsch Berichte als Tabelle exportieren
ideaDetailEnglisch What would it help you with?Deutsch Wobei würde es dir helfen?
ideaDetailHintEnglisch A sentence or two, a real example helps most.Deutsch Ein, zwei Sätze, ein echtes Beispiel hilft am meisten.
sendEnglisch Send ideaDeutsch Idee senden
sendingEnglisch SendingDeutsch Wird gesendet
tooShortEnglisch Please describe the idea in at least a few words.Deutsch Bitte beschreib die Idee mit mindestens ein paar Worten.
failedEnglisch 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.
thanksEnglisch 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.
emptyEnglisch Nothing here right now.Deutsch Gerade nichts hier.
completeEnglisch {n} doneDeutsch {n} fertig
sidewaysEnglisch Swipe to see the later quartersDeutsch 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
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} />;
}

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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert