Umfrage
Umfragen, die man gern zu Ende ausfüllt. Jede Fragenart hat ihr eigenes ruhiges Layout, ein Zähler unten zeigt, was beantwortet ist, und offene Fragen stehen über dem Formular mit einem Link zu jeder.
Eine von 3 Components in Umfragen und Feedback
Variants
Details
Was Umfrage mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Umfragen und Feedback
- Bereich
- Content
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/survey.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 23 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SurveyProps aus components/ui/survey.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSurveyVariantscalematrixchoiceopenbranching
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodequestionsPflichtSurveyQuestion[]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
Eingebaute Texte
Diese Texte schreibt Umfrage selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 23 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| send | Englisch Send answers | Deutsch Antworten senden |
| answered | Englisch {n} of {total} answered | Deutsch {n} von {total} beantwortet |
| required | Englisch Please answer this question, it helps us most. | Deutsch Bitte beantworte diese Frage, sie hilft uns am meisten. |
| matrix | Englisch Please rate every line, {n} still open. | Deutsch Bitte bewerte jede Zeile, {n} sind noch offen. |
| other | Englisch Other | Deutsch Etwas anderes |
| otherLabel | Englisch Your answer | Deutsch Deine Antwort |
| otherNeeded | Englisch Please tell us what you mean by other. | Deutsch Bitte schreib kurz, was du mit etwas anderem meinst. |
| pickUpTo | Englisch Choose up to {n} | Deutsch Wähle bis zu {n} |
| picked | Englisch {n} of {max} chosen | Deutsch {n} von {max} gewählt |
| starters | Englisch Start with | Deutsch Beginne mit |
| anonymous | Englisch Answer anonymously | Deutsch Anonym antworten |
| anonymousText | Englisch We see your answers, never your name. | Deutsch Wir sehen deine Antworten, nie deinen Namen. |
| name | Englisch Name | Deutsch Name |
| Englisch Email | Deutsch E-Mail | |
| next | Englisch Next question | Deutsch Nächste Frage |
| back | Englisch Previous question | Deutsch Vorherige Frage |
| question | Englisch Question {n} | Deutsch Frage {n} |
| path | Englisch Your path | Deutsch Dein Weg |
| skipped | Englisch Skipped for you | Deutsch Für dich übersprungen |
| doneTitle | Englisch Thank you, your answers are in | Deutsch Danke, deine Antworten sind da |
| doneText | Englisch Every answer is read. We share what we change in the next newsletter. | Deutsch Wir lesen jede Antwort. Was wir ändern, steht im nächsten Newsletter. |
| again | Englisch Answer again | Deutsch Noch einmal antworten |
| aspect | Englisch Aspect | Deutsch Bereich |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-describedby, aria-invalid, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- 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. 5 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 740 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Umfrage?
Mit aiw add @ai-website/survey. Der Befehl legt die Datei components/ui/survey.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, input, textarea, switch, motion, reveal, section und form-flow. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Umfrage?
5 Varianten, Skala, Matrix, Auswahl, Offene Fragen und Verzweigung. Du wählst sie mit der Prop variant, zum Beispiel variant="matrix".
Kann ich die Texte von Umfrage übersetzen?
Ja. Umfrage bringt 23 eigene Texte auf Deutsch und Englisch mit, etwa „Antworten senden“, „Bitte beantworte diese Frage, sie hilft uns am meisten.“ und „Etwas anderes“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Umfrage einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Umfrage mit aiw_components und installieren sie mit aiw_add @ai-website/survey. Nenn im Prompt die Variante, etwa variant="scale", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Umfrage ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Survey-Code zum Kopieren.