View as Markdown

Questionnaire

A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions.

100%
Loading...
<twig:Questionnaire id="demo" defaultItem="direction" shortcuts="letters" class="mx-auto max-w-md">
    <twig:Questionnaire:Progress />

    <twig:Questionnaire:Item name="direction" required>
        <twig:Questionnaire:Title>What should the agent build next?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Choose a direction or describe another task.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="tool-calls">
                <span class="font-medium">Tool call timeline</span>
                <twig:Questionnaire:ChoiceDescription>Show what the agent ran and what came back.</twig:Questionnaire:ChoiceDescription>
            </twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="approvals">
                <span class="font-medium">Approval checkpoints</span>
                <twig:Questionnaire:ChoiceDescription>Ask before sensitive or destructive actions.</twig:Questionnaire:ChoiceDescription>
            </twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="handoffs">
                <span class="font-medium">Sub-agent handoffs</span>
                <twig:Questionnaire:ChoiceDescription>Make delegated work and results easier to follow.</twig:Questionnaire:ChoiceDescription>
            </twig:Questionnaire:Choice>
            <twig:Questionnaire:Input aria-label="Another agent feature" placeholder="Describe another feature…" />
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="signals" multiple>
        <twig:Questionnaire:Title>What should every progress update include?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Select all that apply, or skip this question.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="progress">Progress</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="decisions">Decisions</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="risks">Risks</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="next-step">Next step</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="timing" required>
        <twig:Questionnaire:Title>When should work begin?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Choose when the agent should begin the work.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="now">Start now</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="next-cycle">Next development cycle</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="backlog">Add it to the backlog</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Skip />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit>Save plan</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Installation

Note

Available since UX Toolkit 3.5.

php bin/console ux:install questionnaire --kit shadcn

Install the following Composer dependencies:

composer require twig/html-extra:^3.24.0 symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0 twig/extra-bundle symfony/ux-icons

Copy the following file(s) into your app:

assets/controllers/questionnaire_controller.js
import { Controller } from '@hotwired/stimulus';

const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';

export default class extends Controller {
    static targets = [
        'item',
        'progress',
        'progressCurrent',
        'progressTotal',
        'progressStep',
        'answer',
        'choice',
        'choiceLabel',
        'shortcutBadge',
        'freeform',
        'error',
        'previous',
        'skip',
        'next',
        'submit',
    ];
    static values = { activeItem: String, shortcuts: String };

    // The freeform input shares its name with the fixed choices, so while it is empty its name is
    // parked here to keep it from submitting a value that shadows the selected answer.
    _freeformNames = new WeakMap();

    freeformTargetConnected(input) {
        this._freeformNames.set(input, input.getAttribute('name'));
        this._syncFreeformName(input);
    }

    connect() {
        this._assignShortcuts();

        if (!this._itemByName(this.activeItemValue)) {
            const [first] = this._navigableItems();
            this.activeItemValue = first ? first.dataset.name : '';
        }

        this._render();
    }

    activeItemValueChanged() {
        this._render();
    }

    next() {
        if (!this._validate(this.activeItem)) return;

        // Only promote to `answered`, so navigating past a skipped item does not undo the skip.
        if (this._isAnswered(this.activeItem)) {
            this._setStatus(this.activeItem, 'answered');
        }
        this._move(1);
    }

    previous() {
        this._clearError(this.activeItem);
        this._move(-1);
    }

    skip() {
        const item = this.activeItem;
        if (!item) return;

        this._reset(item);
        this._setStatus(item, 'skipped');
        this._clearError(item);
        this._move(1);
    }

    onSubmit(event) {
        if (!this._validate(this.activeItem)) {
            event.preventDefault();
            return;
        }

        if (this._isAnswered(this.activeItem)) {
            this._setStatus(this.activeItem, 'answered');
        }
    }

    onAnswerChange(event) {
        if (!this.answerTargets.includes(event.target)) return;

        const item = this._itemOf(event.target);
        if (!item) return;

        if (this.choiceTargets.includes(event.target)) {
            this._clearFreeform(item);
        }

        this._setStatus(item, this._isAnswered(item) ? 'answered' : 'unanswered');
        this._clearError(item);
        this._render();
    }

    onAnswerInput(event) {
        const input = event.target;
        if (!this.freeformTargets.includes(input)) return;

        const item = this._itemOf(input);
        if (!item) return;

        this._syncFreeformName(input);

        if (input.value && item.dataset.multiple !== 'true') {
            this._choicesOf(item).forEach((choice) => (choice.checked = false));
        }

        this._setStatus(item, this._isAnswered(item) ? 'answered' : 'unanswered');
        this._clearError(item);
        this._render();
    }

    onKeyDown(event) {
        if (event.metaKey || event.ctrlKey || event.altKey) return;

        const item = this.activeItem;
        if (!item) return;

        if (event.key === 'Enter') {
            if (event.target.type === 'submit') return;

            event.preventDefault();
            if (this._isLast(item)) {
                this._form?.requestSubmit();
            } else {
                this.next();
            }
            return;
        }

        if (this.freeformTargets.includes(event.target)) return;

        const choice = this._choicesOf(item).find(
            (input) => this._labelOf(input)?.dataset.shortcut === event.key.toUpperCase()
        );
        if (!choice || choice.disabled) return;

        event.preventDefault();
        choice.checked = item.dataset.multiple === 'true' ? !choice.checked : true;
        choice.dispatchEvent(new Event('change', { bubbles: true }));
    }

    get activeItem() {
        return this._itemByName(this.activeItemValue);
    }

    // The root is a form on its own, or a div nested in the form that owns submission.
    get _form() {
        return this.element.closest('form');
    }

    _move(offset) {
        const items = this._navigableItems();
        const index = items.indexOf(this.activeItem);
        const target = items[index + offset];

        if (target) {
            this.activeItemValue = target.dataset.name;
        }
    }

    _render() {
        const items = this._navigableItems();
        const active = this.activeItem;

        this.itemTargets.forEach((item) => {
            const isActive = item === active;
            item.dataset.active = isActive ? 'true' : 'false';
            item.toggleAttribute('hidden', !isActive);
            item.toggleAttribute('inert', !isActive);
        });

        const current = Math.max(items.indexOf(active) + 1, 1);
        const total = Math.max(items.length, 1);

        this.progressTargets.forEach((progress) => {
            progress.setAttribute('aria-valuenow', String(current));
            progress.setAttribute('aria-valuemax', String(total));
        });
        this.progressCurrentTargets.forEach((element) => (element.textContent = String(current)));
        this.progressTotalTargets.forEach((element) => (element.textContent = String(total)));
        this.progressStepTargets.forEach((step, index) => {
            step.dataset.active = index < current ? 'true' : 'false';
        });

        const first = active === items[0];
        const last = this._isLast(active);

        this._toggle(this.previousTargets, !first);
        this._toggle(this.skipTargets, Boolean(active) && active.dataset.required !== 'true');
        this._toggle(this.nextTargets, !last);
        this._toggle(this.submitTargets, last);
    }

    _toggle(elements, visible) {
        elements.forEach((element) => {
            element.toggleAttribute('hidden', !visible);
            element.disabled = !visible;
        });
    }

    _validate(item) {
        if (!item || item.dataset.required !== 'true' || this._isAnswered(item)) return true;

        this._showError(item);

        return false;
    }

    _showError(item) {
        item.setAttribute('aria-invalid', 'true');
        this._markInvalid(item, true);

        const [error] = this._errorsOf(item);
        if (error) {
            error.removeAttribute('hidden');
            item.setAttribute('aria-describedby', error.id);
        }

        const [answer] = this._answersOf(item).filter((input) => !input.disabled);
        // A programmatic focus never matches `:focus-visible`, so the ring is asked for explicitly.
        answer?.focus({ focusVisible: true });
    }

    _markInvalid(item, invalid) {
        for (const label of this.choiceLabelTargets) {
            if (item.contains(label)) {
                label.dataset.invalid = String(invalid);
            }
        }

        for (const input of this._freeformOf(item)) {
            if (invalid) {
                input.setAttribute('aria-invalid', 'true');
            } else {
                input.removeAttribute('aria-invalid');
            }
        }
    }

