Save progress
Long forms that need not be finished in one go. Autosave in the browser with a quiet chip, a link by email to carry on anywhere, started forms as drafts, and a welcome back where the visitor stopped.
One of 3 components in Form states
Variants
Details
What Save progress brings along, read from the registry and the source.
- Category
- Form states
- Area
- Content
- Variants
- 4
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/save-progress.tsx
- npm packages
- None
- Languages
- English and German, 38 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SaveProgressProps interface from components/ui/save-progress.tsx, with the comments from the code.
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
Built-in texts
Save progress writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 38 texts
| Key | English | German |
|---|---|---|
| saving | English Saving | German Wird gespeichert |
| savedNow | English Saved just now | German Gerade gespeichert |
| savedAgo | English Saved {n} s ago | German Vor {n} s gespeichert |
| savedMin | English Saved {n} min ago | German Vor {n} Min. gespeichert |
| localNote | English Kept in this browser until you send it. Closing the tab loses nothing. | German Bleibt in diesem Browser, bis du sendest. Wenn du den Tab schließt, geht nichts verloren. |
| localShort | English Saved in this browser | German In diesem Browser gespeichert |
| sentTitle | English Sent, and the draft is cleared | German Gesendet, der Entwurf ist gelöscht |
| restored | English Welcome back, we restored what you wrote. | German Willkommen zurück, wir haben deinen Text wiederhergestellt. |
| clear | English Clear the draft | German Entwurf leeren |
| send | English Send | German Senden |
| later | English Save and continue later | German Speichern und später weitermachen |
| laterText | English We send you a link. It opens this form with everything you entered, on any device. | German Wir schicken dir einen Link. Er öffnet dieses Formular mit allem, was du eingegeben hast, auf jedem Gerät. |
| English Email for the link | German E-Mail für den Link | |
| emailNeeded | English Please enter an address like name@example.com, so the link reaches you. | German Bitte gib eine Adresse wie name@beispiel.de ein, damit der Link dich erreicht. |
| sendLink | English Send me the link | German Link zuschicken |
| linkSent | English The link is on its way to {e} | German Der Link ist unterwegs an {e} |
| linkText | English It works for {d} days. You can also copy it now. | German Er gilt {d} Tage. Du kannst ihn auch jetzt kopieren. |
| copy | English Copy link | German Link kopieren |
| copied | English Copied | German Kopiert |
| kept | English What we keep | German Was wir aufbewahren |
| keptText | English {n} of {t} fields filled | German {n} von {t} Feldern ausgefüllt |
| continue | English Continue | German Weitermachen |
| remove | English Remove {t} | German {t} entfernen |
| undo | English Undo | German Rückgängig |
| removed | English {t} removed | German {t} entfernt |
| edited | English Edited {e} | German Bearbeitet {e} |
| done | English {n} % done | German {n} % erledigt |
| empty | English No drafts. Everything is sent. | German Keine Entwürfe. Alles ist gesendet. |
| welcome | English Welcome back | German Willkommen zurück |
| startedOn | English You started {t} on {d}. | German Du hast {t} am {d} begonnen. |
| progress | English {n} of {t} steps done | German {n} von {t} Schritten erledigt |
| nextUp | English Next up · {s} | German Als Nächstes · {s} |
| resume | English Continue where I stopped | German Dort weitermachen, wo ich war |
| fresh | English Start over | German Neu beginnen |
| sure | English Start over? Your answers so far will be removed. | German Neu beginnen? Deine bisherigen Antworten werden entfernt. |
| yesFresh | English Yes, start over | German Ja, neu beginnen |
| keep | English Keep my answers | German Antworten behalten |
| cleared | English Started over. The form is empty. | German Neu begonnen. Das Formular ist leer. |
Accessibility
Read from the source of the component.
- Sets aria-expanded, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the role status.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- 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. 2 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, 530 lines
'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>
);
}Frequent questions
How do I install Save progress?
With aiw add @ai-website/save-progress. The command puts components/ui/save-progress.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow, input and textarea. It needs no further npm packages.
Which variants does Save progress have?
4 variants, Autosave, Continue later by link, Drafts and Welcome back. You pick one with the variant prop, for example variant="resume-link".
Can I translate the texts of Save progress?
Yes. Save progress carries 38 texts of its own in English and German, such as “Saving”, “Saved just now” and “Kept in this browser until you send it. Closing the tab loses nothing.”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Save progress?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Save progress with aiw_components and install it with aiw_add @ai-website/save-progress. Name the variant in your prompt, for example variant="autosave", and the assistant writes your content straight into the props.
Can I adjust Save progress without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching SaveProgress code to copy.