Survey
Surveys people like to finish. Each question type has its own calm layout, a counter at the bottom shows what is answered, and open questions are listed above the form with a link to each one.
One of 3 components in Surveys and feedback
Variants
Details
What Survey brings along, read from the registry and the source.
- Category
- Surveys and feedback
- Area
- Content
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/survey.tsx
- npm packages
- None
- Languages
- English and German, 23 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SurveyProps interface from components/ui/survey.tsx, with the comments from the code.
variantoptionalSurveyVariantscalematrixchoiceopenbranching
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodequestionsrequiredSurveyQuestion[]noteoptionalstringQuiet line next to the send button, e.g. "Takes three minutes"
submitLabeloptionalstringanonymousoptionalbooleanopen: offer to answer without name
doneoptional{ title: string; text?: string }onSubmitoptional(answers: Record<string, SurveyAnswer>, extra: { anonymous?: boolean; name?: string; email?: string }) => void
Built-in texts
Survey writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 23 texts
| Key | English | German |
|---|---|---|
| send | English Send answers | German Antworten senden |
| answered | English {n} of {total} answered | German {n} von {total} beantwortet |
| required | English Please answer this question, it helps us most. | German Bitte beantworte diese Frage, sie hilft uns am meisten. |
| matrix | English Please rate every line, {n} still open. | German Bitte bewerte jede Zeile, {n} sind noch offen. |
| other | English Other | German Etwas anderes |
| otherLabel | English Your answer | German Deine Antwort |
| otherNeeded | English Please tell us what you mean by other. | German Bitte schreib kurz, was du mit etwas anderem meinst. |
| pickUpTo | English Choose up to {n} | German Wähle bis zu {n} |
| picked | English {n} of {max} chosen | German {n} von {max} gewählt |
| starters | English Start with | German Beginne mit |
| anonymous | English Answer anonymously | German Anonym antworten |
| anonymousText | English We see your answers, never your name. | German Wir sehen deine Antworten, nie deinen Namen. |
| name | English Name | German Name |
| English Email | German E-Mail | |
| next | English Next question | German Nächste Frage |
| back | English Previous question | German Vorherige Frage |
| question | English Question {n} | German Frage {n} |
| path | English Your path | German Dein Weg |
| skipped | English Skipped for you | German Für dich übersprungen |
| doneTitle | English Thank you, your answers are in | German Danke, deine Antworten sind da |
| doneText | English Every answer is read. We share what we change in the next newsletter. | German Wir lesen jede Antwort. Was wir ändern, steht im nächsten Newsletter. |
| again | English Answer again | German Noch einmal antworten |
| aspect | English Aspect | German Bereich |
Accessibility
Read from the source of the component.
- Sets aria-describedby, aria-invalid, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
- Uses the role radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- 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. 5 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, 740 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, 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 { Switch } from '@/components/ui/switch';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { DonePanel, ErrorSummary, FlowHead, ScalePicker, useFocusFirstError, type FlowError } from '@/components/ui/form-flow';
/*
* Surveys people like to finish: every question type has its own calm
* layout, a counter shows what is answered, open problems are listed above
* the form and link to their question. Answers go to `onSubmit` as
* { [questionId]: string | string[] | Record<row, string> }.
*
* scale statements on a five point scale with words at both ends
* matrix several aspects rated in one table, rows of tiles on phones
* choice single and multiple choice, "other" opens its own field
* open open questions with starters to tap and an anonymous switch
* branching one question at a time, the answer decides the next one
*/
export type SurveyVariant = 'scale' | 'matrix' | 'choice' | 'open' | 'branching';
export interface SurveyQuestion {
id: string;
title: string;
hint?: string;
required?: boolean;
/** scale is the default */
type?: 'scale' | 'matrix' | 'single' | 'multi' | 'text';
/** single and multi: the choices */
options?: string[];
/** single and multi: an "other" choice with its own text field */
other?: boolean;
/** multi: most choices allowed */
max?: number;
/** matrix: the aspects in rows */
rows?: string[];
/** scale and matrix: the points from low to high, default 1 to 5 */
points?: string[];
/** scale: words at both ends */
low?: string;
high?: string;
/** text: starters to tap */
prompts?: string[];
maxLength?: number;
/** branching: the next question per answer, `*` for any other answer, `end` to finish */
next?: Record<string, string>;
}
export type SurveyAnswer = string | string[] | Record<string, string>;
export interface SurveyProps {
variant?: SurveyVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
questions: SurveyQuestion[];
/** Quiet line next to the send button, e.g. "Takes three minutes" */
note?: string;
submitLabel?: string;
/** open: offer to answer without name */
anonymous?: boolean;
done?: { title: string; text?: string };
onSubmit?: (answers: Record<string, SurveyAnswer>, extra: { anonymous?: boolean; name?: string; email?: string }) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
send: 'Send answers',
answered: '{n} of {total} answered',
required: 'Please answer this question, it helps us most.',
matrix: 'Please rate every line, {n} still open.',
other: 'Other',
otherLabel: 'Your answer',
otherNeeded: 'Please tell us what you mean by other.',
pickUpTo: 'Choose up to {n}',
picked: '{n} of {max} chosen',
starters: 'Start with',
anonymous: 'Answer anonymously',
anonymousText: 'We see your answers, never your name.',
name: 'Name',
email: 'Email',
next: 'Next question',
back: 'Previous question',
question: 'Question {n}',
path: 'Your path',
skipped: 'Skipped for you',
doneTitle: 'Thank you, your answers are in',
doneText: 'Every answer is read. We share what we change in the next newsletter.',
again: 'Answer again',
aspect: 'Aspect',
},
de: {
send: 'Antworten senden',
answered: '{n} von {total} beantwortet',
required: 'Bitte beantworte diese Frage, sie hilft uns am meisten.',
matrix: 'Bitte bewerte jede Zeile, {n} sind noch offen.',
other: 'Etwas anderes',
otherLabel: 'Deine Antwort',
otherNeeded: 'Bitte schreib kurz, was du mit etwas anderem meinst.',
pickUpTo: 'Wähle bis zu {n}',
picked: '{n} von {max} gewählt',
starters: 'Beginne mit',
anonymous: 'Anonym antworten',
anonymousText: 'Wir sehen deine Antworten, nie deinen Namen.',
name: 'Name',
email: 'E-Mail',
next: 'Nächste Frage',
back: 'Vorherige Frage',
question: 'Frage {n}',
path: 'Dein Weg',
skipped: 'Für dich übersprungen',
doneTitle: 'Danke, deine Antworten sind da',
doneText: 'Wir lesen jede Antwort. Was wir ändern, steht im nächsten Newsletter.',
again: 'Noch einmal antworten',
aspect: 'Bereich',
},
};
type Texts = (typeof TEXTS)['en'];
type Answers = Record<string, SurveyAnswer | undefined>;
const OTHER = '__other';
const pointsOf = (q: SurveyQuestion) => q.points ?? ['1', '2', '3', '4', '5'];
/* ------------------------------------------------------------------ */
/* Checking */
/* ------------------------------------------------------------------ */
function checkQuestion(q: SurveyQuestion, a: SurveyAnswer | undefined, otherText: string, t: Texts): string | null {
const type = q.type ?? 'scale';
if (type === 'matrix') {
const rows = q.rows ?? [];
const done = a && typeof a === 'object' && !Array.isArray(a) ? a : {};
const open = rows.filter((r) => !done[r]).length;
if (q.required && open) return t.matrix.replace('{n}', String(open));
return null;
}
const empty = a === undefined || (typeof a === 'string' ? !a.trim() : Array.isArray(a) ? a.length === 0 : false);
if (empty) return q.required ? t.required : null;
const picked = Array.isArray(a) ? a : [a];
if (picked.includes(OTHER) && !otherText.trim()) return t.otherNeeded;
return null;
}
/* ------------------------------------------------------------------ */
/* Question views */
/* ------------------------------------------------------------------ */
interface QProps {
q: SurveyQuestion;
index: number;
id: string;
value: SurveyAnswer | undefined;
set: (v: SurveyAnswer) => void;
other: string;
setOther: (v: string) => void;
error?: string;
t: Texts;
/** No card and no number, for the one-question view */
plain?: boolean;
}
function QuestionShell({ q, index, id, error, children, plain }: { q: SurveyQuestion; index: number; id: string; error?: string; children: React.ReactNode; plain?: boolean }) {
return (
<div id={`${id}-box`} className={cn('scroll-mt-24', !plain && 'kit-card p-5 transition-[border-color,box-shadow] duration-300 sm:p-7', error && !plain && 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]')}>
{!plain && <p className="text-[12px] font-medium tabular-nums text-subtle-foreground">{String(index + 1).padStart(2, '0')}</p>}
{children}
</div>
);
}
function ScaleQ({ q, index, id, value, set, error, plain }: QProps) {
const pts = pointsOf(q);
return (
<QuestionShell q={q} index={index} id={id} error={error} plain={plain}>
<ScalePicker
id={id}
className="mt-1.5"
legend={q.title}
required={q.required}
points={pts.map((p, i) => ({ value: String(i + 1), label: p, short: /^\d+$/.test(p) ? p : String(i + 1) }))}
value={typeof value === 'string' ? value : undefined}
onChange={set}
low={q.low}
high={q.high}
error={error}
/>
{q.hint && <p className="mt-3 text-[13px] text-muted-foreground">{q.hint}</p>}
</QuestionShell>
);
}
function MatrixQ({ q, index, id, value, set, error, t, plain }: QProps) {
const pts = pointsOf(q);
const rows = q.rows ?? [];
const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const pick = (row: string, p: string) => set({ ...v, [row]: p });
return (
<QuestionShell q={q} index={index} id={id} error={error} plain={plain}>
<h3 className="mt-1.5 text-[15px] font-medium leading-snug text-foreground">
{q.title}
{q.required && <span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">*</span>}
</h3>
{q.hint && <p className="mt-1 text-[13px] text-muted-foreground">{q.hint}</p>}
{/* Tablet and desktop: a table, columns light up under the pointer */}
<div className="mt-5 hidden sm:block">
<table className="w-full table-fixed border-separate border-spacing-0 text-[13px]">
<colgroup>
<col className="w-[32%]" />
{pts.map((p) => (
<col key={p} />
))}
</colgroup>
<thead>
<tr>
<th scope="col" className="pb-3 text-start font-medium text-subtle-foreground">
<span className="sr-only">{t.aspect}</span>
</th>
{pts.map((p) => (
<th key={p} scope="col" className="px-1 pb-3 text-center align-bottom text-[12px] font-medium leading-tight text-muted-foreground">
{p}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, ri) => {
const rid = `${id}-r${ri}`;
const missing = Boolean(error) && !v[r];
return (
<tr key={r} role="radiogroup" aria-labelledby={rid} aria-describedby={missing ? `${id}-err` : undefined} className="group/row">
<th id={rid} scope="row" className={cn('border-t border-border py-1 pe-4 text-start text-[14px] font-medium transition-colors group-hover/row:text-foreground', missing ? 'text-destructive' : 'text-foreground')}>
{r}
</th>
{pts.map((p, pi) => {
const on = v[r] === p;
const iid = ri === 0 && pi === 0 ? id : `${id}-${ri}-${pi}`;
return (
<td key={p} className="border-t border-border p-0 text-center transition-colors group-hover/row:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]">
<label htmlFor={iid} className="group/cell grid h-12 cursor-pointer place-items-center">
<input id={iid} type="radio" name={`${id}-${ri}`} value={p} checked={on} onChange={() => pick(r, p)} aria-label={`${r}, ${p}`} aria-invalid={missing || undefined} className="peer sr-only" />
<span
aria-hidden
className={cn(
'grid size-[22px] place-items-center rounded-full border transition-[border-color,background-color,box-shadow,scale] duration-200 ease-[cubic-bezier(0.34,1.6,0.64,1)] group-hover/cell:border-[color-mix(in_oklab,var(--foreground)_40%,var(--border-strong))] peer-focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_25%,transparent)]',
on ? 'scale-110 border-primary bg-primary' : missing ? 'border-[color-mix(in_oklab,var(--destructive)_60%,var(--border))] bg-background' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))] bg-background',
)}
>
<span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300', on ? 'scale-100' : 'scale-0')} />
</span>
</label>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Phone: one block per aspect with its own short scale */}
<div className="mt-5 grid gap-5 sm:hidden">
{rows.map((r, ri) => (
<ScalePicker
key={r}
id={`${id}-m${ri}`}
legend={r}
points={pts.map((p, pi) => ({ value: p, label: p, short: String(pi + 1) }))}
value={v[r]}
onChange={(p) => pick(r, p)}
low={ri === 0 ? pts[0] : undefined}
high={ri === 0 ? pts[pts.length - 1] : undefined}
invalid={Boolean(error) && !v[r]}
describedBy={error && !v[r] ? `${id}-err` : undefined}
className="[&_legend]:text-[14px]"
/>
))}
</div>
{error && (
<p id={`${id}-err`} className="kit-rise mt-4 flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
<span>{error}</span>
</p>
)}
</QuestionShell>
);
}
function ChoiceQ({ q, index, id, value, set, other, setOther, error, t, plain }: QProps) {
const multi = q.type === 'multi';
const picked = Array.isArray(value) ? value : typeof value === 'string' && value ? [value] : [];
const opts = [...(q.options ?? []), ...(q.other ? [OTHER] : [])];
const full = multi && q.max !== undefined && picked.length >= q.max;
const otherOn = picked.includes(OTHER);
const otherRef = useRef<HTMLInputElement>(null);
const wasOther = useRef(otherOn);
useEffect(() => {
if (otherOn && !wasOther.current) otherRef.current?.focus();
wasOther.current = otherOn;
}, [otherOn]);
const toggle = (o: string) => {
if (!multi) return set(o);
set(picked.includes(o) ? picked.filter((x) => x !== o) : [...picked, o]);
};
return (
<QuestionShell q={q} index={index} id={id} error={error} plain={plain}>
<fieldset aria-describedby={error ? `${id}-err` : q.hint || q.max ? `${id}-hint` : undefined} className="mt-1.5 min-w-0">
<legend className="text-[15px] font-medium leading-snug text-foreground">
{q.title}
{q.required && <span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">*</span>}
</legend>
{(q.hint || (multi && q.max)) && (
<p id={`${id}-hint`} className="mt-1 flex flex-wrap items-center justify-between gap-x-4 text-[13px] text-muted-foreground">
<span>{q.hint ?? t.pickUpTo.replace('{n}', String(q.max))}</span>
{multi && q.max !== undefined && (
<span aria-live="polite" className={cn('tabular-nums transition-colors', full ? 'font-medium text-foreground' : '')}>
{t.picked.replace('{n}', String(picked.length)).replace('{max}', String(q.max))}
</span>
)}
</p>
)}
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
{opts.map((o, i) => {
const on = picked.includes(o);
const oid = i === 0 ? id : `${id}-${i}`;
const disabled = full && !on;
const isOther = o === OTHER;
return (
<label
key={o}
htmlFor={oid}
className={cn(
'group/opt flex min-h-12 cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3.5 py-2.5 text-[14px] text-foreground transition-[border-color,background-color,box-shadow,opacity] duration-200 hover:border-border-strong',
'has-[:checked]:border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] has-[:checked]:bg-[color-mix(in_oklab,var(--primary)_7%,var(--background))]',
'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_25%,transparent)]',
disabled && 'cursor-not-allowed opacity-45 hover:border-border',
// A last option alone in its row takes the whole row
i === opts.length - 1 && opts.length % 2 === 1 && 'sm:col-span-2',
)}
>
<input id={oid} type={multi ? 'checkbox' : 'radio'} name={id} value={o} checked={on} disabled={disabled} aria-invalid={error ? true : undefined} onChange={() => toggle(o)} className="peer sr-only" />
<span aria-hidden className={cn('grid size-[18px] shrink-0 place-items-center border transition-[background-color,border-color] duration-200', multi ? 'rounded-[5px]' : 'rounded-full', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
{multi ? (
<svg viewBox="0 0 24 24" width={13} height={13} fill="none" className={cn('text-primary-foreground transition-opacity', on ? 'opacity-100' : 'opacity-0')}>
<path d="m5 12.5 4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<span className={cn('size-[7px] rounded-full bg-primary-foreground transition-[scale] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', on ? 'scale-100' : 'scale-0')} />
)}
</span>
<span className="min-w-0 flex-1 font-medium">{isOther ? t.other : o}</span>
</label>
);
})}
</div>
</fieldset>
{/* The field for "other" opens with a soft height */}
<div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', otherOn ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={!otherOn || undefined}>
<div className="relative min-h-0 overflow-hidden">
<Input ref={otherRef} id={`${id}-other`} label={t.otherLabel} value={other} onChange={(e) => setOther(e.target.value)} invalid={Boolean(error) && otherOn && !other.trim()} className="pt-4" />
</div>
</div>
{error && (
<p id={`${id}-err`} className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
<span>{error}</span>
</p>
)}
</QuestionShell>
);
}
function TextQ({ q, index, id, value, set, error, t, plain }: QProps) {
const text = typeof value === 'string' ? value : '';
const ref = useRef<HTMLTextAreaElement>(null);
const start = (p: string) => {
const next = text.trim() ? `${text.trimEnd()} ${p} ` : `${p} `;
set(next);
requestAnimationFrame(() => {
const el = ref.current;
if (!el) return;
el.focus();
el.setSelectionRange(next.length, next.length);
});
};
return (
<QuestionShell q={q} index={index} id={id} error={error} plain={plain}>
<Textarea ref={ref} id={id} label={q.title} required={q.required} hint={q.hint} error={error} value={text} onChange={(e) => set(e.target.value)} maxLength={q.maxLength} minRows={4} className="mt-1.5 [&_label]:text-[15px]" />
{q.prompts && q.prompts.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="me-1 text-[12.5px] text-muted-foreground">{t.starters}</span>
{q.prompts.map((p) => (
<button
key={p}
type="button"
onClick={() => start(p)}
className="inline-flex min-h-9 items-center gap-1.5 rounded-full border border-border bg-surface px-3 text-[13px] text-foreground transition-[border-color,background-color,translate] duration-200 hover:-translate-y-px hover:border-border-strong focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_25%,transparent)] focus-visible:outline-none active:translate-y-0 motion-reduce:hover:translate-y-0 max-sm:min-h-11"
>
<Icon name="plus" size={13} className="text-muted-foreground" />
{p}
</button>
))}
</div>
)}
</QuestionShell>
);
}
function QuestionView(p: QProps) {
const type = p.q.type ?? 'scale';
if (type === 'matrix') return <MatrixQ {...p} />;
if (type === 'single' || type === 'multi') return <ChoiceQ {...p} />;
if (type === 'text') return <TextQ {...p} />;
return <ScaleQ {...p} />;
}
/* ------------------------------------------------------------------ */
/* One page */
/* ------------------------------------------------------------------ */
function isAnswered(q: SurveyQuestion, a: SurveyAnswer | undefined) {
if (a === undefined) return false;
if ((q.type ?? 'scale') === 'matrix') return typeof a === 'object' && !Array.isArray(a) && (q.rows ?? []).every((r) => a[r]);
return typeof a === 'string' ? Boolean(a.trim()) : Array.isArray(a) ? a.length > 0 : false;
}
function Counter({ n, total, t }: { n: number; total: number; t: Texts }) {
const share = total ? n / total : 0;
return (
<div className="flex min-w-0 items-center gap-3">
<span aria-hidden className="hidden h-1.5 w-20 shrink-0 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)] sm:block sm:w-28">
<span className="block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${Math.round(share * 1000) / 1000})` }} />
</span>
{/* Phones: a small ring instead of the bar, so the send row stays one line */}
<svg viewBox="0 0 20 20" width={20} height={20} aria-hidden className="shrink-0 -rotate-90 sm:hidden">
<circle cx="10" cy="10" r="8" fill="none" stroke="color-mix(in oklab, var(--foreground) 12%, transparent)" strokeWidth="2.5" />
<circle cx="10" cy="10" r="8" fill="none" stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" pathLength={1} strokeDasharray="1 1" strokeDashoffset={Math.round((1 - share) * 1000) / 1000} className="transition-[stroke-dashoffset] duration-500 motion-reduce:transition-none" />
</svg>
<span aria-live="polite" className="text-[13px] tabular-nums text-muted-foreground">
<span aria-hidden className="sm:hidden">
{n}/{total}
</span>
<span className="max-sm:sr-only">{t.answered.replace('{n}', String(n)).replace('{total}', String(total))}</span>
</span>
</div>
);
}
function OnePage({ p, t, variant }: { p: SurveyProps; t: Texts; variant: SurveyVariant }) {
const uid = useId();
const [answers, setAnswers] = useState<Answers>({});
const [others, setOthers] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState(0);
const [done, setDone] = useState(false);
const [anon, setAnon] = useState(true);
const [who, setWho] = useState({ name: '', email: '' });
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, attempt, summaryRef);
const qid = (q: SurveyQuestion) => `${uid}-${q.id}`;
const answeredCount = p.questions.filter((q) => isAnswered(q, answers[q.id])).length;
const errorOf = (q: SurveyQuestion) => errors[q.id];
// The summary names the questions, the message itself stands at each question
const summary: FlowError[] = p.questions.filter((q) => errors[q.id]).map((q) => ({ field: q.type === 'matrix' ? `${qid(q)}-box` : qid(q), message: q.title }));
const set = (q: SurveyQuestion, v: SurveyAnswer) => {
setAnswers((a) => ({ ...a, [q.id]: v }));
if (errorOf(q)) {
const m = checkQuestion(q, v, others[q.id] ?? '', t);
setErrors((e) => {
const next = { ...e };
if (m) next[q.id] = m;
else delete next[q.id];
return next;
});
}
};
if (done)
return (
<div className="kit-card mx-auto max-w-[44rem] p-6 sm:p-10">
<DonePanel
title={p.done?.title ?? t.doneTitle}
text={p.done?.text ?? t.doneText}
actions={
<Button variant="outline" iconLeft="rotate-ccw" onClick={() => (setAnswers({}), setOthers({}), setDone(false))}>
{t.again}
</Button>
}
/>
</div>
);
return (
<form
ref={formRef}
noValidate
className="mx-auto grid max-w-[46rem] grid-cols-1 gap-4"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
for (const q of p.questions) {
const m = checkQuestion(q, answers[q.id], others[q.id] ?? '', t);
if (m) found[q.id] = m;
}
setErrors(found);
if (Object.keys(found).length) {
setAttempt((a) => a + 1);
return;
}
p.onSubmit?.(answers as Record<string, SurveyAnswer>, { anonymous: anon, ...(anon ? {} : who) });
setDone(true);
}}
>
<ErrorSummary ref={summaryRef} errors={summary} />
<Stagger step={90} as="div" className="grid grid-cols-1 gap-4">
{p.questions.map((q, i) => (
<QuestionView
key={q.id}
q={q}
index={i}
id={qid(q)}
value={answers[q.id]}
set={(v) => set(q, v)}
other={others[q.id] ?? ''}
setOther={(v) => setOthers((o) => ({ ...o, [q.id]: v }))}
error={errorOf(q)}
t={t}
/>
))}
</Stagger>
{variant === 'open' && p.anonymous !== false && (
<div className="kit-card p-5 sm:p-7">
<Switch label={t.anonymous} description={t.anonymousText} checked={anon} onCheckedChange={setAnon} />
<div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', anon ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]')} inert={anon || undefined}>
<div className="relative min-h-0 overflow-hidden">
<div className="grid grid-cols-1 gap-4 pt-5 sm:grid-cols-2">
<Input label={t.name} autoComplete="name" value={who.name} onChange={(e) => setWho((w) => ({ ...w, name: e.target.value }))} />
<Input label={t.email} type="email" autoComplete="email" value={who.email} onChange={(e) => setWho((w) => ({ ...w, email: e.target.value }))} />
</div>
</div>
</div>
</div>
)}
{/* The send row stays at the bottom of the screen while the questions pass by */}
<div className="sticky bottom-3 z-20 mt-2">
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--card)_88%,transparent)] p-2 ps-4 shadow-[var(--shadow-lg)] backdrop-blur-md sm:p-3 sm:ps-5">
<div className="flex min-w-0 items-center gap-4">
<Counter n={answeredCount} total={p.questions.length} t={t} />
{p.note && <span className="hidden text-[13px] text-muted-foreground lg:inline">{p.note}</span>}
</div>
<Button type="submit" icon="send">
{p.submitLabel ?? t.send}
</Button>
</div>
</div>
</form>
);
}
/* ------------------------------------------------------------------ */
/* Branching */
/* ------------------------------------------------------------------ */
function Branching({ p, t }: { p: SurveyProps; t: Texts }) {
const uid = useId();
const qs = p.questions;
const [path, setPath] = useState<string[]>([qs[0]?.id]);
const [answers, setAnswers] = useState<Answers>({});
const [others, setOthers] = useState<Record<string, string>>({});
const [error, setError] = useState<string | undefined>();
const [attempt, setAttempt] = useState(0);
const [done, setDone] = useState(false);
const [dir, setDir] = useState(1);
const cardRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLFormElement>(null);
useFocusFirstError(formRef, attempt);
const current = qs.find((q) => q.id === path[path.length - 1]) ?? qs[0];
const first = useRef(true);
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
const el = cardRef.current;
el?.querySelector<HTMLElement>('input, textarea')?.focus({ preventScroll: true });
if (!el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ opacity: 0, transform: `translateY(${dir * 14}px)` }, { opacity: 1, transform: 'translateY(0)' }], { duration: 420, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}, [path.length, done, dir]);
const nextId = (q: SurveyQuestion, a: SurveyAnswer | undefined) => {
const key = Array.isArray(a) ? a[0] : typeof a === 'string' ? a : '';
const by = q.next?.[key] ?? q.next?.['*'];
if (by) return by;
const i = qs.findIndex((x) => x.id === q.id);
return qs[i + 1]?.id ?? 'end';
};
const go = (a = answers[current.id]) => {
const m = checkQuestion(current, a, others[current.id] ?? '', t);
if (m) {
setError(m);
setAttempt((x) => x + 1);
return;
}
setError(undefined);
const n = nextId(current, a);
setDir(1);
if (n === 'end') {
p.onSubmit?.(Object.fromEntries(path.map((id) => [id, answers[id] ?? a])) as Record<string, SurveyAnswer>, {});
setDone(true);
} else setPath((x) => [...x, n]);
};
const back = () => {
setDir(-1);
setError(undefined);
setPath((x) => (x.length > 1 ? x.slice(0, -1) : x));
};
const skipped = qs.filter((q) => !path.includes(q.id) && qs.indexOf(q) < qs.indexOf(current)).length;
return (
<div className="mx-auto grid max-w-[64rem] grid-cols-1 gap-5 lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-10">
{/* The path so far: answered questions with their answers, a line that grows */}
{/* On phones and tablets the path follows the card and appears once there is a path to show */}
<Reveal effect="rise" className={cn('max-lg:order-2', path.length < 2 && !done && 'max-lg:hidden')}>
<nav aria-label={t.path} className="max-lg:rounded-xl max-lg:border max-lg:border-border max-lg:bg-surface max-lg:p-5 lg:sticky lg:top-24">
<p className="text-[13px] font-medium text-muted-foreground">{t.path}</p>
<ol className="relative mt-4 grid gap-0 border-s border-border ps-5">
{path.map((id, i) => {
const q = qs.find((x) => x.id === id);
if (!q) return null;
const a = answers[id];
const isCurrent = i === path.length - 1 && !done;
const text = Array.isArray(a) ? a.map((x) => (x === OTHER ? others[id] || t.other : x)).join(', ') : typeof a === 'string' ? (a === OTHER ? others[id] || t.other : a) : '';
return (
<li key={id} className="kit-rise relative pb-4 last:pb-0">
<span aria-hidden className={cn('absolute -start-[25px] top-1.5 size-[9px] rounded-full border-2 transition-colors', isCurrent ? 'border-primary bg-background shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_16%,transparent)]' : 'border-primary bg-primary')} />
<p className={cn('text-[13px] leading-snug', isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground')}>{q.title}</p>
{!isCurrent && text && <p className="mt-0.5 truncate text-[13px] font-medium text-foreground">{text}</p>}
</li>
);
})}
</ol>
{skipped > 0 && !done && (
<p className="mt-4 flex items-center gap-2 text-[12.5px] text-muted-foreground">
<Icon name="route" size={14} />
{t.skipped} · {skipped}
</p>
)}
</nav>
</Reveal>
<Reveal effect="scale" className="kit-card min-w-0 p-6 sm:p-10">
{done ? (
<DonePanel
title={p.done?.title ?? t.doneTitle}
text={p.done?.text ?? t.doneText}
actions={
<Button variant="outline" iconLeft="rotate-ccw" onClick={() => (setAnswers({}), setOthers({}), setPath([qs[0].id]), setDone(false))}>
{t.again}
</Button>
}
/>
) : (
<form
ref={formRef}
noValidate
onSubmit={(e) => {
e.preventDefault();
go();
}}
>
<p className="flex items-center justify-between gap-3 text-[13px] text-muted-foreground">
<span className="font-medium tabular-nums text-foreground">{t.question.replace('{n}', String(path.length))}</span>
</p>
<div ref={cardRef} className="mt-4 min-h-[11rem]">
{current && (
<QuestionView
q={current}
index={path.length - 1}
id={`${uid}-${current.id}`}
value={answers[current.id]}
set={(v) => {
setAnswers((a) => ({ ...a, [current.id]: v }));
setError(undefined);
}}
other={others[current.id] ?? ''}
setOther={(v) => setOthers((o) => ({ ...o, [current.id]: v }))}
error={error}
t={t}
plain
/>
)}
</div>
<div className="mt-8 flex flex-col-reverse gap-3 border-t border-border pt-6 sm:flex-row sm:items-center sm:justify-between">
{path.length > 1 ? (
<Button type="button" variant="ghost" iconLeft="arrow-left" onClick={back} className="-ms-5 max-sm:ms-0">
{t.back}
</Button>
) : (
<span className="text-[13px] text-muted-foreground max-sm:text-center">{p.note}</span>
)}
<Button type="submit" icon="arrow-right" className="max-sm:w-full">
{t.next}
</Button>
</div>
</form>
)}
</Reveal>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* A survey described as data.
* <Survey variant="scale" questions={[{ id: 'easy', title: 'Booking was easy', low: 'Not at all', high: 'Completely' }]} onSubmit={(answers) => …} />
*/
export function Survey(props: SurveyProps) {
const { variant = 'scale', 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 === 'branching' ? <Branching p={props} t={t} /> : <OnePage p={props} t={t} variant={variant} />}
</Section>
);
}Frequent questions
How do I install Survey?
With aiw add @ai-website/survey. The command puts components/ui/survey.tsx into your project, the code is yours from then on. It brings along button, icon, input, textarea, switch, motion, reveal, section and form-flow. It needs no further npm packages.
Which variants does Survey have?
5 variants, Scale, Matrix, Choice, Open questions and Branching. You pick one with the variant prop, for example variant="matrix".
Can I translate the texts of Survey?
Yes. Survey carries 23 texts of its own in English and German, such as “Send answers”, “Please answer this question, it helps us most.” and “Other”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Survey?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Survey with aiw_components and install it with aiw_add @ai-website/survey. Name the variant in your prompt, for example variant="scale", and the assistant writes your content straight into the props.
Can I adjust Survey without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Survey code to copy.