    _clearError(item) {
        if (!item || !item.hasAttribute('aria-invalid')) return;

        item.removeAttribute('aria-invalid');
        item.removeAttribute('aria-describedby');
        this._markInvalid(item, false);
        this._errorsOf(item).forEach((error) => error.setAttribute('hidden', ''));
    }

    _reset(item) {
        this._choicesOf(item).forEach((choice) => (choice.checked = false));
        this._clearFreeform(item);
    }

    _clearFreeform(item) {
        this._freeformOf(item).forEach((input) => {
            input.value = '';
            this._syncFreeformName(input);
        });
    }

    _syncFreeformName(input) {
        const name = this._freeformNames.get(input);
        if (!name) return;

        if (input.value.trim()) {
            input.setAttribute('name', name);
        } else {
            input.removeAttribute('name');
        }
    }

    _isAnswered(item) {
        return (
            this._choicesOf(item).some((choice) => choice.checked) ||
            this._freeformOf(item).some((input) => input.value.trim())
        );
    }

    _isLast(item) {
        const items = this._navigableItems();

        return !item || item === items[items.length - 1];
    }

    _setStatus(item, status) {
        item.dataset.status = status;
    }

    _assignShortcuts() {
        const mode = this.shortcutsValue;
        if ('letters' !== mode && 'numbers' !== mode) return;

        this.itemTargets.forEach((item) => {
            this._choicesOf(item).forEach((choice, index) => {
                const label = this._labelOf(choice);
                if (!label) return;

                const shortcut = mode === 'letters' ? LETTERS[index] : mode === 'numbers' ? String(index + 1) : null;

                if (!shortcut) {
                    delete label.dataset.shortcut;

                    return;
                }

                label.dataset.shortcut = shortcut;
                const badge = this.shortcutBadgeTargets.find((element) => label.contains(element));
                if (badge) {
                    badge.textContent = shortcut;
                }
            });
        });
    }

    _navigableItems() {
        return this.itemTargets.filter((item) => !item.disabled);
    }

    _itemByName(name) {
        return name ? this._navigableItems().find((item) => item.dataset.name === name) : undefined;
    }

    _itemOf(element) {
        return this.itemTargets.find((item) => item.contains(element));
    }

    _answersOf(item) {
        return this.answerTargets.filter((input) => item.contains(input));
    }

    _choicesOf(item) {
        return this.choiceTargets.filter((input) => item.contains(input));
    }

    _labelOf(choice) {
        return this.choiceLabelTargets.find((label) => label.contains(choice)) ?? null;
    }

    _freeformOf(item) {
        return this.freeformTargets.filter((input) => item.contains(input));
    }

    _errorsOf(item) {
        return this.errorTargets.filter((error) => item.contains(error));
    }
}
templates/components/Questionnaire.html.twig
{%- props
    ## string Unique identifier used to generate internal Questionnaire IDs.
    id = 'questionnaire',
    ## 'form'|'div' The HTML tag to render, `div` to nest the questionnaire inside an existing form.
    as = 'form',
    ## string Name of the `Questionnaire:Item` shown on initial render.
    defaultItem = '',
    ## 'letters'|'numbers'|null Keyboard shortcut style assigned to each answer.
    shortcuts = null,
    ## boolean Whether the Stimulus controller drives navigation, validation and shortcuts.
    interactive = true
-%}
{%- do provide('questionnaire.id', id) -%}
{%- do provide('questionnaire.defaultItem', defaultItem) -%}
{%- do provide('questionnaire.interactive', interactive) -%}
{# A `div` never receives the `submit` event of the form it is nested in, so that action is dropped. #}
{%- set _questionnaire_actions = as == 'form'
    ? 'keydown->questionnaire#onKeyDown submit->questionnaire#onSubmit change->questionnaire#onAnswerChange input->questionnaire#onAnswerInput'
    : 'keydown->questionnaire#onKeyDown change->questionnaire#onAnswerChange input->questionnaire#onAnswerInput' -%}
<{{ as }}
    id="{{ id }}"
    data-slot="questionnaire"
    {% if interactive %}
        data-questionnaire-active-item-value="{{ defaultItem }}"
        data-questionnaire-shortcuts-value="{{ shortcuts }}"
    {% endif %}
    {{ attributes.defaults({
        class: 'flex w-full min-w-0 flex-col gap-4'|tailwind_classes,
        'data-controller': interactive ? 'questionnaire' : false,
        'data-action': interactive ? _questionnaire_actions : false,
    }) }}
>
    {##- The questionnaire structure, typically includes `Questionnaire:Progress`, `Questionnaire:Item` and `Questionnaire:Actions`. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/Questionnaire/Actions.html.twig
<div
    data-slot="questionnaire-actions"
    {{ attributes.defaults({
        class: 'grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2 sm:min-h-8'|tailwind_classes,
    }) }}
>
    {##- The navigation buttons, typically `Questionnaire:Previous`, `Questionnaire:Skip`, `Questionnaire:Next` and `Questionnaire:Submit`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Questionnaire/Choice.html.twig
{%- props
    ## string The value submitted when this choice is selected.
    value,
    ## boolean Whether the choice is selected on initial render.
    checked = false,
    ## boolean Whether the choice can be selected.
    disabled = false
-%}
{%- set _questionnaire_id = inject('questionnaire.id', 'questionnaire') -%}
{%- set _questionnaire_itemName = inject('questionnaire.itemName', '') -%}
{%- set _questionnaire_itemInputName = inject('questionnaire.itemInputName', '') -%}
{%- set _questionnaire_itemMultiple = inject('questionnaire.itemMultiple', false) -%}
{%- set _id = _questionnaire_id ~ '-' ~ _questionnaire_itemName ~ '-' ~ value -%}
{# `outerBlocks` only reaches one component deep, so the content is captured before the nested calls. #}
{%- set _label -%}
    {##- The choice label, optionally followed by a `Questionnaire:ChoiceDescription`. -#}
    {%- block content %}{% endblock -%}
{%- endset -%}
<twig:Field:Label
    for="{{ _id }}"
    data-questionnaire-target="choiceLabel"
    data-type="{{ _questionnaire_itemMultiple ? 'checkbox' : 'radio' }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    data-invalid="false"
    {{ ...attributes.defaults({
        class: 'group/questionnaire-choice min-h-11 cursor-pointer transition-colors hover:bg-muted/50 data-[invalid=true]:border-destructive data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50'|tailwind_classes,
    }) }}
>
    <twig:Field orientation="horizontal">
        <twig:Field:Content>{{ _label }}</twig:Field:Content>
        {%- if _questionnaire_itemMultiple -%}
            <twig:Checkbox
                id="{{ _id }}"
                name="{{ _questionnaire_itemInputName }}"
                value="{{ value }}"
                data-questionnaire-target="answer choice"
                :checked="checked"
                :disabled="disabled"
            />
        {%- else -%}
            <twig:RadioGroup:Item
                id="{{ _id }}"
                name="{{ _questionnaire_itemInputName }}"
                value="{{ value }}"
                data-questionnaire-target="answer choice"
                :checked="checked"
                :disabled="disabled"
            />
        {%- endif -%}
        <twig:Kbd
            data-questionnaire-target="shortcutBadge"
            aria-hidden="true"
            class="ms-auto hidden group-data-[shortcut]/questionnaire-choice:inline-flex"
        />
    </twig:Field>
</twig:Field:Label>
templates/components/Questionnaire/ChoiceDescription.html.twig
<twig:Field:Description
    data-questionnaire-target="choiceDescription"
    {{ ...attributes }}
>
    {##- The secondary text describing the choice. -#}
    {{- block(outerBlocks.content) -}}
</twig:Field:Description>
templates/components/Questionnaire/Choices.html.twig
{%- set _questionnaire_itemMultiple = inject('questionnaire.itemMultiple', false) -%}
{%- if _questionnaire_itemMultiple -%}
    <twig:Field:Group
        {{ ...attributes.defaults({
            class: 'group/questionnaire-choices min-w-0 gap-2'|tailwind_classes,
        }) }}
    >
        {##- The answers, typically multiple `Questionnaire:Choice` components and an optional `Questionnaire:Input`. -#}
        {{- block(outerBlocks.content) -}}
    </twig:Field:Group>
{%- else -%}
    <twig:RadioGroup
        {{ ...attributes.defaults({
            class: 'group/questionnaire-choices min-w-0 gap-2'|tailwind_classes,
        }) }}
    >
        {##- The answers, typically multiple `Questionnaire:Choice` components and an optional `Questionnaire:Input`. -#}
        {{- block(outerBlocks.content) -}}
    </twig:RadioGroup>
{%- endif -%}
templates/components/Questionnaire/Description.html.twig
<p
    data-slot="questionnaire-description"
    {{ attributes.defaults({
        class: 'text-sm text-muted-foreground text-pretty'|tailwind_classes,
    }) }}
>
    {##- The supporting text shown under the question prompt. -#}
    {%- block content %}{% endblock -%}
</p>
templates/components/Questionnaire/Error.html.twig
{%- set _questionnaire_itemErrorId = inject('questionnaire.itemErrorId', '') -%}
{%- set _questionnaire_itemRequired = inject('questionnaire.itemRequired', false) -%}
{%- set _questionnaire_interactive = inject('questionnaire.interactive', true) -%}
<p
    id="{{ _questionnaire_itemErrorId }}"
    data-slot="questionnaire-error"
    {% if _questionnaire_interactive %}data-questionnaire-target="error" hidden{% endif %}
    role="alert"
    {{ attributes.defaults({
        class: 'mt-2 text-sm text-destructive'|tailwind_classes,
    }) }}
>
    {##- The validation message, replacing the wording the item's `required` prop selects. -#}
    {%- block content -%}
        {{- _questionnaire_itemRequired ? 'Choose an answer to continue.' : 'Choose an answer or skip this question.' -}}
    {%- endblock -%}
</p>
templates/components/Questionnaire/Input.html.twig
{%- set _questionnaire_itemInputName = inject('questionnaire.itemInputName', '') -%}
{%- set _questionnaire_interactive = inject('questionnaire.interactive', true) -%}
<div
    data-slot="questionnaire-input-wrapper"
    class="group/questionnaire-input relative w-full min-w-0"
>
    <input
        data-slot="questionnaire-input"
        {% if _questionnaire_interactive %}data-questionnaire-target="answer freeform"{% endif %}
        {{ attributes.defaults({
            class: 'min-h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-[color,box-shadow,background-color] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 sm:min-h-0 sm:h-8 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40'|tailwind_classes,
            type: 'text',
            name: _questionnaire_itemInputName,
        }) }}
    >
</div>
templates/components/Questionnaire/Item.html.twig
{%- props
    ## string Name submitted with the answer, also identifying the item during navigation.
    name,
    ## boolean Whether an answer is required before leaving the item.
    required = false,
    ## boolean Whether the item accepts more than one answer.
    multiple = false,
    ## boolean Whether the item is inert and skipped by navigation.
    disabled = false
-%}
{%- set _questionnaire_id = inject('questionnaire.id', 'questionnaire') -%}
{%- set _questionnaire_defaultItem = inject('questionnaire.defaultItem', '') -%}
{%- set _questionnaire_interactive = inject('questionnaire.interactive', true) -%}
{# Without the controller there is no JS to reveal the first item, so an unset `defaultItem` shows them all. #}
{%- set _active = _questionnaire_interactive
    ? _questionnaire_defaultItem == name
    : (_questionnaire_defaultItem is empty or _questionnaire_defaultItem == name) -%}
{%- do provide('questionnaire.itemName', name) -%}
{%- do provide('questionnaire.itemInputName', name ~ (multiple ? '[]' : '')) -%}
{%- do provide('questionnaire.itemMultiple', multiple) -%}
{%- do provide('questionnaire.itemRequired', required) -%}
{%- do provide('questionnaire.itemErrorId', _questionnaire_id ~ '-' ~ name ~ '-error') -%}
<fieldset
    data-slot="questionnaire-item"
    {% if _questionnaire_interactive %}data-questionnaire-target="item"{% endif %}
    data-name="{{ name }}"
    data-multiple="{{ multiple ? 'true' : 'false' }}"
    data-required="{{ required ? 'true' : 'false' }}"
    data-status="unanswered"
    data-active="{{ _active ? 'true' : 'false' }}"
    {% if disabled %}disabled{% endif %}
    {% if not _active %}hidden inert{% endif %}
    {{ attributes.defaults({
        class: 'flex min-w-0 flex-col gap-4 border-0 p-0 outline-none [&[hidden]]:hidden'|tailwind_classes,
    }) }}
>
    {##- The item structure, typically includes `Questionnaire:Title`, `Questionnaire:Choices` and `Questionnaire:Error`. -#}
    {%- block content %}{% endblock -%}
</fieldset>
templates/components/Questionnaire/Next.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default'
-%}
{%- set _questionnaire_interactive = inject('questionnaire.interactive', true) -%}
{# Without the controller a `type="button"` is inert, so the button submits the step to the server instead. #}
{%- set _questionnaire_next_attrs = _questionnaire_interactive ? {
    'data-questionnaire-target': 'next',
    'data-action': 'click->questionnaire#next',
} : {type: 'submit'} -%}
<twig:Button
    variant="{{ variant }}"
    size="{{ size }}"
    data-slot="questionnaire-next"
    {{ ..._questionnaire_next_attrs }}
    {{ ...attributes.defaults({class: 'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0 [&[hidden]]:hidden'|tailwind_classes}) }}
>
    {##- The button label. -#}
    {{- block(outerBlocks.content)|default('Next') -}}
</twig:Button>
templates/components/Questionnaire/Previous.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
    variant = 'outline',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default'
-%}
{%- set _questionnaire_interactive = inject('questionnaire.interactive', true) -%}
{# Without the controller a `type="button"` is inert, so the button submits the step to the server instead. #}
{%- set _questionnaire_previous_attrs = _questionnaire_interactive ? {
    'data-questionnaire-target': 'previous',
    'data-action': 'click->questionnaire#previous',
} : {type: 'submit'} -%}
<twig:Button
    variant="{{ variant }}"
    size="{{ size }}"
    data-slot="questionnaire-previous"
    {{ ..._questionnaire_previous_attrs }}
    {{ ...attributes.defaults({class: 'col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0 [&[hidden]]:hidden'|tailwind_classes}) }}
>
    {##- The button label. -#}
    {{- block(outerBlocks.content)|default('Previous') -}}
</twig:Button>
templates/components/Questionnaire/Progress.html.twig
{%- props
    ## int|null The 1-based position of the active question.
    current = null,
    ## int|null The number of navigable questions.
    total = null
-%}
{%- set _questionnaire_interactive = inject('questionnaire.interactive', true) -%}
{%- set _current = current ?? 1 -%}
{%- set _total = total ?? 1 -%}
<div
    data-slot="questionnaire-progress"
    {% if _questionnaire_interactive %}data-questionnaire-target="progress"{% endif %}
    role="progressbar"
    aria-label="Questionnaire progress"
    aria-valuemin="1"
    aria-valuenow="{{ _current }}"
    aria-valuemax="{{ _total }}"
    {{ attributes.defaults({
        class: 'min-h-[1lh] w-fit min-w-[14ch] text-xs font-medium text-muted-foreground tabular-nums'|tailwind_classes,
    }) }}
>
    {##- The progress label, replacing the default "Question X of Y" wording. -#}
    {%- block content -%}
        Question <span {% if _questionnaire_interactive %}data-questionnaire-target="progressCurrent"{% endif %}>{{ _current }}</span> of <span {% if _questionnaire_interactive %}data-questionnaire-target="progressTotal"{% endif %}>{{ _total }}</span>
    {%- endblock -%}
</div>
templates/components/Questionnaire/Skip.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
    variant = 'outline',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default'
-%}
{%- set _questionnaire_interactive = inject('questionnaire.interactive', true) -%}
{# Without the controller a `type="button"` is inert, so the button submits the step to the server instead. #}
{%- set _questionnaire_skip_attrs = _questionnaire_interactive ? {
    'data-questionnaire-target': 'skip',
    'data-action': 'click->questionnaire#skip',
} : {type: 'submit'} -%}
<twig:Button
    variant="{{ variant }}"
    size="{{ size }}"
    data-slot="questionnaire-skip"
    {{ ..._questionnaire_skip_attrs }}
    {{ ...attributes.defaults({class: 'col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0 [&[hidden]]:hidden'|tailwind_classes}) }}
>
    {##- The button label. -#}
    {{- block(outerBlocks.content)|default('Skip') -}}
</twig:Button>
templates/components/Questionnaire/Submit.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default'
-%}
{%- set _questionnaire_interactive = inject('questionnaire.interactive', true) -%}
{%- set _questionnaire_submit_attrs = _questionnaire_interactive ? {
    'data-questionnaire-target': 'submit',
} : {} -%}
<twig:Button
    variant="{{ variant }}"
    size="{{ size }}"
    type="submit"
    data-slot="questionnaire-submit"
    {{ ..._questionnaire_submit_attrs }}
    {{ ...attributes.defaults({class: 'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0 [&[hidden]]:hidden'|tailwind_classes}) }}
>
    {##- The button label. -#}
    {{- block(outerBlocks.content)|default('Submit') -}}
</twig:Button>
templates/components/Questionnaire/Title.html.twig
<legend
    data-slot="questionnaire-title"
    {{ attributes.defaults({
        class: 'cn-font-heading text-base leading-snug font-medium text-pretty [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4'|tailwind_classes,
    }) }}
>
    {##- The question prompt. -#}
    {%- block content %}{% endblock -%}
</legend>
templates/components/RadioGroup.html.twig
<div
    role="radiogroup"
    data-slot="radio-group"
    {{ attributes.defaults({
        class: 'grid w-full gap-2'|tailwind_classes,
    }) }}
>
    {##- The radio items, typically multiple `RadioGroup:Item` components with `Label`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/RadioGroup/Item.html.twig
{%- props
    ## string The name shared by all radio inputs in the same group.
    name,
    ## string The value submitted when this item is selected.
    value
-%}
<label class="{{ ('relative inline-flex size-4 shrink-0 items-center justify-center cursor-pointer group-data-[disabled=true]/field:cursor-not-allowed ' ~ attributes.render('class'))|tailwind_merge }}">
    <input
        type="radio"
        name="{{ name }}"
        value="{{ value }}"
        data-slot="radio-group-item"
        class="peer sr-only"
        {{ attributes.without('class') }}
    >
    <span class="flex aspect-square size-4 items-center justify-center rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 peer-focus-visible:border-ring peer-focus-visible:ring-3 peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 peer-aria-invalid:border-destructive peer-aria-invalid:ring-3 peer-aria-invalid:ring-destructive/20 peer-aria-invalid:peer-checked:border-primary dark:bg-input/30 dark:peer-aria-invalid:border-destructive/50 dark:peer-aria-invalid:ring-destructive/40 peer-checked:border-primary peer-checked:bg-primary peer-checked:text-primary-foreground dark:peer-checked:bg-primary"></span>
    <span class="pointer-events-none absolute top-1/2 start-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 scale-0 rounded-full bg-primary-foreground transition-transform peer-checked:scale-100"></span>
</label>
templates/components/Kbd.html.twig
<kbd
    data-slot="kbd"
    {{ attributes.defaults({
        class: ('pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*=\'size-\'])]:size-3 ')|tailwind_classes,
    }) }}
>
    {##- The keyboard key text (e.g., "Ctrl", "⌘", "Enter"). -#}
    {%- block content %}{% endblock -%}
</kbd>
templates/components/KbdGroup.html.twig
<kbd
    data-slot="kbd-group"
    {{ attributes.defaults({
        class: 'inline-flex items-center gap-1'|tailwind_classes,
    }) }}
>
    {##- The keyboard shortcut combination, typically multiple `Kbd` components. -#}
    {%- block content %}{% endblock -%}
</kbd>
templates/components/Field.html.twig
{%- props
    ## 'vertical'|'horizontal'|'responsive' The layout direction of the field.
    orientation = 'vertical'
-%}
{%- set style = html_cva(
    base: 'group/field flex w-full gap-2 data-[invalid=true]:text-destructive',
    variants: {
        orientation: {
            vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
            horizontal: 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
            responsive: 'flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
        },
    },
) -%}

<div
    role="group"
    data-slot="field"
    data-orientation="{{ orientation }}"
    {{ attributes.defaults({
        class: style.apply({orientation: orientation})|tailwind_classes,
    }) }}
>
    {##- The field content, typically includes `Field:Label` and form input(s). -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Field/Content.html.twig
<div
    data-slot="field-content"
    {{ attributes.defaults({
        class: 'group/field-content flex flex-1 flex-col gap-0.5 leading-snug'|tailwind_classes,
    }) }}
>
    {##- The input and supplementary elements like `Field:Description` and `Field:Error`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Field/Description.html.twig
<p
    data-slot="field-description"
    {{ attributes.defaults({
        class: 'text-start text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5 last:mt-0 nth-last-2:-mt-1 [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary'|tailwind_classes,
    }) }}
>
    {##- The helper text describing the field. -#}
    {%- block content %}{% endblock -%}
</p>
templates/components/Field/Error.html.twig
{%- props
    ## array A list of error messages (strings or objects with a `message` property).
    errors = []
-%}
{%- set slot_content -%}
    {##- Custom error content, overrides the `errors` prop if provided. -#}
    {%- block content %}{% endblock -%}
{%- endset -%}
{%- set slot_content = slot_content|trim -%}

{%- if slot_content == '' -%}
    {%- set messages = [] -%}
    {%- for error in errors|default([]) -%}
        {%- set message = error.message ?? error -%}
        {%- if message is not same as(false) and message is not null and message != '' and not (message in messages) -%}
            {%- set messages = messages|merge([message]) -%}
        {%- endif -%}
    {%- endfor -%}
{%- endif -%}

{%- if slot_content != '' or (messages ?? [])|length > 0 -%}
    <div
        role="alert"
        data-slot="field-error"
        {{ attributes.defaults({
            class: 'text-destructive text-sm font-normal'|tailwind_classes,
        }) }}
    >
        {%- if slot_content != '' -%}
            {{ slot_content }}
        {%- elseif (messages ?? [])|length == 1 -%}
            {{ messages[0] }}
        {%- else -%}
            <ul class="ms-4 flex list-disc flex-col gap-1">
                {%- for message in messages|default([]) -%}
                    <li>{{ message }}</li>
                {%- endfor -%}
            </ul>
        {%- endif -%}
    </div>
{%- endif -%}
templates/components/Field/Group.html.twig
<div
    data-slot="field-group"
    {{ attributes.defaults({
        class: 'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4'|tailwind_classes,
    }) }}
>
    {##- The grouped fields, typically multiple `Field` components. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Field/Label.html.twig
<twig:Label
    data-slot="field-label"
    {{ ...attributes.defaults({class: 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-checked:border-primary/30 has-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 dark:has-checked:border-primary/20 dark:has-checked:bg-primary/10 has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col'|tailwind_classes}) }}
>
    {##- The label text for the field. -#}
    {{- block(outerBlocks.content) -}}
</twig:Label>
templates/components/Field/Legend.html.twig
{%- props
    ## 'legend'|'label' The text size variant.
    variant = 'legend'
-%}
<legend
    data-slot="field-legend"
    data-variant="{{ variant }}"
    {{ attributes.defaults({
        class: 'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base'|tailwind_classes,
    }) }}
>
    {##- The legend text for a fieldset. -#}
    {%- block content %}{% endblock -%}
</legend>
templates/components/Field/Separator.html.twig
{%- set content -%}
    {##- Optional text displayed in the center of the separator. -#}
    {%- block content %}{% endblock -%}
{%- endset -%}
{%- set has_content = content|trim != '' -%}
<div
    data-slot="field-separator"
    data-content="{{ has_content ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: 'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2'|tailwind_classes,
    }) }}
>
    <twig:Separator class="absolute inset-0 top-1/2" />
    {%- if has_content -%}
        <span
            class="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
            data-slot="field-separator-content"
        >
            {{ content }}
        </span>
    {%- endif -%}
</div>
templates/components/Field/Set.html.twig
<fieldset
    data-slot="field-set"
    {{ attributes.defaults({
        class: 'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3'|tailwind_classes,
    }) }}
>
    {##- The fieldset content, typically includes `Field:Legend` and `Field` components. -#}
    {%- block content %}{% endblock -%}
</fieldset>
templates/components/Field/Title.html.twig
<div
    data-slot="field-label"
    {{ attributes.defaults({
        class: 'flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50'|tailwind_classes,
    }) }}
>
    {##- The title text for the field (non-label variant). -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Separator.html.twig
{%- props
    ## 'horizontal'|'vertical' The separator orientation.
    orientation = 'horizontal',
    ## boolean Whether the separator is purely decorative (not semantic).
    decorative = true
-%}
<div
    data-slot="separator"
    data-orientation="{{ orientation }}"
    role="{{ decorative ? 'none' : 'separator' }}"
    {{ not decorative ? 'aria-orientation="' ~ orientation ~ '"' }}
    {{ attributes.defaults({
        class: 'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch'|tailwind_classes,
    }) }}
></div>
templates/components/Label.html.twig
<label
    data-slot="label"
    {{ attributes.defaults({
        class: 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50'|tailwind_classes,
    }) }}
>
    {##- The label text for a form control. -#}
    {%- block content %}{% endblock -%}
</label>
templates/components/Checkbox.html.twig
<div
    data-slot="checkbox"
    class="group relative grid size-4 shrink-0 grid-cols-1 group-has-disabled/field:opacity-50"
>
    <input
        type="checkbox"
        {{ attributes.defaults({class: 'peer col-start-1 row-start-1 appearance-none size-4 rounded-[4px] border border-input bg-transparent transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 checked:border-primary checked:bg-primary dark:checked:border-primary dark:checked:bg-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 forced-colors:appearance-auto'|tailwind_classes}) }}
    />
    <span
        data-slot="checkbox-indicator"
        class="pointer-events-none col-start-1 row-start-1 flex items-center justify-center text-primary-foreground opacity-0 transition-none group-has-checked:opacity-100 group-has-disabled:text-primary-foreground/50"
    >
        <twig:ux:icon name="lucide:check" class="size-3.5" />
    </span>
</div>
templates/components/Button.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default',
    ## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
    as = 'button'
-%}
{%- set style = html_cva(
    base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    variants: {
        variant: {
            default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
            outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
            secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
            ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
            destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
            link: 'text-primary underline-offset-4 hover:underline',
        },
        size: {
            default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
            sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            icon: 'size-8',
            'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
            'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
            'icon-lg': 'size-9',
        },
    },
) -%}
<{{ as }}
    data-slot="button"
    data-size="{{ size }}"
    data-variant="{{ variant }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The button label and/or icon. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>

Usage

<twig:Questionnaire defaultItem="direction" shortcuts="letters">
    <twig:Questionnaire:Progress />

    <twig:Questionnaire:Item name="direction" required>
        <twig:Questionnaire:Title>What should we prototype next?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Choose a direction or write your own.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="delegation">Delegation</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="questions">Question prompts</twig:Questionnaire:Choice>
            <twig:Questionnaire:Input aria-label="Another answer" placeholder="Type another answer…" />
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Skip />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit />
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Questionnaire renders a form, so answers are submitted with the name of each Questionnaire:Item. An item marked multiple submits an array, and its inputs are named <name>[].

Questionnaire owns the ordered items, the active item, validation, progress, and navigation. The containing page, card, or dialog owns cancellation, persistence, and transport.

Set :interactive="false" to hand that ownership to the server instead. The component then renders as plain markup: no data-controller, no data-action, and no data-questionnaire-* attributes anywhere in the tree, so the Stimulus controller never boots. See Server-Driven Flow.

Examples

Multiple Selection

Use the multiple prop for an item that accepts more than one fixed answer.

100%
Loading...
<twig:Questionnaire id="multiple" defaultItem="context" shortcuts="letters" class="mx-auto max-w-md">
    <twig:Questionnaire:Item name="context" multiple required>
        <twig:Questionnaire:Title>What context should the agent inspect?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Select every source that may affect the implementation.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="source">Relevant source files</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="tests">Existing tests</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="docs">Architecture documentation</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="history">Recent commit history</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Submit>Share context</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Freeform Answer

Compose Questionnaire:Input with fixed choices when the user can provide another answer. The input and the choices share the item name, so answering one clears the other.

100%
Loading...
<twig:Questionnaire id="freeform" defaultItem="approach" shortcuts="letters" class="mx-auto max-w-md">
    <twig:Questionnaire:Item name="approach" required>
        <twig:Questionnaire:Title>How should the agent approach this refactor?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Choose a strategy or write a more specific instruction.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="incremental">Make the smallest safe change</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="module">Refactor one module at a time</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="rewrite">Replace the implementation completely</twig:Questionnaire:Choice>
            <twig:Questionnaire:Input aria-label="Another refactoring approach" placeholder="Describe another approach…" />
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Submit>Use this approach</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Explicit Skip

Add Questionnaire:Skip when an optional item may be intentionally left unanswered. The button only appears on items that are not required.

100%
Loading...
<twig:Questionnaire id="skip" defaultItem="task" class="mx-auto max-w-md">
    <twig:Questionnaire:Progress />

    <twig:Questionnaire:Item name="task" required>
        <twig:Questionnaire:Title>What kind of change is this?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Choose the category that best describes the work.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="feature">New feature</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="fix">Bug fix</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="refactor">Refactor</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="constraints">
        <twig:Questionnaire:Title>Are there any implementation constraints?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Answer if needed, or intentionally skip this question.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="no-dependencies">Do not add dependencies</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="no-migrations">Do not change the database</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="preserve-api">Preserve the public API</twig:Questionnaire:Choice>
            <twig:Questionnaire:Input aria-label="Another implementation constraint" placeholder="Describe another constraint…" />
        </twig:Questionnaire:Choices>
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="review" required>
        <twig:Questionnaire:Title>How should the work be reviewed?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Choose the checks the agent should complete before handoff.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="tests">Run the test suite</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="diff">Review the final diff</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="both">Tests and diff review</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Skip />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit>Submit brief</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Shortcuts

Assign a letter or number key to each answer with the shortcuts prop. Press the key to select the matching choice.

100%
Loading...
<div class="mx-auto flex w-full max-w-md flex-col gap-8">
    <twig:Questionnaire id="shortcuts-letters" defaultItem="action" shortcuts="letters">
        <twig:Questionnaire:Item name="action" required>
            <twig:Questionnaire:Title>What should the agent do next?</twig:Questionnaire:Title>
            <twig:Questionnaire:Description>Use the displayed shortcut or navigate with the keyboard.</twig:Questionnaire:Description>
            <twig:Questionnaire:Choices>
                <twig:Questionnaire:Choice value="inspect">Inspect the implementation</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="tests">Run the relevant tests</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="patch">Prepare the patch</twig:Questionnaire:Choice>
            </twig:Questionnaire:Choices>
            <twig:Questionnaire:Error />
        </twig:Questionnaire:Item>

        <twig:Questionnaire:Actions>
            <twig:Questionnaire:Submit>Confirm action</twig:Questionnaire:Submit>
        </twig:Questionnaire:Actions>
    </twig:Questionnaire>

    <twig:Questionnaire id="shortcuts-numbers" defaultItem="scope" shortcuts="numbers">
        <twig:Questionnaire:Item name="scope" required>
            <twig:Questionnaire:Title>How much of the codebase may change?</twig:Questionnaire:Title>
            <twig:Questionnaire:Description>The same questionnaire with numbered shortcuts.</twig:Questionnaire:Description>
            <twig:Questionnaire:Choices>
                <twig:Questionnaire:Choice value="file">A single file</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="package">One package</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="workspace">The whole workspace</twig:Questionnaire:Choice>
            </twig:Questionnaire:Choices>
            <twig:Questionnaire:Error />
        </twig:Questionnaire:Item>

        <twig:Questionnaire:Actions>
            <twig:Questionnaire:Submit>Confirm scope</twig:Questionnaire:Submit>
        </twig:Questionnaire:Actions>
    </twig:Questionnaire>
</div>

Custom Validation

Fill Questionnaire:Error with your own message to replace the default one shown when a required item has no answer.

100%
Loading...
<twig:Questionnaire id="validation" defaultItem="detail" class="mx-auto max-w-md">
    <twig:Questionnaire:Progress />

    <twig:Questionnaire:Item name="detail" required>
        <twig:Questionnaire:Title>How detailed should the answer be?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>The error below replaces the default validation message.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="summary">A short summary</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="walkthrough">A full walkthrough</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error>Pick a level of detail before continuing.</twig:Questionnaire:Error>
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="audience" required>
        <twig:Questionnaire:Title>Who will read the answer?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Each item can carry its own message.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="reviewer">A reviewer</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="maintainer">A maintainer</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error>Tell us who the answer is for.</twig:Questionnaire:Error>
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit>Save answers</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Controlled

Use the defaultItem prop to render a questionnaire that opens on a specific item, such as the one a server-side validation marked invalid.

100%
Loading...
<twig:Questionnaire id="controlled" defaultItem="checks" class="mx-auto max-w-md">
    <twig:Questionnaire:Progress />

    <twig:Questionnaire:Item name="scope" required>
        <twig:Questionnaire:Title>What may the agent change?</twig:Questionnaire:Title>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="component">One component</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="tests">Tests only</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="feature">A whole feature</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="checks" required>
        <twig:Questionnaire:Title>Which checks should run before handoff?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>This questionnaire opens here instead of on the first item.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="targeted">Targeted tests</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="package">Package suite</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="full">Full workspace</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit>Save answers</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Resume

Restore a saved session by combining defaultItem with the checked prop on the answers that were already given.

100%
Loading...
<twig:Questionnaire id="resume" defaultItem="verification" class="mx-auto max-w-md">
    <twig:Questionnaire:Progress />

    <twig:Questionnaire:Item name="change" required>
        <twig:Questionnaire:Title>What kind of migration is this?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>This answer was restored from the saved session.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="incremental" checked>Incremental migration</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="cutover">One-time cutover</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="verification" multiple required>
        <twig:Questionnaire:Title>How should the migration be verified?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Two answers were already selected before the session was saved.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="tests" checked>Automated tests</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="typecheck" checked>Type checking</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="manual">Manual review</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="notes">
        <twig:Questionnaire:Title>Anything else to carry over?</twig:Questionnaire:Title>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Input aria-label="Migration notes" placeholder="Add a note…" />
        </twig:Questionnaire:Choices>
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Skip />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit>Resume run</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Conditional Items

Mark an item disabled when it does not apply to the user's earlier answers: it is inert and navigation steps over it.

100%
Loading...
<twig:Questionnaire id="conditional" defaultItem="runtime" class="mx-auto max-w-md">
    <twig:Questionnaire:Progress />

    <twig:Questionnaire:Item name="runtime" required>
        <twig:Questionnaire:Title>Where should the agent run?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>The sandbox question only applies to cloud runs.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="local">On this machine</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="cloud">In the cloud</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="sandbox" required disabled>
        <twig:Questionnaire:Title>Which sandbox should the cloud run use?</twig:Questionnaire:Title>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="preview">Preview</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="staging">Staging</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="isolated">Fully isolated</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="approval" required>
        <twig:Questionnaire:Title>When should the agent ask for approval?</twig:Questionnaire:Title>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="always">Before every action</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="destructive">Before destructive actions</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="never">Never</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit>Start run</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Navigation State

Each Questionnaire:Item exposes data-status, so answered and skipped questions can be styled differently from unanswered ones.

100%
Loading...
<twig:Questionnaire id="navigation-state" defaultItem="permissions" class="mx-auto max-w-md">
    <twig:Questionnaire:Progress />

    <twig:Questionnaire:Item
        name="permissions"
        required
        class="rounded-lg border border-dashed border-border p-4 data-[status=answered]:border-solid data-[status=answered]:border-primary/40"
    >
        <twig:Questionnaire:Title>What may the agent modify?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>The dashed border becomes solid once this question is answered.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="files">Project files</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="tests">Tests only</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="config">Configuration</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item
        name="checks"
        class="rounded-lg border border-dashed border-border p-4 data-[status=answered]:border-solid data-[status=answered]:border-primary/40 data-[status=skipped]:opacity-60"
    >
        <twig:Questionnaire:Title>Which checks should the agent run?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Skipping this question dims it instead.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="tests">Tests</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="types">Type checking</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="all">Everything</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Skip />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit>Confirm</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Custom Progress

Replace the content of Questionnaire:Progress to build a custom indicator. Elements marked data-questionnaire-target="progressStep" receive data-active, and progressCurrent / progressTotal receive the step numbers.

100%
Loading...
<twig:Questionnaire id="custom-progress" defaultItem="scope" class="mx-auto max-w-md">
    <twig:Questionnaire:Progress class="w-full">
        <div class="mb-2 flex gap-1.5" aria-hidden="true">
            <span data-questionnaire-target="progressStep" class="h-1.5 flex-1 rounded-full bg-muted data-[active=true]:bg-primary"></span>
            <span data-questionnaire-target="progressStep" class="h-1.5 flex-1 rounded-full bg-muted data-[active=true]:bg-primary"></span>
            <span data-questionnaire-target="progressStep" class="h-1.5 flex-1 rounded-full bg-muted data-[active=true]:bg-primary"></span>
        </div>
        <span>Checkpoint <span data-questionnaire-target="progressCurrent">1</span> of <span data-questionnaire-target="progressTotal">3</span></span>
    </twig:Questionnaire:Progress>

    <twig:Questionnaire:Item name="scope" required>
        <twig:Questionnaire:Title>How large is the change?</twig:Questionnaire:Title>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="small">Small patch</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="medium">Feature-sized change</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="large">Cross-package change</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="strategy" required>
        <twig:Questionnaire:Title>How should commits be organized?</twig:Questionnaire:Title>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="single">Single commit</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="logical">Logical commits</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="squash">Squash before review</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item name="delivery" required>
        <twig:Questionnaire:Title>How should the work be delivered?</twig:Questionnaire:Title>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="patch">Patch only</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="commit">Committed locally</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="branch">Push a review branch</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit>Finish plan</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Animated Items

Animate the active item with data-[active=true] while progress and navigation stay stationary.

100%
Loading...
<twig:Questionnaire id="animated" defaultItem="task" class="mx-auto max-w-md">
    <twig:Questionnaire:Progress />

    <twig:Questionnaire:Item
        name="task"
        required
        class="data-[active=true]:animate-in data-[active=true]:fade-in-0 data-[active=true]:slide-in-from-bottom-2 data-[active=true]:duration-300 motion-reduce:animate-none"
    >
        <twig:Questionnaire:Title>What should the agent do?</twig:Questionnaire:Title>
        <twig:Questionnaire:Description>Each item fades and slides in as it becomes active.</twig:Questionnaire:Description>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="implement">Implement the change</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="debug">Debug the failure</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="review">Review the diff</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item
        name="review"
        required
        class="data-[active=true]:animate-in data-[active=true]:fade-in-0 data-[active=true]:slide-in-from-bottom-2 data-[active=true]:duration-300 motion-reduce:animate-none"
    >
        <twig:Questionnaire:Title>How much should be reviewed?</twig:Questionnaire:Title>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="targeted">The touched files</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="complete">The complete diff</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="manual">A manual walkthrough</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Item
        name="delivery"
        required
        class="data-[active=true]:animate-in data-[active=true]:fade-in-0 data-[active=true]:slide-in-from-bottom-2 data-[active=true]:duration-300 motion-reduce:animate-none"
    >
        <twig:Questionnaire:Title>How should the result be delivered?</twig:Questionnaire:Title>
        <twig:Questionnaire:Choices>
            <twig:Questionnaire:Choice value="summary">A short summary</twig:Questionnaire:Choice>
            <twig:Questionnaire:Choice value="branch">A review branch</twig:Questionnaire:Choice>
        </twig:Questionnaire:Choices>
        <twig:Questionnaire:Error />
    </twig:Questionnaire:Item>

    <twig:Questionnaire:Actions>
        <twig:Questionnaire:Previous />
        <twig:Questionnaire:Next />
        <twig:Questionnaire:Submit>Start work</twig:Questionnaire:Submit>
    </twig:Questionnaire:Actions>
</twig:Questionnaire>

Card

Compose Questionnaire with Card slots while keeping the question title and description semantic.

100%
Loading...
<twig:Questionnaire id="card" defaultItem="task" shortcuts="numbers" class="mx-auto max-w-md">
    <twig:Card>
        <twig:Questionnaire:Item name="task" required>
            <twig:Card:Header>
                <twig:Questionnaire:Title>What should the agent work on?</twig:Questionnaire:Title>
                <twig:Questionnaire:Description>Choose the task that should be handled next.</twig:Questionnaire:Description>
                <twig:Card:Action>
                    <twig:Questionnaire:Progress />
                </twig:Card:Action>
            </twig:Card:Header>
            <twig:Card:Content>
                <twig:Questionnaire:Choices>
                    <twig:Questionnaire:Choice value="fix">Fix the failing tests</twig:Questionnaire:Choice>
                    <twig:Questionnaire:Choice value="refactor">Refactor the data layer</twig:Questionnaire:Choice>
                    <twig:Questionnaire:Choice value="docs">Update the integration guide</twig:Questionnaire:Choice>
                </twig:Questionnaire:Choices>
                <twig:Questionnaire:Error />
            </twig:Card:Content>
        </twig:Questionnaire:Item>

        <twig:Questionnaire:Item name="output" required>
            <twig:Card:Header>
                <twig:Questionnaire:Title>What should the final handoff include?</twig:Questionnaire:Title>
                <twig:Questionnaire:Description>Pick the level of detail needed for review.</twig:Questionnaire:Description>
                <twig:Card:Action>
                    <twig:Questionnaire:Progress />
                </twig:Card:Action>
            </twig:Card:Header>
            <twig:Card:Content>
                <twig:Questionnaire:Choices>
                    <twig:Questionnaire:Choice value="summary">Summary only</twig:Questionnaire:Choice>
                    <twig:Questionnaire:Choice value="files">Summary and changed files</twig:Questionnaire:Choice>
                    <twig:Questionnaire:Choice value="review">Full review handoff</twig:Questionnaire:Choice>
                </twig:Questionnaire:Choices>
                <twig:Questionnaire:Error />
            </twig:Card:Content>
        </twig:Questionnaire:Item>

        <twig:Card:Footer>
            <twig:Questionnaire:Actions class="w-full">
                <twig:Questionnaire:Previous />
                <twig:Questionnaire:Next />
                <twig:Questionnaire:Submit>Create task</twig:Questionnaire:Submit>
            </twig:Questionnaire:Actions>
        </twig:Card:Footer>
    </twig:Card>
</twig:Questionnaire>

Dialog

Compose Questionnaire inside a Dialog while keeping cancellation and dismissal owned by the dialog.

100%
Loading...
<div class="flex items-start justify-center" style="min-height: 480px">
    <twig:Dialog id="questionnaire">
        <twig:Dialog:Trigger>
            <twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>Open clarification</twig:Button>
        </twig:Dialog:Trigger>
        <twig:Dialog:Content>
            <twig:Questionnaire id="dialog" defaultItem="scope">
                <twig:Questionnaire:Item name="scope" required>
                    <twig:Dialog:Header>
                        <twig:Questionnaire:Progress />
                        <twig:Questionnaire:Title>How much should the agent change?</twig:Questionnaire:Title>
                        <twig:Questionnaire:Description>Answer before the agent continues.</twig:Questionnaire:Description>
                    </twig:Dialog:Header>
                    <twig:Questionnaire:Choices>
                        <twig:Questionnaire:Choice value="component">One component</twig:Questionnaire:Choice>
                        <twig:Questionnaire:Choice value="feature">One feature</twig:Questionnaire:Choice>
                        <twig:Questionnaire:Choice value="workspace">The whole workspace</twig:Questionnaire:Choice>
                    </twig:Questionnaire:Choices>
                    <twig:Questionnaire:Error />
                </twig:Questionnaire:Item>

                <twig:Questionnaire:Item name="tests" required>
                    <twig:Dialog:Header>
                        <twig:Questionnaire:Progress />
                        <twig:Questionnaire:Title>Which tests should run afterwards?</twig:Questionnaire:Title>
                        <twig:Questionnaire:Description>The dialog keeps owning dismissal.</twig:Questionnaire:Description>
                    </twig:Dialog:Header>
                    <twig:Questionnaire:Choices>
                        <twig:Questionnaire:Choice value="targeted">Targeted tests</twig:Questionnaire:Choice>
                        <twig:Questionnaire:Choice value="package">Package suite</twig:Questionnaire:Choice>
                    </twig:Questionnaire:Choices>
                    <twig:Questionnaire:Error />
                </twig:Questionnaire:Item>

                <twig:Questionnaire:Actions>
                    <twig:Questionnaire:Previous />
                    <twig:Questionnaire:Next />
                    <twig:Questionnaire:Submit>Continue</twig:Questionnaire:Submit>
                </twig:Questionnaire:Actions>
            </twig:Questionnaire>
        </twig:Dialog:Content>
    </twig:Dialog>
</div>

Server-Driven Flow

Set :interactive="false" when the server owns the flow, as with a Symfony form flow. The Stimulus controller is not registered at all, so nothing on the client moves the user between questions, validates an answer, or shows and hides the navigation: the server renders the current step, its errors, and the buttons it wants.

In this mode defaultItem selects which Questionnaire:Item is rendered visible; leave it unset and every item renders, so the usual approach is to output only the current step. Questionnaire:Progress takes current and total to keep the progressbar accurate, Questionnaire:Error renders whenever you output it, and Questionnaire:Previous, Questionnaire:Skip and Questionnaire:Next become type="submit" so a name and value tell the server which way to go.

Questionnaire renders a form by default. Set as="div" to nest it inside a form you already render, and let that outer form own the method, the action and the hidden fields.

100%
Loading...
<form method="post" class="mx-auto max-w-md">
    <twig:Questionnaire id="server-flow" as="div" :interactive="false" defaultItem="verification">
        <twig:Questionnaire:Progress :current="2" :total="4" />

        <twig:Questionnaire:Item name="verification" required>
            <twig:Questionnaire:Title>How should the migration be verified?</twig:Questionnaire:Title>
            <twig:Questionnaire:Description>The server validated this step and sent you back to it.</twig:Questionnaire:Description>
            <twig:Questionnaire:Choices>
                <twig:Questionnaire:Choice value="tests">Automated tests</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="typecheck">Type checking</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="manual">Manual review</twig:Questionnaire:Choice>
            </twig:Questionnaire:Choices>
            <twig:Questionnaire:Error>Choose how the migration should be verified.</twig:Questionnaire:Error>
        </twig:Questionnaire:Item>

        <twig:Questionnaire:Actions>
            <twig:Questionnaire:Previous name="step" value="back" formnovalidate />
            <twig:Questionnaire:Next name="step" value="next" />
        </twig:Questionnaire:Actions>
    </twig:Questionnaire>
</form>

With a Symfony form flow

A flow type declares its steps and adds a navigator, which supplies the back, next, finish and reset buttons:

use Symfony\Component\Form\Flow\AbstractFlowType;
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
use Symfony\Component\Form\Flow\Type\NavigatorFlowType;

class MigrationType extends AbstractFlowType
{
    public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        $builder->addStep('change', ChangeType::class);
        $builder->addStep('verification', VerificationType::class);

        $builder->add('navigator', NavigatorFlowType::class);
    }
}

The controller renders the form of the current step:

$flow = $this->createForm(MigrationType::class, $migration);
$flow->handleRequest($request);

if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
    return $this->redirectToRoute('migration_success');
}

return $this->render('migration.html.twig', ['form' => $flow->getStepForm()]);

The form_flow_*() functions read the flow cursor, and the field_*() functions give each field its submitted name, label, choices and errors. Calling field_name() also marks the field as rendered, so form_end() still emits the CSRF token and the flow's hidden fields without duplicating anything you laid out by hand:

{# `form` is the step form returned by `$flow->getStepForm()` #}
{% set step = form_flow_current_step(form) %}

{{ form_start(form) }}
    <twig:Questionnaire as="div" :interactive="false">
        <twig:Questionnaire:Progress
            :current="form_flow_step_index(form) + 1"
            :total="form_flow_total_steps(form)"
        />

        <twig:Questionnaire:Item name="{{ field_name(form[step]) }}" required>
            <twig:Questionnaire:Title>{{ field_label(form[step]) }}</twig:Questionnaire:Title>
            <twig:Questionnaire:Description>{{ field_help(form[step]) }}</twig:Questionnaire:Description>
            <twig:Questionnaire:Choices>
                {% for choice in field_choices(form[step]) %}
                    <twig:Questionnaire:Choice value="{{ choice.value }}">{{ choice.label }}</twig:Questionnaire:Choice>
                {% endfor %}
            </twig:Questionnaire:Choices>
            {% for error in field_errors(form[step]) %}
                <twig:Questionnaire:Error>{{ error }}</twig:Questionnaire:Error>
            {% endfor %}
        </twig:Questionnaire:Item>

        <twig:Questionnaire:Actions>
            {% if form_flow_can_move_back(form) %}
                <twig:Questionnaire:Previous name="{{ field_name(form.navigator.back) }}" formnovalidate>
                    {{ field_label(form.navigator.back) }}
                </twig:Questionnaire:Previous>
            {% endif %}

            {% if form_flow_can_move_next(form) %}
                <twig:Questionnaire:Next name="{{ field_name(form.navigator.next) }}">
                    {{ field_label(form.navigator.next) }}
                </twig:Questionnaire:Next>
            {% endif %}

            {% if form_flow_is_last_step(form) %}
                <twig:Questionnaire:Submit name="{{ field_name(form.navigator.finish) }}">
                    {{ field_label(form.navigator.finish) }}
                </twig:Questionnaire:Submit>
            {% endif %}
        </twig:Questionnaire:Actions>
    </twig:Questionnaire>
{{ form_end(form) }}

The navigator only exposes the buttons that apply to the current step, so each one is guarded rather than rendered unconditionally: form_flow_can_move_back() for back, form_flow_can_move_next() for next, and form_flow_is_last_step() for finish. formnovalidate on the back button lets the user return to the previous step without tripping HTML validation on the step they are leaving.

form_flow_step_index() is zero-based, hence the + 1 for the 1-based current prop. The other cursor helpers are form_flow_steps(), form_flow_next_step(), form_flow_previous_step(), form_flow_first_step(), form_flow_last_step(), form_flow_is_first_step() and form_flow_is_last_step().

Two things to watch:

  • field_name() returns the field's full name, which for a multiple choice field already ends in [], and Questionnaire:Item appends [] of its own when you set multiple. Strip one of them: name="{{ field_name(form[step])|replace({'[]': ''}) }}" multiple.
  • A Questionnaire:Input shares the name of its item so the controller can treat it as an alternative answer. Without the controller that empty input is still submitted and shadows the selected choice, so give it its own name here: <twig:Questionnaire:Input name="{{ field_name(form[step].other) }}" />.

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

100%
Loading...
<div class="mx-auto flex w-full max-w-md flex-col gap-8">
    <twig:Questionnaire id="rtl-ar" dir="rtl" defaultItem="ar-scope" shortcuts="numbers">
        <twig:Questionnaire:Item name="ar-scope" required>
            <twig:Questionnaire:Title>ما الذي يجب أن يعمل عليه الوكيل؟</twig:Questionnaire:Title>
            <twig:Questionnaire:Description>اختر المهمة التالية.</twig:Questionnaire:Description>
            <twig:Questionnaire:Choices>
                <twig:Questionnaire:Choice value="fix">إصلاح الاختبارات الفاشلة</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="refactor">إعادة هيكلة طبقة البيانات</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="docs">تحديث دليل التكامل</twig:Questionnaire:Choice>
            </twig:Questionnaire:Choices>
            <twig:Questionnaire:Error />
        </twig:Questionnaire:Item>

        <twig:Questionnaire:Actions>
            <twig:Questionnaire:Submit>تأكيد</twig:Questionnaire:Submit>
        </twig:Questionnaire:Actions>
    </twig:Questionnaire>

    <twig:Questionnaire id="rtl-he" dir="rtl" defaultItem="he-scope" shortcuts="numbers">
        <twig:Questionnaire:Item name="he-scope" required>
            <twig:Questionnaire:Title>על מה הסוכן צריך לעבוד?</twig:Questionnaire:Title>
            <twig:Questionnaire:Description>בחר את המשימה הבאה.</twig:Questionnaire:Description>
            <twig:Questionnaire:Choices>
                <twig:Questionnaire:Choice value="fix">תיקון הבדיקות שנכשלו</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="refactor">שכתוב שכבת הנתונים</twig:Questionnaire:Choice>
                <twig:Questionnaire:Choice value="docs">עדכון מדריך האינטגרציה</twig:Questionnaire:Choice>
            </twig:Questionnaire:Choices>
            <twig:Questionnaire:Error />
        </twig:Questionnaire:Item>

        <twig:Questionnaire:Actions>
            <twig:Questionnaire:Submit>אישור</twig:Questionnaire:Submit>
        </twig:Questionnaire:Actions>
    </twig:Questionnaire>
</div>

Accessibility

  • Each Questionnaire:Item renders a <fieldset> and Questionnaire:Title renders its <legend>, so the choices are announced as a named group. Give every item a Questionnaire:Title.
  • An item that isn't the active one is rendered hidden inert, and an item marked disabled gets the native disabled attribute on its fieldset, so neither one is focusable or submitted.
  • Questionnaire:Choice is a <label> wrapping a real <input type="radio"> or <input type="checkbox">, picked from the item's multiple prop. The input stays transparent rather than removed, so checked state, keyboard behavior and form submission stay the browser's.
  • Questionnaire:Error renders role="alert" with a stable id. When validation fails the controller sets aria-invalid="true" and aria-describedby pointing at it on the fieldset, and clears both once the item is answered.
  • Questionnaire:Progress renders role="progressbar" with aria-label="Questionnaire progress", aria-valuemin, aria-valuenow and aria-valuemax.
  • The choice indicator (the dot or the check) and the shortcut badge are decorative and carry aria-hidden="true", so the choice label text has to carry the meaning on its own.
  • Enter moves to the next item, or submits the form on the last one, unless focus is already on a submit button.
  • With the shortcuts prop, pressing a choice's letter or number selects it. Shortcuts are ignored while focus is in a Questionnaire:Input so a freeform answer can be typed normally, and a disabled choice ignores its shortcut.

API Reference

<twig:Questionnaire>

Prop Type Default
id 
string 'questionnaire'
as 
'form'|'div' 'form'
defaultItem 
string ''
shortcuts 
'letters'|'numbers'|null null
interactive 
boolean true
Block Description
content The questionnaire structure, typically includes Questionnaire:Progress, Questionnaire:Item and Questionnaire:Actions.

<twig:Questionnaire:Actions>

Block Description
content The navigation buttons, typically Questionnaire:Previous, Questionnaire:Skip, Questionnaire:Next and Questionnaire:Submit.

<twig:Questionnaire:Choice>

Prop Type Default
value 
string -
checked 
boolean false
disabled 
boolean false
Block Description
content The choice label, optionally followed by a Questionnaire:ChoiceDescription.

<twig:Questionnaire:ChoiceDescription>

Block Description
content The secondary text describing the choice.

<twig:Questionnaire:Choices>

Block Description
content The answers, typically multiple Questionnaire:Choice components and an optional Questionnaire:Input.
content The answers, typically multiple Questionnaire:Choice components and an optional Questionnaire:Input.

<twig:Questionnaire:Description>

Block Description
content The supporting text shown under the question prompt.

<twig:Questionnaire:Error>

Block Description
content The validation message, replacing the wording the item's required prop selects.

<twig:Questionnaire:Item>

Prop Type Default
name 
string -
required 
boolean false
multiple 
boolean false
disabled 
boolean false
Block Description
content The item structure, typically includes Questionnaire:Title, Questionnaire:Choices and Questionnaire:Error.

<twig:Questionnaire:Next>

Prop Type Default
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'default'
size 
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' 'default'
Block Description
content The button label.

<twig:Questionnaire:Previous>

Prop Type Default
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'outline'
size 
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' 'default'
Block Description
content The button label.

<twig:Questionnaire:Progress>

Prop Type Default
current 
int|null null
total 
int|null null
Block Description
content The progress label, replacing the default "Question X of Y" wording.

<twig:Questionnaire:Skip>

Prop Type Default
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'outline'
size 
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' 'default'
Block Description
content The button label.

<twig:Questionnaire:Submit>

Prop Type Default
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'default'
size 
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' 'default'
Block Description
content The button label.

<twig:Questionnaire:Title>

Block Description
content The question prompt.