Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Skala

Aussagen auf einer Skala von eins bis fünf mit Worten an beiden Enden. Die gewählte Kachel gleitet über die Skala, Pfeiltasten bewegen sie, und die Sendeleiste bleibt beim Scrollen im Blick.

Im Studio öffnen

Matrix

Mehrere Bereiche in einer Tabelle bewertet, die Zeile unter dem Zeiger hellt auf und der Punkt springt hinein. Auf dem Telefon wird jeder Bereich eine eigene kurze Skala, nichts muss seitlich scrollen.

Im Studio öffnen

Auswahl

Einfach- und Mehrfachauswahl als ruhige Zeilen in zwei Spalten. Ein Zähler zeigt, wie viele gewählt sind, und hält den Rest an der Grenze an, „Etwas anderes“ öffnet ein eigenes Feld mit weicher Höhe und bekommt den Fokus.

Im Studio öffnen

Offene Fragen

Offene Fragen mit Satzanfängen zum Antippen, der Anfang landet im Text und der Cursor wartet dahinter. Ein Schalter erlaubt anonyme Antworten, Name und E-Mail klappen weg, solange er an ist.

Im Studio öffnen

Verzweigung

Eine Frage nach der anderen, und die Antwort entscheidet, welche als Nächstes kommt. Daneben wächst der bisherige Weg mit jeder Antwort und nennt, wie viele Fragen für diese Person übersprungen wurden.

Im Studio öffnen

Details

Was Umfrage mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/survey.tsx
Bringt mit
utilskitbuttoniconinputtextareaswitchmotionrevealsectionform-flow
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 23 eigene Texte

Props

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

  • variantoptional
    SurveyVariant

    scalematrixchoiceopenbranching

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • questionsPflicht
    SurveyQuestion[]
  • noteoptional
    string

    Quiet line next to the send button, e.g. "Takes three minutes"

  • submitLabeloptional
    string
  • anonymousoptional
    boolean

    open: 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
sendEnglisch Send answersDeutsch Antworten senden
answeredEnglisch {n} of {total} answeredDeutsch {n} von {total} beantwortet
requiredEnglisch Please answer this question, it helps us most.Deutsch Bitte beantworte diese Frage, sie hilft uns am meisten.
matrixEnglisch Please rate every line, {n} still open.Deutsch Bitte bewerte jede Zeile, {n} sind noch offen.
otherEnglisch OtherDeutsch Etwas anderes
otherLabelEnglisch Your answerDeutsch Deine Antwort
otherNeededEnglisch Please tell us what you mean by other.Deutsch Bitte schreib kurz, was du mit etwas anderem meinst.
pickUpToEnglisch Choose up to {n}Deutsch Wähle bis zu {n}
pickedEnglisch {n} of {max} chosenDeutsch {n} von {max} gewählt
startersEnglisch Start withDeutsch Beginne mit
anonymousEnglisch Answer anonymouslyDeutsch Anonym antworten
anonymousTextEnglisch We see your answers, never your name.Deutsch Wir sehen deine Antworten, nie deinen Namen.
nameEnglisch NameDeutsch Name
emailEnglisch EmailDeutsch E-Mail
nextEnglisch Next questionDeutsch Nächste Frage
backEnglisch Previous questionDeutsch Vorherige Frage
questionEnglisch Question {n}Deutsch Frage {n}
pathEnglisch Your pathDeutsch Dein Weg
skippedEnglisch Skipped for youDeutsch Für dich übersprungen
doneTitleEnglisch Thank you, your answers are inDeutsch Danke, deine Antworten sind da
doneTextEnglisch 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.
againEnglisch Answer againDeutsch Noch einmal antworten
aspectEnglisch AspectDeutsch 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
components/ui/survey.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert