Fortschritt speichern
Lange Formulare, die nicht in einem Zug fertig werden müssen. Automatisch im Browser gespeichert mit ruhigem Hinweis, ein Link per E-Mail zum Weitermachen überall, begonnene Formulare als Entwürfe und ein Willkommen zurück dort, wo man aufgehört hat.
Eine von 3 Components in Formularzustände
Variants
Details
Was Fortschritt speichern mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Formularzustände
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/save-progress.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 38 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SaveProgressProps aus components/ui/save-progress.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSaveProgressVariantautosaveresume-linkdraftsresume-banner
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodestorageKeyoptionalstringautosave: where the draft lives in the browser
fieldsoptional{ name: string; label: string; value?: string; long?: boolean }[]autosave and resume-link: the fields to show, as label and start value
daysoptionalnumberresume-link: days the link stays valid, and the link your server made
linkoptionalstringdraftsoptionalSaveDraft[]drafts
resumeoptional{ title: string; started: string; done: number; total: number; next: string }resume-banner: what was started and where it stands
onSaveoptional(data: Record<string, unknown>) => voidonContinueoptional(id?: string) => voidonRemoveoptional(id: string) => void
Eingebaute Texte
Diese Texte schreibt Fortschritt speichern selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 38 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| saving | Englisch Saving | Deutsch Wird gespeichert |
| savedNow | Englisch Saved just now | Deutsch Gerade gespeichert |
| savedAgo | Englisch Saved {n} s ago | Deutsch Vor {n} s gespeichert |
| savedMin | Englisch Saved {n} min ago | Deutsch Vor {n} Min. gespeichert |
| localNote | Englisch Kept in this browser until you send it. Closing the tab loses nothing. | Deutsch Bleibt in diesem Browser, bis du sendest. Wenn du den Tab schließt, geht nichts verloren. |
| localShort | Englisch Saved in this browser | Deutsch In diesem Browser gespeichert |
| sentTitle | Englisch Sent, and the draft is cleared | Deutsch Gesendet, der Entwurf ist gelöscht |
| restored | Englisch Welcome back, we restored what you wrote. | Deutsch Willkommen zurück, wir haben deinen Text wiederhergestellt. |
| clear | Englisch Clear the draft | Deutsch Entwurf leeren |
| send | Englisch Send | Deutsch Senden |
| later | Englisch Save and continue later | Deutsch Speichern und später weitermachen |
| laterText | Englisch We send you a link. It opens this form with everything you entered, on any device. | Deutsch Wir schicken dir einen Link. Er öffnet dieses Formular mit allem, was du eingegeben hast, auf jedem Gerät. |
| Englisch Email for the link | Deutsch E-Mail für den Link | |
| emailNeeded | Englisch Please enter an address like name@example.com, so the link reaches you. | Deutsch Bitte gib eine Adresse wie name@beispiel.de ein, damit der Link dich erreicht. |
| sendLink | Englisch Send me the link | Deutsch Link zuschicken |
| linkSent | Englisch The link is on its way to {e} | Deutsch Der Link ist unterwegs an {e} |
| linkText | Englisch It works for {d} days. You can also copy it now. | Deutsch Er gilt {d} Tage. Du kannst ihn auch jetzt kopieren. |
| copy | Englisch Copy link | Deutsch Link kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| kept | Englisch What we keep | Deutsch Was wir aufbewahren |
| keptText | Englisch {n} of {t} fields filled | Deutsch {n} von {t} Feldern ausgefüllt |
| continue | Englisch Continue | Deutsch Weitermachen |
| remove | Englisch Remove {t} | Deutsch {t} entfernen |
| undo | Englisch Undo | Deutsch Rückgängig |
| removed | Englisch {t} removed | Deutsch {t} entfernt |
| edited | Englisch Edited {e} | Deutsch Bearbeitet {e} |
| done | Englisch {n} % done | Deutsch {n} % erledigt |
| empty | Englisch No drafts. Everything is sent. | Deutsch Keine Entwürfe. Alles ist gesendet. |
| welcome | Englisch Welcome back | Deutsch Willkommen zurück |
| startedOn | Englisch You started {t} on {d}. | Deutsch Du hast {t} am {d} begonnen. |
| progress | Englisch {n} of {t} steps done | Deutsch {n} von {t} Schritten erledigt |
| nextUp | Englisch Next up · {s} | Deutsch Als Nächstes · {s} |
| resume | Englisch Continue where I stopped | Deutsch Dort weitermachen, wo ich war |
| fresh | Englisch Start over | Deutsch Neu beginnen |
| sure | Englisch Start over? Your answers so far will be removed. | Deutsch Neu beginnen? Deine bisherigen Antworten werden entfernt. |
| yesFresh | Englisch Yes, start over | Deutsch Ja, neu beginnen |
| keep | Englisch Keep my answers | Deutsch Antworten behalten |
| cleared | Englisch Started over. The form is empty. | Deutsch Neu begonnen. Das Formular ist leer. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-expanded, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- 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. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 530 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { DoneMark, FlowHead, isEmail, Unfold } from '@/components/ui/form-flow';
/*
* Long forms that do not have to be finished in one go.
*
* autosave every change is kept in the browser, a quiet chip says when it was saved
* resume-link save now and get a link by email to carry on, on any device
* drafts started forms as a list with their progress, continue or remove
* resume-banner a returning visitor is welcomed back where they stopped, or starts fresh
*
* autosave stores in localStorage under `storageKey`; nothing leaves the
* browser until the form is sent. The others hand their actions to you.
*/
export type SaveProgressVariant = 'autosave' | 'resume-link' | 'drafts' | 'resume-banner';
export interface SaveDraft {
id: string;
title: string;
/** 0 to 1 */
progress: number;
/** e.g. "Tuesday, 14:20" */
edited: string;
/** Step the draft stands at, e.g. "Step 3 of 5, Documents" */
step?: string;
}
export interface SaveProgressProps {
variant?: SaveProgressVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** autosave: where the draft lives in the browser */
storageKey?: string;
/** autosave and resume-link: the fields to show, as label and start value */
fields?: { name: string; label: string; value?: string; long?: boolean }[];
/** resume-link: days the link stays valid, and the link your server made */
days?: number;
link?: string;
/** drafts */
drafts?: SaveDraft[];
/** resume-banner: what was started and where it stands */
resume?: { title: string; started: string; done: number; total: number; next: string };
onSave?: (data: Record<string, unknown>) => void;
onContinue?: (id?: string) => void;
onRemove?: (id: string) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
saving: 'Saving',
savedNow: 'Saved just now',
savedAgo: 'Saved {n} s ago',
savedMin: 'Saved {n} min ago',
localNote: 'Kept in this browser until you send it. Closing the tab loses nothing.',
localShort: 'Saved in this browser',
sentTitle: 'Sent, and the draft is cleared',
restored: 'Welcome back, we restored what you wrote.',
clear: 'Clear the draft',
send: 'Send',
later: 'Save and continue later',
laterText: 'We send you a link. It opens this form with everything you entered, on any device.',
email: 'Email for the link',
emailNeeded: 'Please enter an address like name@example.com, so the link reaches you.',
sendLink: 'Send me the link',
linkSent: 'The link is on its way to {e}',
linkText: 'It works for {d} days. You can also copy it now.',
copy: 'Copy link',
copied: 'Copied',
kept: 'What we keep',
keptText: '{n} of {t} fields filled',
continue: 'Continue',
remove: 'Remove {t}',
undo: 'Undo',
removed: '{t} removed',
edited: 'Edited {e}',
done: '{n} % done',
empty: 'No drafts. Everything is sent.',
welcome: 'Welcome back',
startedOn: 'You started {t} on {d}.',
progress: '{n} of {t} steps done',
nextUp: 'Next up · {s}',
resume: 'Continue where I stopped',
fresh: 'Start over',
sure: 'Start over? Your answers so far will be removed.',
yesFresh: 'Yes, start over',
keep: 'Keep my answers',
cleared: 'Started over. The form is empty.',
},
de: {
saving: 'Wird gespeichert',
savedNow: 'Gerade gespeichert',
savedAgo: 'Vor {n} s gespeichert',
savedMin: 'Vor {n} Min. gespeichert',
localNote: 'Bleibt in diesem Browser, bis du sendest. Wenn du den Tab schließt, geht nichts verloren.',
localShort: 'In diesem Browser gespeichert',
sentTitle: 'Gesendet, der Entwurf ist gelöscht',
restored: 'Willkommen zurück, wir haben deinen Text wiederhergestellt.',
clear: 'Entwurf leeren',
send: 'Senden',
later: 'Speichern und später weitermachen',
laterText: 'Wir schicken dir einen Link. Er öffnet dieses Formular mit allem, was du eingegeben hast, auf jedem Gerät.',
email: 'E-Mail für den Link',
emailNeeded: 'Bitte gib eine Adresse wie name@beispiel.de ein, damit der Link dich erreicht.',
sendLink: 'Link zuschicken',
linkSent: 'Der Link ist unterwegs an {e}',
linkText: 'Er gilt {d} Tage. Du kannst ihn auch jetzt kopieren.',
copy: 'Link kopieren',
copied: 'Kopiert',
kept: 'Was wir aufbewahren',
keptText: '{n} von {t} Feldern ausgefüllt',
continue: 'Weitermachen',
remove: '{t} entfernen',
undo: 'Rückgängig',
removed: '{t} entfernt',
edited: 'Bearbeitet {e}',
done: '{n} % erledigt',
empty: 'Keine Entwürfe. Alles ist gesendet.',
welcome: 'Willkommen zurück',
startedOn: 'Du hast {t} am {d} begonnen.',
progress: '{n} von {t} Schritten erledigt',
nextUp: 'Als Nächstes · {s}',
resume: 'Dort weitermachen, wo ich war',
fresh: 'Neu beginnen',
sure: 'Neu beginnen? Deine bisherigen Antworten werden entfernt.',
yesFresh: 'Ja, neu beginnen',
keep: 'Antworten behalten',
cleared: 'Neu begonnen. Das Formular ist leer.',
},
};
type Texts = (typeof TEXTS)['en'];
const read = (key: string) => {
try {
return JSON.parse(localStorage.getItem(key) ?? 'null') as Record<string, string> | null;
} catch {
return null;
}
};
const write = (key: string, value: unknown) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* private mode or full, the form still works */
}
};
/* ------------------------------------------------------------------ */
/* autosave */
/* ------------------------------------------------------------------ */
function Autosave({ p, t }: { p: SaveProgressProps; t: Texts }) {
const id = useId();
const key = p.storageKey ?? 'kit-draft';
const fields = p.fields ?? [];
const start = Object.fromEntries(fields.map((f) => [f.name, f.value ?? '']));
const [v, setV] = useState<Record<string, string>>(start);
const [state, setState] = useState<'idle' | 'saving' | 'saved'>('idle');
const [savedAt, setSavedAt] = useState(0);
const [now, setNow] = useState(0);
const [restored, setRestored] = useState(false);
const [sent, setSent] = useState(false);
const sentRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (sent) sentRef.current?.focus({ preventScroll: true });
}, [sent]);
const first = useRef(true);
// Restore once after mount, never during render (the server knows no browser storage)
useEffect(() => {
const saved = read(key);
if (saved && Object.values(saved).some(Boolean)) {
setV((x) => ({ ...x, ...saved }));
setRestored(true);
}
}, [key]);
// A short pause after typing, then save
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
setState('saving');
const timer = window.setTimeout(() => {
write(key, v);
setState('saved');
setSavedAt(Date.now());
setNow(Date.now());
}, 700);
return () => window.clearTimeout(timer);
}, [v, key]);
useEffect(() => {
if (state !== 'saved') return;
const timer = window.setInterval(() => setNow(Date.now()), 5000);
return () => window.clearInterval(timer);
}, [state]);
const ago = Math.round((now - savedAt) / 1000);
const label = state === 'saving' ? t.saving : state === 'saved' ? (ago < 5 ? t.savedNow : ago < 60 ? t.savedAgo.replace('{n}', String(ago)) : t.savedMin.replace('{n}', String(Math.round(ago / 60)))) : '';
if (sent)
return (
<div className="kit-card mx-auto flex max-w-[44rem] flex-col items-center p-8 text-center" role="status">
<DoneMark size={56} />
<p ref={sentRef} tabIndex={-1} className="mt-4 text-[18px] font-semibold text-foreground outline-none">{t.sentTitle}</p>
</div>
);
return (
<Reveal effect="rise" className="mx-auto max-w-[44rem]">
<form
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
p.onSave?.(v);
try {
localStorage.removeItem(key);
} catch {
/* nothing stored */
}
setSent(true);
}}
>
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3 sm:px-6">
<p className="flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name="hard-drive" size={15} />
<span className={cn(state !== 'idle' && 'max-sm:hidden')}>{t.localShort}</span>
</p>
{/* The save chip: a spinner while saving, a check that draws itself when done */}
<span aria-live="polite" className={cn('inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-[12.5px] font-medium transition-[background-color,color,opacity] duration-300', state === 'idle' ? 'opacity-0' : 'opacity-100', state === 'saved' ? 'bg-[color-mix(in_oklab,var(--success)_12%,var(--card))] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground')}>
{state === 'saving' ? <Icon name="loader" size={13} className="animate-spin motion-reduce:animate-none" /> : <Icon name="check" size={13} strokeWidth={2.4} />}
{label}
</span>
</div>
<div className="grid gap-5 p-6 sm:p-8">
{restored && (
<p role="status" className="kit-rise flex flex-wrap items-center justify-between gap-3 rounded-xl bg-surface px-4 py-3 text-[13.5px] text-foreground">
<span className="flex items-center gap-2">
<Icon name="history" size={16} className="text-muted-foreground" />
{t.restored}
</span>
<button type="button" onClick={() => (setV(Object.fromEntries(fields.map((f) => [f.name, '']))), setRestored(false))} className="min-h-9 text-[13px] font-medium text-muted-foreground underline-offset-4 max-sm:min-h-11 hover:text-foreground hover:underline focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
{t.clear}
</button>
</p>
)}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{fields.map((f) =>
f.long ? (
<Textarea key={f.name} id={`${id}-${f.name}`} label={f.label} value={v[f.name] ?? ''} onChange={(e) => setV((x) => ({ ...x, [f.name]: e.target.value }))} minRows={5} className="sm:col-span-2" />
) : (
<Input key={f.name} id={`${id}-${f.name}`} label={f.label} value={v[f.name] ?? ''} onChange={(e) => setV((x) => ({ ...x, [f.name]: e.target.value }))} />
),
)}
</div>
<div className="flex flex-col gap-3 border-t border-border pt-5 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[12.5px] text-muted-foreground sm:max-w-[24rem]">{t.localNote}</p>
<Button type="submit" icon="send" className="max-sm:w-full">
{t.send}
</Button>
</div>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* resume-link */
/* ------------------------------------------------------------------ */
function ResumeLink({ p, t }: { p: SaveProgressProps; t: Texts }) {
const id = useId();
const fields = p.fields ?? [];
const filled = fields.filter((f) => f.value).length;
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [sent, setSent] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const link = p.link ?? '';
return (
<div className="mx-auto grid max-w-[56rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)] lg:gap-8">
{/* What is kept: the filled fields as a short list with a ring of progress */}
<Reveal effect="rise" className="rounded-2xl border border-border bg-surface p-6 sm:p-8">
<div className="flex items-center gap-4">
<svg viewBox="0 0 44 44" width={48} height={48} aria-hidden className="shrink-0 -rotate-90">
<circle cx="22" cy="22" r="18" fill="none" stroke="color-mix(in oklab, var(--foreground) 12%, transparent)" strokeWidth="4" />
<circle cx="22" cy="22" r="18" fill="none" stroke="var(--primary)" strokeWidth="4" strokeLinecap="round" pathLength={1} strokeDasharray="1 1" strokeDashoffset={round(1 - filled / Math.max(1, fields.length), 3)} />
</svg>
<div>
<p className="text-[15px] font-semibold text-foreground">{t.kept}</p>
<p className="text-[13px] tabular-nums text-muted-foreground">{t.keptText.replace('{n}', String(filled)).replace('{t}', String(fields.length))}</p>
</div>
</div>
<Stagger step={60} as="ul" className="mt-6 grid gap-2.5">
{fields.map((f) => (
<li key={f.name} className="flex items-start gap-3 text-[14px]">
<span className={cn('mt-0.5 grid size-5 shrink-0 place-items-center rounded-full', f.value ? 'bg-success text-background' : 'border border-border-strong')}>{f.value && <Icon name="check" size={11} strokeWidth={3} />}</span>
<span className="min-w-0">
<span className={cn('block', f.value ? 'text-foreground' : 'text-muted-foreground')}>{f.label}</span>
{f.value && <span className="block truncate text-[13px] text-muted-foreground">{f.value}</span>}
</span>
</li>
))}
</Stagger>
</Reveal>
<Reveal effect="rise" delay={100} className="kit-card p-6 sm:p-8">
{sent ? (
<div role="status" className="kit-rise">
<DoneMark size={48} />
<p className="mt-4 text-[17px] font-semibold text-foreground">{t.linkSent.replace('{e}', sent)}</p>
<p className="mt-1 text-[14px] text-muted-foreground">{t.linkText.replace('{d}', String(p.days ?? 30))}</p>
{link && <div className="mt-5 flex items-center gap-2 rounded-xl border border-border bg-surface py-1.5 pe-1.5 ps-3.5">
<span className="min-w-0 flex-1 truncate text-[13.5px] tabular-nums text-foreground">{link}</span>
<Button
variant="outline"
size="sm"
iconLeft={copied ? 'check' : 'copy'}
onClick={() => {
navigator.clipboard?.writeText(link).catch(() => {});
setCopied(true);
}}
>
{copied ? t.copied : t.copy}
</Button>
</div>}
</div>
) : (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
if (!isEmail(email)) {
setError(t.emailNeeded);
document.getElementById(`${id}-e`)?.focus();
return;
}
p.onSave?.({ email });
setSent(email);
}}
>
<span className="grid size-11 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
<Icon name="bookmark" size={20} />
</span>
<p className="mt-4 text-[18px] font-semibold text-foreground">{t.later}</p>
<p className="mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{t.laterText}</p>
<Input id={`${id}-e`} type="email" label={t.email} autoComplete="email" value={email} onChange={(e) => (setEmail(e.target.value), setError(''))} error={error || undefined} className="mt-5" />
<Button type="submit" icon="send" className="mt-5 w-full">
{t.sendLink}
</Button>
</form>
)}
</Reveal>
</div>
);
}
/* ------------------------------------------------------------------ */
/* drafts */
/* ------------------------------------------------------------------ */
function Drafts({ p, t }: { p: SaveProgressProps; t: Texts }) {
const locale = useLocale();
const [list, setList] = useState(p.drafts ?? []);
const [removed, setRemoved] = useState<{ d: SaveDraft; i: number } | null>(null);
const pct = (n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 0 }).format(n * 100);
return (
<div className="mx-auto max-w-[46rem]">
{list.length === 0 && !removed ? (
<p className="kit-card p-8 text-center text-[14.5px] text-muted-foreground">{t.empty}</p>
) : (
<Stagger step={80} as="ul" className="grid gap-3">
{list.map((d) => (
<li key={d.id} className="kit-card group/d flex flex-col gap-4 p-5 transition-[box-shadow,transform] duration-300 hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0 sm:flex-row sm:items-center sm:p-6">
{/* Progress as a ring with the percent inside */}
<span className="relative grid size-14 shrink-0 place-items-center max-sm:hidden">
<svg viewBox="0 0 44 44" width={56} height={56} aria-hidden className="absolute -rotate-90">
<circle cx="22" cy="22" r="19" fill="none" stroke="color-mix(in oklab, var(--foreground) 10%, transparent)" strokeWidth="3" />
<circle cx="22" cy="22" r="19" fill="none" stroke="var(--primary)" strokeWidth="3" strokeLinecap="round" pathLength={1} strokeDasharray="1 1" strokeDashoffset={round(1 - d.progress, 3)} />
</svg>
<span className="relative text-[13px] font-semibold tabular-nums text-foreground">{pct(d.progress)}%</span>
</span>
<span className="min-w-0 flex-1">
<span className="block text-[15.5px] font-semibold text-foreground">{d.title}</span>
{d.step && <span className="mt-0.5 block text-[13.5px] text-muted-foreground">{d.step}</span>}
<span className="mt-2 block text-[12.5px] text-subtle-foreground">
{t.edited.replace('{e}', d.edited)}
<span className="sm:hidden"> · {t.done.replace('{n}', pct(d.progress))}</span>
</span>
{/* Phones: a bar instead of the ring */}
<span aria-hidden className="mt-3 block h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)] sm:hidden">
<span className="block h-full origin-left rounded-full bg-primary" style={{ transform: `scaleX(${round(d.progress, 3)})` }} />
</span>
</span>
<span className="flex shrink-0 items-center gap-2">
<Button size="sm" icon="arrow-right" onClick={() => p.onContinue?.(d.id)} className="max-sm:flex-1">
{t.continue}
</Button>
<button
type="button"
aria-label={t.remove.replace('{t}', d.title)}
onClick={() => {
setRemoved({ d, i: list.indexOf(d) });
setList((x) => x.filter((y) => y.id !== d.id));
p.onRemove?.(d.id);
}}
className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none sm:size-9"
>
<Icon name="trash" size={16} />
</button>
</span>
</li>
))}
</Stagger>
)}
{/* Removing is undoable for as long as the note stands */}
{removed && (
<p role="status" className="kit-rise mt-4 flex items-center justify-between gap-3 rounded-xl border border-border bg-surface px-4 py-2.5 text-[13.5px] text-foreground">
{t.removed.replace('{t}', removed.d.title)}
<Button
variant="ghost"
size="sm"
iconLeft="undo"
onClick={() => {
setList((x) => [...x.slice(0, removed.i), removed.d, ...x.slice(removed.i)]);
setRemoved(null);
}}
>
{t.undo}
</Button>
</p>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* resume-banner */
/* ------------------------------------------------------------------ */
function ResumeBanner({ p, t }: { p: SaveProgressProps; t: Texts }) {
const r = p.resume ?? { title: '', started: '', done: 0, total: 1, next: '' };
const [confirm, setConfirm] = useState(false);
const [cleared, setCleared] = useState(false);
const share = r.done / Math.max(1, r.total);
if (cleared)
return (
<p role="status" className="kit-card kit-rise mx-auto flex max-w-[46rem] items-center gap-3 p-5 text-[14.5px] text-foreground">
<Icon name="rotate-ccw" size={18} className="text-muted-foreground" />
{t.cleared}
</p>
);
return (
<Reveal effect="scale" className="mx-auto max-w-[46rem]">
<div className="relative overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
<div aria-hidden className="kit-glow pointer-events-none absolute inset-0 opacity-70" />
<div className="relative p-6 sm:p-8">
<p className="text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{t.welcome}</p>
<p className="mt-2 text-balance text-[21px] font-semibold leading-snug tracking-[-0.02em] text-foreground sm:text-[24px]">{t.startedOn.replace('{t}', r.title).replace('{d}', r.started)}</p>
{/* Steps as segments, the done ones filled, the next one breathing */}
<div className="mt-6 flex gap-1.5" aria-hidden>
{Array.from({ length: r.total }, (_, i) => (
<span key={i} className={cn('h-1.5 flex-1 rounded-full', i < r.done ? 'bg-primary' : i === r.done ? 'bg-[color-mix(in_oklab,var(--primary)_35%,transparent)] motion-safe:animate-pulse' : 'bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]')} />
))}
</div>
<p className="mt-3 flex flex-wrap justify-between gap-x-4 gap-y-1 text-[13.5px] text-muted-foreground">
<span className="tabular-nums">{t.progress.replace('{n}', String(r.done)).replace('{t}', String(r.total))}</span>
<span>{t.nextUp.replace('{s}', r.next)}</span>
</p>
<span className="sr-only">{Math.round(share * 100)} %</span>
<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button icon="arrow-right" magnetic onClick={() => p.onContinue?.()} className="max-sm:w-full">
{t.resume}
</Button>
<Button variant="ghost" iconLeft="rotate-ccw" aria-expanded={confirm} onClick={() => setConfirm((c) => !c)} className="max-sm:w-full">
{t.fresh}
</Button>
</div>
{/* Starting over asks once, with a soft height */}
<Unfold open={confirm}>
<div className="mt-5 flex flex-col gap-3 rounded-xl border border-[color-mix(in_oklab,var(--destructive)_35%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_5%,var(--card))] p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[14px] text-foreground">{t.sure}</p>
<div className="flex shrink-0 gap-2">
<Button variant="ghost" size="sm" onClick={() => setConfirm(false)}>
{t.keep}
</Button>
<Button variant="destructive" size="sm" onClick={() => setCleared(true)}>
{t.yesFresh}
</Button>
</div>
</div>
</Unfold>
</div>
</div>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* Long forms that need not be finished in one go.
* <SaveProgress variant="autosave" storageKey="application-draft" fields={[{ name, label, long }]} />
*/
export function SaveProgress(props: SaveProgressProps) {
const { variant = 'autosave', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
{variant === 'autosave' && <Autosave p={props} t={t} />}
{variant === 'resume-link' && <ResumeLink p={props} t={t} />}
{variant === 'drafts' && <Drafts p={props} t={t} />}
{variant === 'resume-banner' && <ResumeBanner p={props} t={t} />}
</Section>
);
}Häufige Fragen
Wie installiere ich Fortschritt speichern?
Mit aiw add @ai-website/save-progress. Der Befehl legt die Datei components/ui/save-progress.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow, input und textarea. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Fortschritt speichern?
4 Varianten, Automatisch speichern, Später per Link weitermachen, Entwürfe und Willkommen zurück. Du wählst sie mit der Prop variant, zum Beispiel variant="resume-link".
Kann ich die Texte von Fortschritt speichern übersetzen?
Ja. Fortschritt speichern bringt 38 eigene Texte auf Deutsch und Englisch mit, etwa „Wird gespeichert“, „Gerade gespeichert“ und „Bleibt in diesem Browser, bis du sendest. Wenn du den Tab schließt, geht nichts verloren.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Fortschritt speichern einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Fortschritt speichern mit aiw_components und installieren sie mit aiw_add @ai-website/save-progress. Nenn im Prompt die Variante, etwa variant="autosave", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Fortschritt speichern ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SaveProgress-Code zum Kopieren.