An ordered native form that asks one question at a time, with choices, free text, skipping and submission.
<tp-questionnaire>
Questionnaire
Default density
Compact density
Restart from the initial state
Show the copyable source
<div style="inline-size:28rem;max-inline-size:100%">
<tp-questionnaire id="questionnaire-hero" label="Project kickoff" shortcut-mode="letters"></tp-questionnaire>
</div>
<script type="module">
const questionnaire = document.querySelector('#questionnaire-hero');
questionnaire.questions = [
{
name: 'goal',
title: 'What should this project deliver first?',
description: 'Choose one, or describe something else.',
required: true,
choices: [
{ value: 'onboarding', label: 'Faster onboarding', description: 'Get new accounts to their first project sooner.' },
{ value: 'billing', label: 'Self-serve billing', description: 'Let admins change plans without contacting sales.' },
{ value: 'reporting', label: 'Usage reports', description: 'Show teams where their seats and credits go.' },
],
input: { label: 'Something else', placeholder: 'Describe another goal…' },
},
{
name: 'signals',
title: 'What should every status update include?',
description: 'Select all that apply, or skip this question.',
kind: 'multiple',
skippable: true,
choices: [
{ value: 'progress', label: 'Progress' },
{ value: 'decisions', label: 'Decisions' },
{ value: 'risks', label: 'Risks' },
{ value: 'next-step', label: 'Next step' },
],
},
{
name: 'start',
title: 'When should work begin?',
required: true,
choices: [
{ value: 'now', label: 'This week' },
{ value: 'next-cycle', label: 'Next cycle' },
{ value: 'backlog', label: 'Add it to the backlog' },
],
},
];
questionnaire.actions = { submit: { label: 'Save plan' } };
questionnaire.addEventListener('tp-submit', (event) => event.preventDefault());
</script>
Usage
A Questionnaire walks people through a short, ordered set of questions, one at a time, inside a single native form. Use it for intake flows, setup wizards and feedback where focusing on one question beats a long page of fields. Questions are data: assign the questions property, then handle tp-submit. The host owns saving and sending the answers.
It must not be nested inside another native form.
Answers
Single-choice questions render native radios and kind: 'multiple' renders checkboxes. An input adds a freeform answer beside the choices, and a question without choices is a plain text question.
Multiple answers retain native checkbox semantics and repeated FormData values. A mixed single answer selects either a choice or entered text; an unselected text draft is preserved but has no successful form name. Multiple answers can combine fixed values and entered text. Selecting a fixed answer preserves the unselected text draft.
Choices and free text
Default density
Compact density
Restart from the initial state
Show the copyable source
<div style="inline-size:28rem;max-inline-size:100%">
<tp-questionnaire id="questionnaire-answers" label="Context sources"></tp-questionnaire>
</div>
<script type="module">
const questionnaire = document.querySelector('#questionnaire-answers');
questionnaire.questions = [
{
name: 'sources',
title: 'Which sources should the assistant read?',
description: 'Select every source it may use.',
kind: 'multiple',
required: true,
choices: [
{ value: 'docs', label: 'Product docs', defaultChecked: true },
{ value: 'tickets', label: 'Support tickets' },
{ value: 'changelog', label: 'Changelog' },
],
input: { label: 'Another source', placeholder: 'Paste a URL…', type: 'url' },
},
{
name: 'tone',
title: 'Describe the tone of its replies',
required: true,
input: { label: 'Tone', placeholder: 'Friendly, concise, no jargon…' },
},
];
questionnaire.addEventListener('tp-submit', (event) => event.preventDefault());
</script>
Skipping and validation
required questions must be answered before Next. Optional questions can offer Skip with skippable; skipped answers are left out of the submitted data. validate(answer) returns an error message for answers that are present but wrong.
Intentional skip clears its answer and is distinct from leaving it unanswered. Empty or whitespace-only text and empty multiple sets are unanswered. Entering/selecting an answer clears skipped status. Answer presence and validity are separate: nonempty invalid text is still answered.
Skip and validate
Default density
Compact density
Restart from the initial state
Show the copyable source
<div style="inline-size:28rem;max-inline-size:100%">
<tp-questionnaire id="questionnaire-skip" label="Workspace setup"></tp-questionnaire>
</div>
<script type="module">
const questionnaire = document.querySelector('#questionnaire-skip');
questionnaire.questions = [
{
name: 'constraints',
title: 'Any constraints we should know about?',
description: 'Optional. Skip if there are none.',
skippable: true,
input: { label: 'Constraints', placeholder: 'Must stay on the EU region…' },
},
{
name: 'seats',
title: 'How many seats do you need?',
required: true,
input: { label: 'Seats', type: 'number', placeholder: '12' },
validate: (answer) => (Number(answer) > 500 ? 'For more than 500 seats, contact sales.' : null),
},
];
questionnaire.actions = { submit: { label: 'Create workspace' } };
questionnaire.addEventListener('tp-submit', (event) => event.preventDefault());
</script>
Saved progress
To resume a draft, give choices defaultChecked, inputs a defaultValue, and set default-item to the question to open on. These defaults are what reset() and a native form reset restore.
A restored active item alone does not create saved answers; supply defaultValue, per-question defaults, or per-answer defaults as appropriate.
Resume a draft
Default density
Compact density
Restart from the initial state
Show the copyable source
Discard changes
Discard changes
<div style="display:grid;gap:var(--tp-space-4);justify-items:start;inline-size:28rem;max-inline-size:100%">
<tp-questionnaire id="questionnaire-resume" label="Migration plan" default-item="verification" style="inline-size:100%"></tp-questionnaire>
<tp-button id="questionnaire-resume-reset" variant="outline" size="sm">Discard changes</tp-button>
</div>
<script type="module">
const questionnaire = document.querySelector('#questionnaire-resume');
questionnaire.questions = [
{
name: 'approach',
title: 'How should the database move?',
required: true,
choices: [
{ value: 'incremental', label: 'Incremental migration', defaultChecked: true },
{ value: 'cutover', label: 'Single cutover' },
],
},
{
name: 'verification',
title: 'How should the migration be verified?',
description: 'Saved in your draft on Oct 12.',
kind: 'multiple',
required: true,
choices: [
{ value: 'tests', label: 'Run migration tests', defaultChecked: true },
{ value: 'row-counts', label: 'Compare row counts', defaultChecked: true },
{ value: 'smoke', label: 'Manual smoke test' },
],
},
{
name: 'notes',
title: 'Anything else the team should know?',
skippable: true,
input: { label: 'Notes', defaultValue: 'Keep the public API stable during the move.' },
},
];
questionnaire.actions = { submit: { label: 'Update draft' } };
questionnaire.addEventListener('tp-submit', (event) => event.preventDefault());
document.querySelector('#questionnaire-resume-reset').addEventListener('click', () => questionnaire.reset());
</script>
Conditional questions
A question with disabled: true drops out of navigation, progress, validation and submission. Listen for tp-value-change and reassign questions to include follow-ups only when an answer calls for them.
Follow-up question
Default density
Compact density
Restart from the initial state
Show the copyable source
shortcut-mode="letters" or "numbers" assigns a key to each enabled choice and shows it as a Kbd. Pressing the key selects the answer; Enter confirms and moves on.
Text entry retains normal editing. Native radio navigation and input editing remain native. Outside those paths, Up/Down move answer focus, the backward inline arrow (Left in left-to-right, Right in right-to-left writing) moves back, the forward inline arrow advances an answered valid question, and Enter on a filled answer or modified Enter validates and confirms. Shortcuts focus and activate enabled choices. Repeated commands, composition, consumed keys and arrows carrying Alt, Ctrl or Meta do not trigger navigation. New questions receive the selected/filled answer, first enabled control, or fieldset focus, in that order. Invalid filled inputs receive native feedback when enabled.
Number shortcuts
Default density
Compact density
Restart from the initial state
Show the copyable source
tp-submit carries the logical answers and the native FormData. Prevent its default to handle the submission yourself instead of navigating.
FormData retains prior active answers and omits disabled, skipped and unselected draft answers. Failed submission activates and focuses the first invalid question.
Submit
Default density
Compact density
Restart from the initial state
Show the copyable source
Set item and accept moves in onItemChange to own which question is active, for example to keep it in the URL. Owners are fixed when the element connects, so create it in script and configure it first. Owning the checkpoint also lets a submit handler check answers against each other, mark a question invalid with an error, and send the person back to it.
With a controlled aggregate record, Root is the sole answer owner: do not also supply controlled choice.checked or input.value for those answers. Accept a controlled proposal by assigning its proposed value synchronously; ignoring it rejects the change.
Checkpoint and cross-answer validation
Default density
Compact density
Restart from the initial state
Show the copyable source
actions sets the label, variant, size, disabled and hidden of Previous, Skip, Next and Submit. Derive availability from status in each question’s onStatusChange and after tp-item-change, here to keep Next and Submit disabled until the current question has an answer.
disabled and hidden can further restrict the component’s own availability; they cannot enable an inapplicable action. Replace the record to update it. Disabled buttons remain disabled; this does not change the validation or programmatic navigation policy.
Disabled until answered
Default density
Compact density
Restart from the initial state
Show the copyable source
The questionnaire-progress part takes content for different wording, or a renderDelegate that draws its own indicator from state.current and state.total. Bind bind to the element you return so it keeps the part’s semantics.
questionnaire.partContracts = {
'questionnaire-progress': {
content: ({ current, total }) => `Checkpoint ${current} of ${total}`,
},
};
Progress bar
Default density
Compact density
Restart from the initial state
Show the copyable source
The questionnaire-question part’s elementReference runs each time a new question becomes active, while progress and buttons stay in place. Animate that element only, scale the duration by the inherited --tp-motion-scale so reduced motion turns it off, and cancel the animation when the reference changes.
Do not animate each answer update or delay focus until the animation finishes.
Animated questions
Default density
Compact density
Restart from the initial state
Show the copyable source
Delegates can rearrange the parts Questionnaire has already rendered. Each part’s state.regions holds them, so a Card can take the title, description and progress in its header slots, the choices in its body and the buttons in its footer, with the fieldset naming and native form untouched. Render each region once.
choices already includes input; render input separately only when replacing the default Choices composition. Keep native inputs in the form’s DOM tree; don’t move them into a new shadow root or a second form.
Inside a Card
Default density
Compact density
Restart from the initial state
Show the copyable source
For a short clarification flow, put the Questionnaire in a Dialog. The Dialog owns cancelling, dismissal and returning focus; the Questionnaire owns answers and navigation, and your submit handler closes the Dialog.
Clarification dialog
Default density
Compact density
Restart from the initial state
Show the copyable source
AttributePropertyTypeDefaultDescriptionflowflowQuestionnaireFlow'linear'How people move between questions: linear lets them advance only from a valid or skipped
question, free lets them visit any question.choice-modechoiceModeQuestionnaireChoiceMode'single'Whether a question with choices takes one answer or several, unless the question sets its own
kind.skippableskippablebooleanfalseLets people skip optional questions unless a question says otherwise; required questions are
never skippable.itemitemstring—The name of the question on screen. Set it to own navigation: moves then arrive as proposals in
onItemChange.default-itemdefaultItemstring''The question to start at, such as where a saved draft left off; otherwise the first enabled
question.shortcut-modeshortcutModeQuestionnaireShortcutMode'none'Assigns letter or number key shortcuts to the choices, shown beside each one.native-validationnativeValidation'enabled' | 'suppressed''enabled'Whether the browser's own validation messages may show; invalid answers block progress either
way.labellabelstring'Questionnaire'The accessible name of the form.
Properties
PropertyTypeDefaultDescriptionquestionsreadonly QuestionnaireQuestion[][]The ordered question definitions: each has a name and title, and optionally choices, a
free-text input, a description, required and skip rules, and its own validation.actionsQuestionnaireActions{}Overrides for the Previous, Skip, Next and Submit Buttons: each entry can set the label, size
and paint, or disable or hide the Button.valueQuestionnaireAnswers—The answers, keyed by question name. Set it to own them: answers then arrive as proposals in
onValueChange, and the Questionnaire shows only what you assign.defaultValueQuestionnaireAnswers{}Answers at first render and after a reset, when the page does not own value, such as answers
restored from a saved draft.onValueChange((event: TpValueChangeEvent<QuestionnaireAnswers>) => void) | undefined—Called with each proposed answer record before it commits; assign value to accept it or
cancel the event to reject it.onItemChange((event: TpValueChangeEvent<string>) => void) | undefined—Called with each proposed move to another question; assign item to accept it or cancel the
event to reject it.answersread-onlyQuestionnaireAnswers—A copy of the committed answers, keyed by question name.currentItemread-onlystring—The name of the question on screen.currentread-onlynumber—The position of the question on screen among the enabled questions, counting from one; 0 when
there is none.totalread-onlynumber—The number of enabled questions.firstread-onlyboolean—Whether the question on screen is the first one.lastread-onlyboolean—Whether the question on screen is the last one.statusread-onlyQuestionnaireStatus | undefined—Whether the question on screen is unanswered, answered or skipped.
Events
EventTypeDescriptiontp-value-changeTpValueChangeEvent<QuestionnaireAnswers>Cancelable proposal of new answers before they commit; detail.value is the whole answer record, with detail.previousValue and the detail.reason.tp-item-changeTpValueChangeEvent<string>Cancelable proposal to show another question; detail.value is the question's name.tp-answer-changeTpValueChangeEvent<string | boolean>Cancelable proposal of one answer when value is not owned by the page; detail.question names the question and detail.choice the choice, if any.tp-submitCustomEvent<QuestionnaireSubmitDetail>Cancelable, after every answer passes validation and before the native submission; detail.answers holds the answers, with the native form and its data.
Methods
MethodReturnsDescriptionsetAnswer(name, answer, sourceEvent?)booleanProposes an answer to the named question as if the person had given it; returns whether it was
accepted.setItem(name, sourceEvent?)booleanProposes moving to the named question under the flow rules; returns whether the move was
accepted.requestSubmit()voidValidates every question and submits, as the Submit Button does.reset()voidRestores the default answers and the starting question, as a native form reset does.
CSS parts
PartDescription::part(questionnaire)The native form that holds the question on screen and the submitted answers.