Skip to content

Multi-step survey with NPS

One question per page, progress always visible. A questionnaire built on md-stepper: four linear steps with a md-progress-indicator header that counts answered questions, an md-rating satisfaction score, a 0-10 NPS scale made of toggle md-buttons (a segmented set caps at five options, so eleven segments would break its M3 contract), an md-slider for renewal likelihood, radio and checkbox question types, and a md-multi-select for feature usage. Continue stays disabled until the current question is answered, and finishing plays the progress bar’s closing animation before the thank-you md-card appears.

Live preview — answer all four questions to reach the thank-you card
Quarterly product survey Question 1 of 4
How satisfied are you with Meridian CRM overall? Tap a star — tapping it again clears your answer.
How likely are you to recommend Meridian CRM to a colleague?
0 1 2 3 4 5 6 7 8 9 10
Not at all likely Extremely likely
How often do you use Meridian?
Pipelines Email sync Reports Automations Mobile app
How likely are you to renew your subscription next quarter?
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: grid; gap: 8px;">
  <span>Quarterly product survey</span>
  <md-progress-indicator value="0" max="4" label="Survey progress"></md-progress-indicator>
</div>

<md-stepper label="Survey questions" next-disabled finish-label="Submit survey">
  <md-step label="Satisfaction" description="Overall experience">
    <md-rating size="lg" rating-label="Overall satisfaction"></md-rating>
  </md-step>
  <md-step label="Recommend" description="0-10 scale">
    <div role="group" aria-label="Likelihood to recommend, 0 to 10">
      <md-button variant="outlined" size="xs" toggle value="0">0</md-button>
      <!-- ...one toggle button per score, 1 through 9... -->
      <md-button variant="outlined" size="xs" toggle value="10">10</md-button>
    </div>
  </md-step>
  <md-step label="Usage" description="How you work">
    <div role="radiogroup" aria-label="Usage frequency">
      <label><md-radio name="survey-frequency" value="daily"></md-radio>Every day</label>
      <label><md-radio name="survey-frequency" value="weekly"></md-radio>A few times a week</label>
      <label><md-radio name="survey-frequency" value="monthly"></md-radio>A few times a month</label>
      <label><md-radio name="survey-frequency" value="rarely"></md-radio>Rarely</label>
    </div>
    <md-multi-select label="Features you use regularly" supporting-text="Optional — pick any that apply">
      <md-select-option value="pipelines">Pipelines</md-select-option>
      <md-select-option value="email-sync">Email sync</md-select-option>
      <md-select-option value="reports">Reports</md-select-option>
      <md-select-option value="automations">Automations</md-select-option>
      <md-select-option value="mobile">Mobile app</md-select-option>
    </md-multi-select>
  </md-step>
  <md-step label="Wrap-up" description="Almost done">
    <md-slider min="0" max="100" step="10" stops value="60" value-indicator
      aria-label="Likelihood to renew" value-text="60 percent"></md-slider>
      <label><md-checkbox name="contact" value="yes"></md-checkbox>You can contact me about this feedback</label>
    </md-step>
  </md-stepper>

  <md-card variant="filled" hidden>
    <span>Thanks — your feedback is in.</span>
  </md-card>

<script type="module">
  const stepper = document.querySelector('[data-stepper]');
  const progress = document.querySelector('[data-progress]');
  const caption = document.querySelector('[data-progress-caption]');
  const rating = document.querySelector('[data-rating]');
  const npsGroup = document.querySelector('[data-nps]');
  const npsButtons = Array.from(npsGroup.querySelectorAll('md-button'));
  const frequencyGroup = document.querySelector('[data-frequency]');
  const renew = document.querySelector('[data-renew]');
  const contact = document.querySelector('[data-contact]');
  const survey = document.querySelector('[data-survey]');
  const thanks = document.querySelector('[data-thanks]');
  const summary = document.querySelector('[data-summary]');

  let nps = null;
  let frequency = null;

  rating.getLabel = (v) => v + ' out of 5 stars';

  // Continue is gated per question: the built-in button stays disabled
  // until the active step has an answer. Linear mode already blocks
  // jumping ahead via the step headers.
  const gate = () => {
    const i = stepper.active;
    stepper.nextDisabled =
      (i === 0 && rating.value === 0) ||
      (i === 1 && nps === null) ||
      (i === 2 && frequency === null);
  };

  rating.addEventListener('mdChange', gate);

  // The NPS row is eleven toggle buttons kept single-select by hand:
  // mdClick's detail.selected is the POST-click state, and preventDefault()
  // vetoes the flip — so re-clicking the chosen score can't empty the scale.
  npsGroup.addEventListener('mdClick', (e) => {
    const btn = e.target.closest('md-button');
    if (!btn) return;
    if (e.detail.selected === false) {
      e.preventDefault();
      return;
    }
    npsButtons.forEach((b) => { if (b !== btn) b.selected = false; });
    nps = Number(e.detail.value);
    gate();
  });

  frequencyGroup.addEventListener('mdChange', (e) => {
    frequency = e.detail.value;
    gate();
  });

  // Keep the announced value human: "60 percent", not a bare number.
  renew.addEventListener('mdInput', (e) => {
    renew.valueText = e.detail.value + ' percent';
  });

  stepper.addEventListener('mdStepChange', (e) => {
    progress.value = e.detail.index;
    caption.textContent = 'Question ' + (e.detail.index + 1) + ' of 4';
    gate();
  });

  stepper.addEventListener('mdComplete', () => {
    progress.value = 4;
    caption.textContent = 'All 4 questions answered';
    progress.complete = true; // plays the closing animation, then self-hides
    survey.hidden = true;
    const band = nps >= 9 ? 'a promoter' : nps >= 7 ? 'passive' : 'a detractor';
    summary.textContent = 'You rated Meridian ' + rating.value + '/5 and gave it an NPS of '
      + nps + ' (' + band + ').'
      + (contact.checked ? ' We will follow up by email.' : '');
    thanks.hidden = false;
  });
</script>
ComponentRole in this screen
md-stepperOwns the flow: four md-step children with per-step panels, mode="linear" (the default) so headers ahead are unreachable, next-disabled re-bound to the active question’s validity, and mdComplete as the submit signal.
md-progress-indicatorThe header bar: value counts answered questions against max="4", and complete plays the closing animation on submit — mdComplete follows that flag, never value === max.
md-ratingFive-star satisfaction. getLabel makes every value announce as “3 out of 5 stars”; re-clicking the current value clears back to default-value (0), which the gate treats as unanswered.
md-buttonEleven toggle buttons form the 0-10 NPS scale — a md-segmented-button-set is capped at five segments by M3, so it is the wrong tool here. One delegated mdClick listener keeps the row single-select.
md-sliderRenewal likelihood: step="10" with stops for honest increments, value-indicator for the always-visible bubble, value-text for the spoken units.
md-radioUsage frequency — four options sharing one name form the exclusive group with arrow-key selection and a roving tabstop.
md-checkboxContact consent, wrapped in a <label> (the component has no slot) with supporting-text under the box.
md-multi-selectFeature usage: several values from a list, shown as removable chips after the menu closes.
md-cardThe thank-you surface, filled variant, with a summary line computed from the answers.
  • Two gates, one per failure mode. next-disabled only disables the built-in Continue button — it never blocks step-header clicks. Those are covered by the stepper’s linear mode, which keeps unreachable headers aria-disabled and out of the tab order until earlier steps complete.
  • The NPS row can never end up empty or double-selected. mdClick’s detail.selected is the post-click state, and preventDefault() vetoes the toggle flip — so a re-click on the chosen score is swallowed instead of deselecting it, and every other button is cleared programmatically (a script-driven selected write emits no events, so there is no feedback loop).
  • Progress announces itself. The stepper is a navigation landmark with a polite live region (“Step 2 of 4: Recommend, current”), and the progress bar carries role="progressbar" with aria-valuenow — two independent progress reports, both free.
  • The slider speaks units. value-text is re-set on every mdInput, so a screen reader hears “60 percent” rather than a bare “60”; stops on a step="10" scale shows only increments a respondent can actually pick.
  • Completion is an animation, not a jump cut. Setting complete on the progress indicator plays the M3 closing sequence and then self-hides; the indicator’s own mdComplete event — not value reaching max — is the hook if you need to sequence anything after it.
  • Narrow embeds: switch the stepper to variant="mobile" — the header row collapses into a Back · progress · Continue bar. Panels must then move into the stepper’s content slot and be swapped on mdStepChange.
  • Server-side save per page: cancel mdBeforeChange, set loading on the stepper while the answer posts (this also disables Back, preventing a double-submit), then commit by assigning stepper.active = e.detail.index — never by calling next() from the handler, which would re-enter it.
  • Five-point scales: for CSAT-style 1-5 questions, a md-segmented-button-set is the right control — it owns exclusivity for you and stays within M3’s five-segment cap.
  • Long questionnaires: orientation="vertical" puts each panel inline under its step, and lazy mounts only the active panel when steps carry heavy content.
  • Recipe: Checkout wizard — the same stepper spine driving a purchase flow with validation per step.
  • Recipe: Product reviewsmd-rating on the display side: aggregates, distributions, and read-only scores.
  • Component manuals: md-stepper and md-rating — full API, keyboard maps, and anti-pattern lists.