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?
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>import { useState } from 'react';
import {
MdButton, MdCard, MdCheckbox, MdMultiSelect, MdProgressIndicator,
MdRadio, MdRating, MdSelectOption, MdSlider, MdStep, MdStepper,
} from '@awc-ui/react';
const SCORES = Array.from({ length: 11 }, (_, i) => i);
const FREQUENCIES = [
{ value: 'daily', label: 'Every day' },
{ value: 'weekly', label: 'A few times a week' },
{ value: 'monthly', label: 'A few times a month' },
{ value: 'rarely', label: 'Rarely' },
];
export default function ProductSurvey() {
const [active, setActive] = useState(0);
const [rating, setRating] = useState(0);
const [nps, setNps] = useState(null);
const [frequency, setFrequency] = useState(null);
const [features, setFeatures] = useState([]);
const [renew, setRenew] = useState(60);
const [contact, setContact] = useState(false);
const [done, setDone] = useState(false);
// Continue stays disabled until the active question is answered.
const nextDisabled =
(active === 0 && rating === 0) ||
(active === 1 && nps === null) ||
(active === 2 && frequency === null);
// detail.selected is the POST-click state; preventDefault() vetoes the
// flip, so re-clicking the chosen score cannot empty the scale.
const onNpsClick = (e, score) => {
if (e.detail.selected === false) {
e.preventDefault();
return;
}
setNps(score);
};
const band = nps >= 9 ? 'a promoter' : nps >= 7 ? 'passive' : 'a detractor';
return (
<div style={{ maxWidth: 640, marginInline: 'auto', display: 'grid', gap: 20 }}>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span>Quarterly product survey</span>
<span>{done ? 'All 4 questions answered' : 'Question ' + (active + 1) + ' of 4'}</span>
</div>
<MdProgressIndicator value={done ? 4 : active} max={4} complete={done} label="Survey progress" />
</div>
{!done && (
<MdStepper
label="Survey questions"
finishLabel="Submit survey"
nextDisabled={nextDisabled}
onMdStepChange={(e) => setActive(e.detail.index)}
onMdComplete={() => setDone(true)}
>
<MdStep label="Satisfaction" description="Overall experience">
<p>How satisfied are you with Meridian CRM overall?</p>
<MdRating size="lg" ratingLabel="Overall satisfaction"
getLabel={(v) => v + ' out of 5 stars'}
onMdChange={(e) => setRating(e.detail)} />
</MdStep>
<MdStep label="Recommend" description="0-10 scale">
<p>How likely are you to recommend Meridian CRM to a colleague?</p>
<div role="group" aria-label="Likelihood to recommend, 0 to 10"
style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{SCORES.map((score) => (
<MdButton key={score} variant="outlined" size="xs" toggle
value={String(score)} selected={nps === score}
onMdClick={(e) => onNpsClick(e, score)}>
{score}
</MdButton>
))}
</div>
</MdStep>
<MdStep label="Usage" description="How you work">
<div role="radiogroup" aria-label="Usage frequency">
{FREQUENCIES.map((f) => (
<label key={f.value} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<MdRadio name="survey-frequency" value={f.value}
checked={frequency === f.value}
onMdChange={() => setFrequency(f.value)} />
{f.label}
</label>
))}
</div>
<MdMultiSelect label="Features you use regularly"
supportingText="Optional — pick any that apply"
value={features} onMdChange={(e) => setFeatures(e.detail)}>
<MdSelectOption value="pipelines">Pipelines</MdSelectOption>
<MdSelectOption value="email-sync">Email sync</MdSelectOption>
<MdSelectOption value="reports">Reports</MdSelectOption>
</MdMultiSelect>
</MdStep>
<MdStep label="Wrap-up" description="Almost done">
<p>How likely are you to renew your subscription next quarter?</p>
<MdSlider min={0} max={100} step={10} stops valueIndicator
value={renew} valueText={renew + ' percent'}
sliderAriaLabel="Likelihood to renew"
onMdInput={(e) => setRenew(e.detail.value)} />
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<MdCheckbox checked={contact}
onMdChange={(e) => setContact(e.detail.checked)} />
You can contact me about this feedback
</label>
</MdStep>
</MdStepper>
)}
{done && (
<MdCard variant="filled" style={{ padding: 24, display: 'grid', gap: 8 }}>
<span>Thanks — your feedback is in.</span>
<span>
{'You rated Meridian ' + rating + '/5 and gave it an NPS of ' + nps +
' (' + band + ').' + (contact ? ' We will follow up by email.' : '')}
</span>
</MdCard>
)}
</div>
);
}import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MdButton, MdCard, MdCheckbox, MdMultiSelect, MdProgressIndicator, MdRadio,
MdRating, MdSelectOption, MdSlider, MdStep, MdStepper,
BooleanValueAccessor, NumericValueAccessor, SelectValueAccessor,
} from '@awc-ui/angular';
@Component({
selector: 'app-product-survey',
standalone: true,
imports: [
FormsModule, MdButton, MdCard, MdCheckbox, MdMultiSelect, MdProgressIndicator,
MdRadio, MdRating, MdSelectOption, MdSlider, MdStep, MdStepper,
BooleanValueAccessor, NumericValueAccessor, SelectValueAccessor,
],
// Written as a concatenated string only to keep this snippet paste-safe;
// in your app a normal template (or templateUrl) is the usual choice.
template:
'<div class="survey">' +
' <header>' +
' <span>Quarterly product survey</span>' +
' <span>{{ caption }}</span>' +
' </header>' +
' <md-progress-indicator [value]="done ? 4 : active" [max]="4" [complete]="done" label="Survey progress"></md-progress-indicator>' +
' @if (!done) {' +
' <md-stepper label="Survey questions" finishLabel="Submit survey" [nextDisabled]="nextDisabled"' +
' (mdStepChange)="active = $event.detail.index" (mdComplete)="done = true">' +
' <md-step label="Satisfaction" description="Overall experience">' +
' <p>How satisfied are you with Meridian CRM overall?</p>' +
' <md-rating size="lg" ratingLabel="Overall satisfaction" [getLabel]="labelFor" [(ngModel)]="rating"></md-rating>' +
' </md-step>' +
' <md-step label="Recommend" description="0-10 scale">' +
' <p>How likely are you to recommend Meridian CRM to a colleague?</p>' +
' <div role="group" aria-label="Likelihood to recommend, 0 to 10">' +
' @for (s of scores; track s) {' +
' <md-button variant="outlined" size="xs" toggle [value]="s" [selected]="nps === s"' +
' (mdClick)="onNps($event, s)">{{ s }}</md-button>' +
' }' +
' </div>' +
' </md-step>' +
' <md-step label="Usage" description="How you work">' +
' <div role="radiogroup" aria-label="Usage frequency">' +
' @for (f of frequencies; track f.value) {' +
' <label>' +
' <md-radio name="survey-frequency" [value]="f.value" [checked]="frequency === f.value"' +
' (mdChange)="frequency = f.value"></md-radio>' +
' {{ f.label }}' +
' </label>' +
' }' +
' </div>' +
' <md-multi-select label="Features you use regularly" supportingText="Optional — pick any that apply"' +
' [(ngModel)]="features">' +
' <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-multi-select>' +
' </md-step>' +
' <md-step label="Wrap-up" description="Almost done">' +
' <p>How likely are you to renew your subscription next quarter?</p>' +
' <md-slider [min]="0" [max]="100" [step]="10" stops valueIndicator [value]="renew"' +
' [valueText]="renewText" sliderAriaLabel="Likelihood to renew"' +
' (mdInput)="renew = $event.detail.value"></md-slider>' +
' <label>' +
' <md-checkbox [(ngModel)]="contact"></md-checkbox>' +
' You can contact me about this feedback' +
' </label>' +
' </md-step>' +
' </md-stepper>' +
' } @else {' +
' <md-card variant="filled">' +
' <span>Thanks — your feedback is in.</span>' +
' <span>{{ summary }}</span>' +
' </md-card>' +
' }' +
'</div>',
})
export class ProductSurveyComponent {
scores = Array.from({ length: 11 }, (_, i) => i);
frequencies = [
{ value: 'daily', label: 'Every day' },
{ value: 'weekly', label: 'A few times a week' },
{ value: 'monthly', label: 'A few times a month' },
{ value: 'rarely', label: 'Rarely' },
];
active = 0;
rating = 0;
nps: number | null = null;
frequency: string | null = null;
features: string[] = [];
renew = 60;
contact = false;
done = false;
labelFor = (v: number) => v + ' out of 5 stars';
// Continue stays disabled until the active question is answered.
get nextDisabled(): boolean {
return (
(this.active === 0 && this.rating === 0) ||
(this.active === 1 && this.nps === null) ||
(this.active === 2 && this.frequency === null)
);
}
get caption(): string {
return this.done ? 'All 4 questions answered' : 'Question ' + (this.active + 1) + ' of 4';
}
get renewText(): string {
return this.renew + ' percent';
}
get summary(): string {
const n = this.nps ?? 0;
const band = n >= 9 ? 'a promoter' : n >= 7 ? 'passive' : 'a detractor';
return 'You rated Meridian ' + this.rating + '/5 and gave it an NPS of ' + n +
' (' + band + ').' + (this.contact ? ' We will follow up by email.' : '');
}
// detail.selected is the post-click state; preventDefault() vetoes the
// flip, so re-clicking the chosen score cannot empty the scale.
onNps(e: CustomEvent, score: number) {
if ((e.detail as any).selected === false) {
e.preventDefault();
return;
}
this.nps = score;
}
}<script setup lang="ts">
import { ref, computed } from 'vue';
import {
MdButton, MdCard, MdCheckbox, MdMultiSelect, MdProgressIndicator,
MdRadio, MdRating, MdSelectOption, MdSlider, MdStep, MdStepper,
} from '@awc-ui/vue';
const scores = Array.from({ length: 11 }, (_, i) => i);
const frequencies = [
{ value: 'daily', label: 'Every day' },
{ value: 'weekly', label: 'A few times a week' },
{ value: 'monthly', label: 'A few times a month' },
{ value: 'rarely', label: 'Rarely' },
];
const active = ref(0);
const rating = ref(0);
const nps = ref<number | null>(null);
const frequency = ref<string | null>(null);
const features = ref<string[]>([]);
const renew = ref(60);
const contact = ref(false);
const done = ref(false);
const labelFor = (v: number) => v + ' out of 5 stars';
// Continue stays disabled until the active question is answered.
const nextDisabled = computed(() =>
(active.value === 0 && rating.value === 0) ||
(active.value === 1 && nps.value === null) ||
(active.value === 2 && frequency.value === null)
);
const caption = computed(() =>
done.value ? 'All 4 questions answered' : 'Question ' + (active.value + 1) + ' of 4'
);
const summary = computed(() => {
const n = nps.value ?? 0;
const band = n >= 9 ? 'a promoter' : n >= 7 ? 'passive' : 'a detractor';
return 'You rated Meridian ' + rating.value + '/5 and gave it an NPS of ' + n +
' (' + band + ').' + (contact.value ? ' We will follow up by email.' : '');
});
// detail.selected is the post-click state; preventDefault() vetoes the flip,
// so re-clicking the chosen score cannot empty the scale.
function onNpsClick(e: CustomEvent, score: number) {
if (e.detail.selected === false) {
e.preventDefault();
return;
}
nps.value = score;
}
</script>
<template>
<div class="survey">
<header>
<span>Quarterly product survey</span>
<span>{{ caption }}</span>
</header>
<MdProgressIndicator :value="done ? 4 : active" :max="4" :complete="done" label="Survey progress" />
<MdStepper v-if="!done" label="Survey questions" finishLabel="Submit survey"
:nextDisabled="nextDisabled"
@mdStepChange="active = $event.detail.index" @mdComplete="done = true">
<MdStep label="Satisfaction" description="Overall experience">
<p>How satisfied are you with Meridian CRM overall?</p>
<MdRating size="lg" ratingLabel="Overall satisfaction" :getLabel="labelFor"
@mdChange="rating = $event.detail" />
</MdStep>
<MdStep label="Recommend" description="0-10 scale">
<p>How likely are you to recommend Meridian CRM to a colleague?</p>
<div role="group" aria-label="Likelihood to recommend, 0 to 10">
<MdButton v-for="score in scores" :key="score" variant="outlined" size="xs" toggle
:value="String(score)" :selected="nps === score" @mdClick="onNpsClick($event, score)">
{{ score }}
</MdButton>
</div>
</MdStep>
<MdStep label="Usage" description="How you work">
<div role="radiogroup" aria-label="Usage frequency">
<label v-for="f in frequencies" :key="f.value">
<MdRadio name="survey-frequency" :value="f.value" :checked="frequency === f.value"
@mdChange="frequency = f.value" />
{{ f.label }}
</label>
</div>
<MdMultiSelect label="Features you use regularly"
supportingText="Optional — pick any that apply"
:value="features" @mdChange="features = $event.detail">
<MdSelectOption value="pipelines">Pipelines</MdSelectOption>
<MdSelectOption value="email-sync">Email sync</MdSelectOption>
<MdSelectOption value="reports">Reports</MdSelectOption>
</MdMultiSelect>
</MdStep>
<MdStep label="Wrap-up" description="Almost done">
<p>How likely are you to renew your subscription next quarter?</p>
<MdSlider :min="0" :max="100" :step="10" stops valueIndicator :value="renew"
:valueText="renew + ' percent'" sliderAriaLabel="Likelihood to renew"
@mdInput="renew = $event.detail.value" />
<label>
<MdCheckbox :checked="contact" @mdChange="contact = $event.detail.checked" />
You can contact me about this feedback
</label>
</MdStep>
</MdStepper>
<MdCard v-else variant="filled">
<span>Thanks — your feedback is in.</span>
<span>{{ summary }}</span>
</MdCard>
</div>
</template><script>
// Register the elements once on the client — in SvelteKit, guard it with
// 'if (browser)' from '$app/environment' (registration is client-only).
import { defineCustomElements } from '@awc-ui/svelte';
if (typeof window !== 'undefined') defineCustomElements(window);
const scores = Array.from({ length: 11 }, (_, i) => i);
const frequencies = [
{ value: 'daily', label: 'Every day' },
{ value: 'weekly', label: 'A few times a week' },
{ value: 'monthly', label: 'A few times a month' },
{ value: 'rarely', label: 'Rarely' },
];
let active = 0;
let rating = 0;
let nps = null;
let frequency = null;
let features = [];
let renew = 60;
let contact = false;
let done = false;
const labelFor = (v) => v + ' out of 5 stars';
// Continue stays disabled until the active question is answered.
$: nextDisabled =
(active === 0 && rating === 0) ||
(active === 1 && nps === null) ||
(active === 2 && frequency === null);
$: caption = done ? 'All 4 questions answered' : 'Question ' + (active + 1) + ' of 4';
$: band = nps >= 9 ? 'a promoter' : nps >= 7 ? 'passive' : 'a detractor';
$: summary =
'You rated Meridian ' + rating + '/5 and gave it an NPS of ' + nps +
' (' + band + ').' + (contact ? ' We will follow up by email.' : '');
// detail.selected is the post-click state; preventDefault() vetoes the
// flip, so re-clicking the chosen score cannot empty the scale.
function onNpsClick(e, score) {
if (e.detail.selected === false) {
e.preventDefault();
return;
}
nps = score;
}
</script>
<div class="survey">
<header>
<span>Quarterly product survey</span>
<span>{caption}</span>
</header>
<md-progress-indicator value={done ? 4 : active} max={4} complete={done}
label="Survey progress"></md-progress-indicator>
{#if !done}
<md-stepper label="Survey questions" finish-label="Submit survey"
nextDisabled={nextDisabled}
on:mdStepChange={(e) => (active = e.detail.index)}
on:mdComplete={() => (done = true)}>
<md-step label="Satisfaction" description="Overall experience">
<p>How satisfied are you with Meridian CRM overall?</p>
<md-rating size="lg" rating-label="Overall satisfaction" getLabel={labelFor}
on:mdChange={(e) => (rating = e.detail)}></md-rating>
</md-step>
<md-step label="Recommend" description="0-10 scale">
<p>How likely are you to recommend Meridian CRM to a colleague?</p>
<div role="group" aria-label="Likelihood to recommend, 0 to 10">
{#each scores as score (score)}
<md-button variant="outlined" size="xs" toggle value={String(score)}
selected={nps === score} on:mdClick={(e) => onNpsClick(e, score)}>{score}</md-button>
{/each}
</div>
</md-step>
<md-step label="Usage" description="How you work">
<div role="radiogroup" aria-label="Usage frequency">
{#each frequencies as f (f.value)}
<label>
<md-radio name="survey-frequency" value={f.value} checked={frequency === f.value}
on:mdChange={() => (frequency = f.value)}></md-radio>
{f.label}
</label>
{/each}
</div>
<md-multi-select label="Features you use regularly"
supporting-text="Optional — pick any that apply"
value={features} on:mdChange={(e) => (features = e.detail)}>
<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-multi-select>
</md-step>
<md-step label="Wrap-up" description="Almost done">
<p>How likely are you to renew your subscription next quarter?</p>
<md-slider min={0} max={100} step={10} stops value-indicator value={renew}
valueText={renew + ' percent'} aria-label="Likelihood to renew"
on:mdInput={(e) => (renew = e.detail.value)}></md-slider>
<label>
<md-checkbox checked={contact}
on:mdChange={(e) => (contact = e.detail.checked)}></md-checkbox>
You can contact me about this feedback
</label>
</md-step>
</md-stepper>
{:else}
<md-card variant="filled">
<span>Thanks — your feedback is in.</span>
<span>{summary}</span>
</md-card>
{/if}
</div>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-stepper | Owns 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-indicator | The 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-rating | Five-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-button | Eleven 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-slider | Renewal likelihood: step="10" with stops for honest increments, value-indicator for the always-visible bubble, value-text for the spoken units. |
md-radio | Usage frequency — four options sharing one name form the exclusive group with arrow-key selection and a roving tabstop. |
md-checkbox | Contact consent, wrapped in a <label> (the component has no slot) with supporting-text under the box. |
md-multi-select | Feature usage: several values from a list, shown as removable chips after the menu closes. |
md-card | The thank-you surface, filled variant, with a summary line computed from the answers. |
The details that make it production-grade
Section titled “The details that make it production-grade”- Two gates, one per failure mode.
next-disabledonly disables the built-in Continue button — it never blocks step-header clicks. Those are covered by the stepper’s linear mode, which keeps unreachable headersaria-disabledand out of the tab order until earlier steps complete. - The NPS row can never end up empty or double-selected.
mdClick’sdetail.selectedis the post-click state, andpreventDefault()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-drivenselectedwrite emits no events, so there is no feedback loop). - Progress announces itself. The stepper is a
navigationlandmark with a polite live region (“Step 2 of 4: Recommend, current”), and the progress bar carriesrole="progressbar"witharia-valuenow— two independent progress reports, both free. - The slider speaks units.
value-textis re-set on everymdInput, so a screen reader hears “60 percent” rather than a bare “60”;stopson astep="10"scale shows only increments a respondent can actually pick. - Completion is an animation, not a jump cut. Setting
completeon the progress indicator plays the M3 closing sequence and then self-hides; the indicator’s ownmdCompleteevent — notvaluereachingmax— is the hook if you need to sequence anything after it.
Variations
Section titled “Variations”- 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’scontentslot and be swapped onmdStepChange. - Server-side save per page: cancel
mdBeforeChange, setloadingon the stepper while the answer posts (this also disables Back, preventing a double-submit), then commit by assigningstepper.active = e.detail.index— never by callingnext()from the handler, which would re-enter it. - Five-point scales: for CSAT-style 1-5 questions, a
md-segmented-button-setis 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, andlazymounts only the active panel when steps carry heavy content.
Related
Section titled “Related”- Recipe: Checkout wizard — the same stepper spine driving a purchase flow with validation per step.
- Recipe: Product reviews —
md-ratingon the display side: aggregates, distributions, and read-only scores. - Component manuals:
md-stepperandmd-rating— full API, keyboard maps, and anti-pattern lists.