Skip to content
v2.5.0
English
Русский
Documentation
All templates

Feedback survey

A stepped product survey with an NPS scale, a satisfaction slider, choice cards, an open answer, a review step and a thank-you screen.

Code

docs/examples/templates/survey.tsx
import { useEffect, useId, useRef, useState, type FormEvent } from 'react';
import {
  Alert,
  AlertContent,
  AlertIcon,
  AlertMain,
  AlertTitle,
  Button,
  Card,
  Checkbox,
  CheckboxCard,
  CheckboxCardContent,
  CheckboxCardControl,
  CheckboxCardControlCheckedIcon,
  CheckboxCardControlInput,
  CheckboxCardDescription,
  CheckboxCardTitle,
  CheckboxCheckedIcon,
  CheckboxIndicator,
  CheckboxInput,
  Fieldset,
  FieldsetLegend,
  Heading,
  HelperText,
  Label,
  Progress,
  ProgressTrack,
  Radio,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
  Slider,
  SliderRange,
  SliderThumb,
  SliderThumbIndicator,
  SliderThumbInput,
  SliderTrack,
  Text,
  TextArea,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import './survey.css';

const steps = ['Rating', 'Usage', 'Details', 'Review'];

const scores = Array.from({ length: 11 }, (_, index) => String(index));

const satisfaction = ['Very dissatisfied', 'Dissatisfied', 'Neutral', 'Satisfied', 'Very satisfied'];

const features = [
  { value: 'notes', title: 'Notes', description: 'Writing and editing pages' },
  { value: 'search', title: 'Search', description: 'Finding pages and files' },
  { value: 'tasks', title: 'Tasks', description: 'Checklists and due dates' },
  { value: 'sharing', title: 'Sharing', description: 'Links, guests and comments' },
];

const roles = [
  { value: 'engineering', label: 'Engineering' },
  { value: 'design', label: 'Design' },
  { value: 'product', label: 'Product management' },
  { value: 'marketing', label: 'Marketing' },
  { value: 'other', label: 'Other' },
];

const IMPROVE_LIMIT = 500;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const field = { display: 'grid', gap: 'var(--spacing-3)', alignContent: 'start' } as const;
const stepStyle = { display: 'grid', gap: 'var(--spacing-12)' } as const;
const secondary = { color: 'var(--label-secondary)' } as const;

type Field = 'score' | 'features' | 'role' | 'roleOther' | 'email' | 'consent';
type Errors = Partial<Record<Field, string>>;

interface Answers {
  score: string
  rating: number
  features: string[]
  role: string
  roleOther: string
  improve: string
  email: string
  consent: boolean
}

const emptyAnswers: Answers = { score: '', rating: 3, features: [], role: '', roleOther: '', improve: '', email: '', consent: false };

const fieldOrder: Field[] = ['score', 'features', 'role', 'roleOther', 'email', 'consent'];

const validateStep = (step: number, answers: Answers) => {
  const errors: Errors = {};

  if (step === 0 && answers.score === '') errors.score = 'Pick a score from 0 to 10.';

  if (step === 1) {
    if (answers.features.length === 0) errors.features = 'Choose at least one feature.';
    if (answers.role === '') errors.role = 'Choose the role closest to yours.';
    if (answers.role === 'other' && answers.roleOther.trim() === '') errors.roleOther = 'Tell us your role.';
  }

  if (step === 2) {
    const email = answers.email.trim();

    if (email !== '' && !EMAIL.test(email)) errors.email = 'Use the full address, like ada@example.com.';
    if (email !== '' && !answers.consent) errors.consent = 'Allow us to contact you, or leave the email empty.';
  }

  return errors;
};

export default function Survey() {
  const id = useId();
  const headingRef = useRef<HTMLHeadingElement>(null);
  const moved = useRef(false);
  const [step, setStep] = useState(0);
  const [answers, setAnswers] = useState<Answers>(emptyAnswers);
  const [errors, setErrors] = useState<Errors>({});
  const [sent, setSent] = useState(false);

  useEffect(() => {
    if (!moved.current) return;
    headingRef.current?.focus();
  }, [step, sent]);

  const change = <K extends keyof Answers>(name: K, value: Answers[K]) => {
    setAnswers({ ...answers, [name]: value });

    if (!(name in errors)) return;

    setErrors({ ...errors, [name]: undefined });
  };

  const toggleFeature = (value: string, checked: boolean) => {
    const next = checked ? [...answers.features, value] : answers.features.filter((item) => item !== value);
    change('features', next);
  };

  const goTo = (next: number) => {
    moved.current = true;
    setErrors({});
    setStep(next);
  };

  const describe = (name: Field) => (errors[name] ? `${id}-${name}-error` : undefined);

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const next = validateStep(step, answers);
    const invalid = fieldOrder.find((name) => next[name]);

    setErrors(next);

    if (invalid) {
      const element = form.querySelector<HTMLElement>(`[data-field='${invalid}']`);
      element?.focus();
      return;
    }

    if (step < steps.length - 1) {
      goTo(step + 1);
      return;
    }

    moved.current = true;
    setSent(true);
  };

  const restart = () => {
    moved.current = true;
    setAnswers(emptyAnswers);
    setErrors({});
    setStep(0);
    setSent(false);
  };

  const roleLabel = answers.role === 'other' ? answers.roleOther.trim() : roles.find((item) => item.value === answers.role)?.label ?? '';

  const review = [
    { step: 0, term: 'Likelihood to recommend', value: `${answers.score} out of 10` },
    { step: 0, term: 'Satisfaction with search', value: satisfaction[answers.rating - 1] },
    { step: 1, term: 'Used most', value: features.filter((item) => answers.features.includes(item.value)).map((item) => item.title).join(', ') },
    { step: 1, term: 'Role', value: roleLabel },
    { step: 2, term: 'What to improve', value: answers.improve.trim() === '' ? 'No answer' : answers.improve.trim() },
    { step: 2, term: 'Follow-up email', value: answers.email.trim() === '' ? 'Don’t contact me' : answers.email.trim() },
  ];

  if (sent) {
    return (
      <Card className='survey' style={{ alignItems: 'stretch', gap: 'var(--spacing-10)', width: '100%', maxWidth: 600, marginInline: 'auto' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
          <Heading ref={headingRef} tabIndex={-1} as='h1' size={6} fontWeight={700} style={{ outline: 'none' }}>Thank you for your feedback</Heading>
          <Text size={3} style={secondary}>Every answer is read by the product team. We share what we change in the monthly changelog.</Text>
        </div>
        {answers.email.trim() !== '' && (
          <Alert variant='success'>
            <AlertIcon />
            <AlertMain>
              <AlertTitle>We may reach out</AlertTitle>
              <AlertContent style={{ overflowWrap: 'anywhere' }}>{`If we have questions, we’ll write to ${answers.email.trim()}.`}</AlertContent>
            </AlertMain>
          </Alert>
        )}
        <Button size='xs' variant='secondary' appearance='ghost' style={{ justifySelf: 'start' }} onClick={restart}>Start a new response</Button>
      </Card>
    );
  }

  return (
    <Card asChild className='survey' style={{ alignItems: 'stretch', gap: 'var(--spacing-12)', width: '100%', maxWidth: 600, marginInline: 'auto' }}>
      <form noValidate aria-labelledby={`${id}-heading`} onSubmit={submit}>
        <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
          <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', gap: 'var(--spacing-2) var(--spacing-8)' }}>
            <Text size={2} style={{ ...secondary, fontVariantNumeric: 'tabular-nums' }}>{`Step ${step + 1} of ${steps.length}`}</Text>
            <Text size={2} style={secondary}>About 2 minutes</Text>
          </div>
          <Progress value={step + 1} max={steps.length} valueText={`Step ${step + 1} of ${steps.length}: ${steps[step]}`} aria-label='Survey progress' fullWidth>
            <ProgressTrack />
          </Progress>
        </div>

        <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
          <Heading ref={headingRef} id={`${id}-heading`} tabIndex={-1} as='h1' size={6} fontWeight={700} style={{ outline: 'none' }}>
            {step === 0 && 'How are we doing?'}
            {step === 1 && 'How you use Fieldnote'}
            {step === 2 && 'Anything else?'}
            {step === 3 && 'Review your answers'}
          </Heading>
          <Text size={3} style={secondary}>
            {step === 0 && 'Your answers help us decide what to build next.'}
            {step === 1 && 'So we can compare feedback across teams.'}
            {step === 2 && 'Both questions are optional.'}
            {step === 3 && 'Check everything before you send it.'}
          </Text>
        </div>

        {step === 0 && (
          <div style={stepStyle}>
            <div style={field}>
              <Label id={`${id}-score-label`}>How likely are you to recommend Fieldnote to a colleague?</Label>
              <SegmentedControl
                size='xs'
                name='score'
                role='radiogroup'
                value={answers.score}
                aria-labelledby={`${id}-score-label`}
                aria-describedby={errors.score ? `${id}-score-error ${id}-score-scale` : `${id}-score-scale`}
                className='surveyScore'
                onChange={(event) => change('score', event.target.value)}
              >
                {scores.map((score, index) => (
                  <SegmentedControlItem key={score} value={score}>
                    <SegmentedControlItemInput data-field={index === 0 ? 'score' : undefined} aria-invalid={errors.score !== undefined} />
                    {score}
                  </SegmentedControlItem>
                ))}
                <SegmentedControlIndicator />
              </SegmentedControl>
              <div id={`${id}-score-scale`} className='surveyScale'>
                <Text size={1} style={secondary}>0 – Not at all likely</Text>
                <Text size={1} style={secondary}>10 – Extremely likely</Text>
              </div>
              {errors.score && <HelperText id={`${id}-score-error`} error>{errors.score}</HelperText>}
            </div>

            <div style={{ ...field, gap: 'var(--spacing-5)' }}>
              <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--spacing-2) var(--spacing-8)' }}>
                <Label id={`${id}-rating-label`}>How satisfied are you with search?</Label>
                <Text size={2} fontWeight={500} aria-hidden='true' style={{ color: 'var(--color-primary-500)' }}>{satisfaction[answers.rating - 1]}</Text>
              </div>
              <Slider min={1} max={5} value={answers.rating} onChangeValue={(value) => change('rating', value)}>
                <SliderTrack>
                  <SliderRange />
                </SliderTrack>
                <SliderThumb>
                  <SliderThumbInput name='rating' aria-labelledby={`${id}-rating-label`} aria-valuetext={`${answers.rating} of 5, ${satisfaction[answers.rating - 1]}`} />
                  <SliderThumbIndicator />
                </SliderThumb>
              </Slider>
              <div aria-hidden='true' className='surveyScale'>
                <Text size={1} style={secondary}>Very dissatisfied</Text>
                <Text size={1} style={secondary}>Very satisfied</Text>
              </div>
            </div>
          </div>
        )}

        {step === 1 && (
          <div style={stepStyle}>
            <Fieldset aria-describedby={describe('features')} className='surveyFieldset' style={{ gap: 'var(--spacing-5)' }}>
              <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-3)' }}><Text size={3} fontWeight={500}>What do you use most?</Text></FieldsetLegend>
              <div className='surveyFeatures'>
                {features.map((item, index) => (
                  <CheckboxCard key={item.value} size='sm' style={{ minWidth: 0 }}>
                    <CheckboxCardContent>
                      <CheckboxCardTitle>{item.title}</CheckboxCardTitle>
                      <CheckboxCardDescription>{item.description}</CheckboxCardDescription>
                    </CheckboxCardContent>
                    <CheckboxCardControl>
                      <CheckboxCardControlInput
                        name='features'
                        value={item.value}
                        data-field={index === 0 ? 'features' : undefined}
                        checked={answers.features.includes(item.value)}
                        aria-invalid={errors.features !== undefined}
                        onChange={(event) => toggleFeature(item.value, event.currentTarget.checked)}
                      />
                      <CheckboxIndicator>
                        <CheckboxCardControlCheckedIcon />
                      </CheckboxIndicator>
                    </CheckboxCardControl>
                  </CheckboxCard>
                ))}
              </div>
              {errors.features && <HelperText id={`${id}-features-error`} error>{errors.features}</HelperText>}
            </Fieldset>

            <Fieldset aria-describedby={describe('role')} className='surveyFieldset' style={{ gap: 'var(--spacing-5)' }}>
              <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-3)' }}><Text size={3} fontWeight={500}>What’s your role?</Text></FieldsetLegend>
              <div style={{ display: 'grid', gap: 'var(--spacing-5)' }}>
                {roles.map((option, index) => (
                  <div key={option.value} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
                    <Radio
                      id={`${id}-role-${option.value}`}
                      name='role'
                      value={option.value}
                      data-field={index === 0 ? 'role' : undefined}
                      checked={answers.role === option.value}
                      aria-invalid={errors.role !== undefined}
                      onChange={() => change('role', option.value)}
                    />
                    <Label htmlFor={`${id}-role-${option.value}`} style={{ fontWeight: 400 }}>{option.label}</Label>
                  </div>
                ))}
              </div>
              {answers.role === 'other' && (
                <div style={{ ...field, paddingInlineStart: 'var(--spacing-14)' }}>
                  <Label htmlFor={`${id}-role-custom`}>Your role</Label>
                  <TextField error={errors.roleOther !== undefined}>
                    <TextFieldInput
                      id={`${id}-role-custom`}
                      name='roleOther'
                      data-field='roleOther'
                      autoComplete='organization-title'
                      placeholder='Customer support'
                      value={answers.roleOther}
                      aria-invalid={errors.roleOther !== undefined}
                      aria-describedby={describe('roleOther')}
                      onChange={(event) => change('roleOther', event.currentTarget.value)}
                    />
                  </TextField>
                  {errors.roleOther && <HelperText id={`${id}-roleOther-error`} error>{errors.roleOther}</HelperText>}
                </div>
              )}
              {errors.role && <HelperText id={`${id}-role-error`} error>{errors.role}</HelperText>}
            </Fieldset>
          </div>
        )}

        {step === 2 && (
          <div style={stepStyle}>
            <div style={field}>
              <Label htmlFor={`${id}-improve`}>What’s one thing we should improve?</Label>
              <TextArea
                id={`${id}-improve`}
                name='improve'
                rows={4}
                maxLength={IMPROVE_LIMIT}
                fullWidth
                placeholder='The more specific, the more useful'
                value={answers.improve}
                aria-describedby={`${id}-improve-count`}
                onChange={(event) => change('improve', event.currentTarget.value)}
              />
              <HelperText id={`${id}-improve-count`} style={{ justifySelf: 'end', fontVariantNumeric: 'tabular-nums' }}>
                {`${answers.improve.length} / ${IMPROVE_LIMIT}`}
              </HelperText>
            </div>
            <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
              <div style={field}>
                <Label htmlFor={`${id}-email`}>Email for follow-up questions</Label>
                <TextField error={errors.email !== undefined}>
                  <TextFieldInput
                    id={`${id}-email`}
                    name='email'
                    type='email'
                    data-field='email'
                    autoComplete='email'
                    value={answers.email}
                    aria-invalid={errors.email !== undefined}
                    aria-describedby={errors.email ? `${id}-email-error` : `${id}-email-hint`}
                    onChange={(event) => change('email', event.currentTarget.value)}
                  />
                </TextField>
                <HelperText id={errors.email ? `${id}-email-error` : `${id}-email-hint`} error={errors.email !== undefined}>
                  {errors.email ?? 'Optional. We only use it for this survey.'}
                </HelperText>
              </div>
              <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
                <Checkbox style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
                  <CheckboxInput
                    name='consent'
                    data-field='consent'
                    checked={answers.consent}
                    aria-invalid={errors.consent !== undefined}
                    aria-describedby={describe('consent')}
                    onChange={(event) => change('consent', event.currentTarget.checked)}
                  />
                  <CheckboxIndicator>
                    <CheckboxCheckedIcon />
                  </CheckboxIndicator>
                  The research team may contact me about my answers
                </Checkbox>
                {errors.consent && <HelperText id={`${id}-consent-error`} error>{errors.consent}</HelperText>}
              </div>
            </div>
          </div>
        )}

        {step === 3 && (
          <dl className='surveyReview'>
            {review.map((item) => (
              <div key={item.term} className='surveyReviewRow'>
                <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 0 }}>
                  <Text asChild size={2} style={secondary}><dt>{item.term}</dt></Text>
                  <Text asChild size={3} fontWeight={500} style={{ margin: 0, overflowWrap: 'anywhere', fontVariantNumeric: 'tabular-nums' }}><dd>{item.value}</dd></Text>
                </div>
                <Button size='xs' type='button' variant='secondary' appearance='ghost' aria-label={`Edit ${item.term.toLowerCase()}`} onClick={() => goTo(item.step)}>Edit</Button>
              </div>
            ))}
          </dl>
        )}

        <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 'var(--spacing-4)' }}>
          {step > 0 && <Button size='xs' type='button' variant='secondary' appearance='ghost' onClick={() => goTo(step - 1)}>Back</Button>}
          <Button size='xs' type='submit'>{step === steps.length - 1 ? 'Send feedback' : 'Continue'}</Button>
        </div>
      </form>
    </Card>
  );
}

Components used