Skip to content
UI Kit
Accent color

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

5 variants

Variants

Scale

Statements on a five point scale with words at both ends. The chosen tile slides along the scale, arrow keys move it, and the send bar stays in view while the questions pass by.

Open in Studio

Matrix

Several aspects rated in one table, the row under the pointer lights up and the dot springs in. On phones every aspect becomes its own short scale, so nothing has to scroll sideways.

Open in Studio

Choice

Single and multiple choice as calm rows in two columns. A counter shows how many are chosen and pauses the rest at the limit, "other" opens its own field with a soft height and takes the focus.

Open in Studio

Open questions

Open questions with starters to tap, a starter lands in the text and the cursor waits behind it. A switch lets people answer anonymously, name and email fold away when it is on.

Open in Studio

Branching

One question at a time, and the answer decides which comes next. Beside it the path so far grows with every answer and names the questions skipped for this visitor.

Open in Studio

Details

What Survey brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/survey.tsx
Brings along
utilskitbuttoniconinputtextareaswitchmotionrevealsectionform-flow
npm packages
None
Languages
English and German, 23 texts of its own

Props

The SurveyProps interface from components/ui/survey.tsx, with the comments from the code.

  • variantoptional
    SurveyVariant

    scalematrixchoiceopenbranching

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • questionsrequired
    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

Built-in texts

Survey writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 23 texts
sendEnglish Send answersGerman Antworten senden
answeredEnglish {n} of {total} answeredGerman {n} von {total} beantwortet
requiredEnglish Please answer this question, it helps us most.German Bitte beantworte diese Frage, sie hilft uns am meisten.
matrixEnglish Please rate every line, {n} still open.German Bitte bewerte jede Zeile, {n} sind noch offen.
otherEnglish OtherGerman Etwas anderes
otherLabelEnglish Your answerGerman Deine Antwort
otherNeededEnglish Please tell us what you mean by other.German Bitte schreib kurz, was du mit etwas anderem meinst.
pickUpToEnglish Choose up to {n}German Wähle bis zu {n}
pickedEnglish {n} of {max} chosenGerman {n} von {max} gewählt
startersEnglish Start withGerman Beginne mit
anonymousEnglish Answer anonymouslyGerman Anonym antworten
anonymousTextEnglish We see your answers, never your name.German Wir sehen deine Antworten, nie deinen Namen.
nameEnglish NameGerman Name
emailEnglish EmailGerman E-Mail
nextEnglish Next questionGerman Nächste Frage
backEnglish Previous questionGerman Vorherige Frage
questionEnglish Question {n}German Frage {n}
pathEnglish Your pathGerman Dein Weg
skippedEnglish Skipped for youGerman Für dich übersprungen
doneTitleEnglish Thank you, your answers are inGerman Danke, deine Antworten sind da
doneTextEnglish 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.
againEnglish Answer againGerman Noch einmal antworten
aspectEnglish AspectGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard