Questionnaire

Questionnaire guides a user through an ordered set of questions. It owns the active item, answer state, validation, progress, and navigation. A containing page, GroupBox, Dialog, or Sheet remains responsible for closing, cancelling, persistence, transport, and application-specific branching.

Install

#Import

use gpui_kit::component::questionnaire::{
    Questionnaire, QuestionnaireActions, QuestionnaireChoice,
    QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription,
    QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext,
    QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireState,
    QuestionnaireSubmit, QuestionnaireTitle,
};
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { QuestionChoice, Question, Questionnaire } from "questionnaire.slint";
use iced::widget::{column, container, mouse_area, row, scrollable, svg, text};
use iced::{Alignment, Element, Length, Task, Theme};

#Usage

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The flow the whole control is built for: a counter, a prompt with a
// sentence under it, choices that say what they mean, a write-your-own row,
// and a keyboard badge on each choice.
let complete = wired(
    app,
    Section::Usage,
    [
        Question::new("What should we prototype next?")
            .help("Choose one direction or write your own.")
            .choices([
                Choice::new("Delegation").detail("Show how work moves to a specialist."),
                Choice::new("Question prompts")
                    .detail("Show choices while the interface waits."),
                Choice::new("Both together"),
            ])
            .free_text("Type another direction…"),
        Question::new("Which tools belong in it?")
            .help("Pick the ones the prototype has to show.")
            .choices([
                Choice::new("Editor").detail("The text surface and its menus."),
                Choice::new("Terminal").detail("A running command and its output."),
                Choice::new("Browser"),
            ])
            .free_text("Add another tool…"),
        Question::new("What tone should the interface use?")
            .help("This question can be skipped.")
            .choices([
                Choice::new("Direct").detail("Short labels, no ceremony."),
                Choice::new("Warm").detail("Sentences, and room to breathe."),
            ]),
    ],
);

let usage = column![
    complete,
    muted_line(if app.usage.finished {
        "Finished the flow."
    } else {
        "Answer, then Next — the last step submits."
    }),
]
.spacing(12.0);

Create the item collection once and use one QuestionnaireState entity as the source of truth for all parts.

use gpui_kit::component::input::InputState;
use gpui_kit::component::questionnaire::{
    QuestionnaireChoiceDefinition, QuestionnaireInputDefinition,
    QuestionnaireItemDefinition, QuestionnaireState,
};

let direction_input = cx.new(|cx| {
    InputState::new(window, cx).placeholder("Type another answer…")
});

let items = vec![
    QuestionnaireItemDefinition::new("direction", "What should we prototype next?")
        .with_required(true)
        .with_description("Choose a direction or write your own.")
        .with_choices([
            QuestionnaireChoiceDefinition::new("delegation", "Delegation")
                .with_description("Show how work moves to a specialist."),
            QuestionnaireChoiceDefinition::new("questions", "Question prompts"),
            QuestionnaireChoiceDefinition::new("both", "Both together"),
        ])
        .with_input(QuestionnaireInputDefinition::new(
            direction_input,
            "Another answer",
        )),
    QuestionnaireItemDefinition::new("detail", "How much detail should it include?")
        .with_description("You can skip this question if you are not sure yet.")
        .with_choices([
            QuestionnaireChoiceDefinition::new("focused", "Focused"),
            QuestionnaireChoiceDefinition::new("complete", "Complete flow"),
        ]),
];

let state = cx.new(|cx| {
    QuestionnaireState::new(items, cx)
        .expect("valid questionnaire schema")
});
SlintNo Slint version of this example yet.

Map every definition in the collection into the compound parts. The active QuestionnaireItem is the only item rendered, so omitting an item from this composition leaves the UI empty when navigation reaches that item.

Questionnaire::new(&state)
    .child(QuestionnaireProgress::new(&state))
    .child(
        QuestionnaireItem::new(&state, "direction")
            .child(QuestionnaireTitle::new(&state, "direction"))
            .child(QuestionnaireDescription::new(&state, "direction"))
            .child(
                QuestionnaireChoices::new(&state, "direction")
                    .child(QuestionnaireChoice::new(&state, "direction", "delegation"))
                    .child(QuestionnaireChoice::new(&state, "direction", "questions"))
                    .child(QuestionnaireChoice::new(&state, "direction", "both"))
                    .child(QuestionnaireInput::new(&state, "direction")),
            )
            .child(QuestionnaireError::new(&state, "direction")),
    )
    .child(
        QuestionnaireItem::new(&state, "detail")
            .child(QuestionnaireTitle::new(&state, "detail"))
            .child(QuestionnaireDescription::new(&state, "detail"))
            .child(
                QuestionnaireChoices::new(&state, "detail")
                    .child(QuestionnaireChoice::new(&state, "detail", "focused"))
                    .child(QuestionnaireChoice::new(&state, "detail", "complete")),
            )
            .child(QuestionnaireError::new(&state, "detail")),
    )
    .child(
        QuestionnaireActions::new(&state)
            .child(QuestionnairePrevious::new(&state))
            .child(QuestionnaireSkip::new(&state))
            .child(QuestionnaireNext::new(&state))
            .child(QuestionnaireSubmit::new(&state)),
    )
Questionnaire {
    questions: [
        {
            prompt: "What should we prototype next?",
            help: "Choose one direction or write your own.",
            choices: [
                { title: "Delegation", detail: "Show how work moves to a specialist." },
                { title: "Question prompts", detail: "Show choices while the interface waits." },
                { title: "Both together", detail: "" },
            ],
            free-text: "Type another direction…",
        },
        {
            prompt: "Which tools belong in it?",
            help: "Pick the ones the prototype has to show.",
            choices: [
                { title: "Editor", detail: "The text surface and its menus." },
                { title: "Terminal", detail: "A running command and its output." },
                { title: "Browser", detail: "" },
            ],
            free-text: "Add another tool…",
        },
        {
            prompt: "What tone should the interface use?",
            help: "This question can be skipped.",
            choices: [
                { title: "Direct", detail: "Short labels, no ceremony." },
                { title: "Warm", detail: "Sentences, and room to breathe." },
            ],
            free-text: "",
        },
    ];
}

#Composition

Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│   ├── QuestionnaireTitle
│   ├── QuestionnaireDescription
│   ├── QuestionnaireChoices
│   │   ├── QuestionnaireChoice
│   │   │   └── QuestionnaireChoiceDescription (custom child)
│   │   └── QuestionnaireInput
│   └── QuestionnaireError
└── QuestionnaireActions
    ├── QuestionnairePrevious
    ├── QuestionnaireSkip
    ├── QuestionnaireNext
    └── QuestionnaireSubmit
SlintNo Slint version of this example yet.
// Only the parts a step needs: a prompt, its choices, and the actions.
let composition = wired(
    app,
    Section::Composition,
    [Question::new("Confirm the target directory")
        .help("The control is only the question and its actions here.")
        .choices([
            Choice::new("ui/components").detail("Where an install puts files."),
            Choice::new("src/components").detail("The crate that builds the gallery."),
        ])],
)
.show_counter(false)
.show_progress(false);

Every part accepts ordinary GPUI styling and can be composed with existing Button, Input, Radio, Checkbox, Progress, Stepper, GroupBox, and Dialog elements. Pass the same state entity to each part. A custom part should read its corresponding state and call state methods for user actions; it should not create a second answer store.

QuestionnaireChoice supplies the default indicator, content, and shortcut. Adding children replaces the fallback label and description while preserving choice activation, focus, state, and accessibility behavior. Use QuestionnaireChoiceDescription::new() for secondary text in a custom choice body. The following seams customize only the corresponding region:

use gpui_kit::{IntoElement as _, ParentElement as _, StyleRefinement, Styled as _, div};
use gpui_kit::component::{ActiveTheme as _, StyledExt as _};
use gpui_kit::component::questionnaire::{
    QuestionnaireChoice, QuestionnaireChoiceDescription,
};

let _styled_choice = QuestionnaireChoice::new(&state, "direction", "questions")
    .indicator_style(StyleRefinement::default().opacity(0.9))
    .content_style(StyleRefinement::default().opacity(0.95))
    .shortcut_style(StyleRefinement::default().opacity(0.8));

let _rendered_choice = QuestionnaireChoice::new(&state, "direction", "delegation")
    .render_indicator(|choice, _, cx| {
        div()
            .size_4()
            .rounded_full()
            .bg(if choice.is_selected() {
                cx.theme().primary
            } else {
                cx.theme().muted
            })
            .into_any_element()
    })
    .child(
        div()
            .child("Delegation")
            .child(QuestionnaireChoiceDescription::new().child(
                "Show how work moves to a specialist.",
            )),
    );
Questionnaire {
    show-counter: false;
    show-progress: false;
    questions: [
        {
            prompt: "Confirm the target directory",
            help: "The control is only the question and its actions here.",
            choices: [
                { title: "ui/components", detail: "Where a Slint install puts files." },
                { title: "crates/slint-component", detail: "The crate that builds the gallery." },
            ],
            free-text: "",
        },
    ];
}

render_shortcut has the same renderer signature and receives the QuestionnaireChoiceState; use it when an application wants to replace the default Kbd hint. A renderer replaces that region completely, so its matching style seam is not applied; style the custom renderer directly. The state snapshot exposes is_selected, is_disabled, is_invalid, and shortcut for custom rendering.

#Choices

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The plain case: a prompt and a list, no counter, no progress, no badges.
let choices_only = wired(
    app,
    Section::Choices,
    [Question::new("How often do you ship?").choices([
        Choice::new("Daily"),
        Choice::new("Weekly"),
        Choice::new("Monthly"),
        Choice::new("Rarely"),
    ])],
)
.show_counter(false)
.show_progress(false)
.show_badges(false);

An item is single-selection by default. Activating a choice — clicking it, pressing Space or Enter on it, or pressing its shortcut — answers the item and confirms it: after a brief pause that keeps the selection visible, the questionnaire moves to the next enabled item, or submits on the last one. An answer that fails validation stays on its item with the error shown. Choosing the answer that is already selected, for example after going back, confirms at once. With reduced motion the pause is skipped. Arrow keys move the selection within the item without confirming it, and typing in a freeform input never confirms. with_multiple keeps every selected choice instead; activating a choice there only toggles it, and Next continues. The answer reader preserves schema order, and a choice disabled later leaves the effective answer.

Definition builders carry the initial snapshot: a choice can start selected, an item, a choice, or an input can start disabled, and a single-choice item may carry at most one default.

let tools_input = cx.new(|cx| InputState::new(window, cx));
let items = vec![
    QuestionnaireItemDefinition::new("plan", "Which plan fits your team?")
        .with_required(true)
        .with_choices([
            QuestionnaireChoiceDefinition::new("plus", "Plus").with_default_selected(true),
            QuestionnaireChoiceDefinition::new("pro", "Pro"),
        ]),
    QuestionnaireItemDefinition::new("tools", "Which tools do you use?")
        .with_multiple(true)
        .with_choices([
            QuestionnaireChoiceDefinition::new("editor", "Editor"),
            QuestionnaireChoiceDefinition::new("terminal", "Terminal"),
            QuestionnaireChoiceDefinition::new("browser", "Browser").with_disabled(true),
        ])
        .with_input(QuestionnaireInputDefinition::new(tools_input, "Something else")),
    QuestionnaireItemDefinition::new("advanced", "Advanced preferences").with_disabled(true),
];
Questionnaire {
    show-counter: false;
    show-progress: false;
    show-badges: false;
    questions: [
        {
            prompt: "How often do you ship?",
            help: "",
            choices: [
                { title: "Daily", detail: "" },
                { title: "Weekly", detail: "" },
                { title: "Monthly", detail: "" },
                { title: "Rarely", detail: "" },
            ],
            free-text: "",
        },
    ];
}

QuestionnaireState::new rejects duplicate item names, duplicate choice values within an item, and multiple defaults on a single-choice item. Setters for unknown items or choices return QuestionnaireSchemaError.

#Freeform answer

Add QuestionnaireInputDefinition to allow a user to enter an answer that is not in the fixed choices. Give the input an accessible label; a placeholder is not a label.

Whitespace-only input is unanswered. The input draft is kept when a fixed choice is selected, but it is submitted only when the freeform answer is active. In a multiple item, a non-empty freeform answer can accompany fixed choices.

#Validation

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// A rejected step: the message sits under the choices, above the actions, and
// it is the application's string. **Next** writes it while nothing is
// answered; answering clears it.
let validation = wired(
    app,
    Section::Validation,
    [Question::new("Choose a public handle")
        .help("The validator rejects short handles.")
        .choices([
            Choice::new("Use my account name").detail("Taken from your profile."),
            Choice::new("Pick a new handle").detail("At least three characters."),
        ])
        .free_text("Public handle")],
);

Required status validation is built in. Add a synchronous validator to an item for domain-specific checks. The validator receives the current item, its answer, and the complete enabled answer snapshot through QuestionnaireValidationContext. Next validates the current item; Submit validates all enabled items and focuses the first invalid item.

let item = QuestionnaireItemDefinition::new("handle", "Choose a handle")
    .with_required(true)
    .with_validator(|context| {
        if context
            .answer()
            .freeform()
            .is_some_and(|value| value.as_ref().len() >= 3)
        {
            Ok(())
        } else {
            Err("Use at least three characters.".into())
        }
    });
Questionnaire {
    error: "Choose a direction, or write one in the field above.";
    questions: [
        {
            prompt: "Choose a public handle",
            help: "The validator rejects short handles.",
            choices: [
                { title: "Use my account name", detail: "Taken from your profile." },
                { title: "Pick a new handle", detail: "At least three characters." },
            ],
            free-text: "Public handle",
        },
    ];
}

An optional unanswered item is invalid until the user explicitly skips it; Skipped is intentionally valid. Disabled items and disabled controls do not participate in validation. The first invalid item is selected on submit, and focus goes to its filled input or selected choice before falling back to the first enabled control.

Use external errors for schema or server responses. External errors belong to the host and remain until the host clears them.

state.update(cx, |state, cx| {
    state
        .set_external_error("handle", "This handle is already taken.", cx)
        .expect("known questionnaire item");
});

// After the owner accepts a corrected answer or a new server response:
state.update(cx, |state, cx| {
    state
        .clear_external_error("handle", cx)
        .expect("known questionnaire item");
});
SlintNo Slint version of this example yet.

reset clears internal validation attempts and errors, but preserves owner-managed external errors.

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Back and Next step through the list; the last step submits.
let navigation = wired(
    app,
    Section::Navigation,
    [
        Question::new("Confirm the release channel")
            .choices([Choice::new("Stable"), Choice::new("Nightly")]),
        Question::new("Confirm the rollout")
            .choices([Choice::new("Everyone"), Choice::new("One team first")]),
    ],
)
.show_counter(false)
.show_progress(false);

let navigation = column![
    navigation,
    muted_line(if app.navigation.finished {
        "Submitted."
    } else {
        "The primary action reads Submit on the last step."
    }),
]
.spacing(12.0);

QuestionnaireState exposes the current item, ordered item states, and navigation state for custom action layouts.

let state = state.read(cx);
let progress = state.progress();
let status = state.item_state("direction").map(|item| item.status());
let navigation = state.navigation_state();
let show_skip = navigation.is_skip_visible();
Questionnaire {
    show-counter: false;
    show-progress: false;
    questions: [
        {
            prompt: "Confirm the release channel",
            help: "",
            choices: [
                { title: "Stable", detail: "" },
                { title: "Nightly", detail: "" }
            ],
            free-text: "",
        },
        {
            prompt: "Confirm the rollout",
            help: "",
            choices: [
                { title: "Everyone", detail: "" },
                { title: "One team first", detail: "" }
            ],
            free-text: "",
        },
    ];
}

QuestionnaireNavigationState answers the same question for Previous, Next, Submit, and is_confirmable; current_item and current_ix locate the active item.

The default action layout shows Previous at the beginning, Next between items, Skip only for the active optional item, and Submit at the end. Hidden actions are not rendered and do not enter keyboard navigation. Disabled items are removed from the navigation and progress totals. The three item statuses are Unanswered, Answered, and Skipped.

#Skipping

Optional items can expose QuestionnaireSkip. A skip is an intentional valid state, clears the item answer, and allows Next to continue. Required items do not allow skipping. Re-entering an item and choosing an answer clears its skipped state. Skipping the final enabled item requests submission after the skip has been recorded.

#Events and submission

Subscribe to QuestionnaireEvent for active-item changes, answer changes, completion, and successful submit. Completed is emitted on the transition into a complete state; Submit is emitted for each successful explicit submit. On the first successful submit, the order is Completed followed by Submit. Changing answers or enabled conditions clears completion, so the next successful submit can emit Completed again.

use gpui_kit::component::questionnaire::QuestionnaireEvent;

cx.subscribe(&state, |_, _, event, _| match event {
    QuestionnaireEvent::CurrentItemChanged { current, .. } => {
        println!("Current item: {:?}", current);
    }
    QuestionnaireEvent::AnswerChanged(change) => {
        println!("Changed: {:?} ({:?})", change.item(), change.status());
    }
    QuestionnaireEvent::Completed(submission)
    | QuestionnaireEvent::Submit(submission) => {
        println!("Answers: {:?}", submission.items());
    }
    _ => {}
})
.detach();
SlintNo Slint version of this example yet.

Detaching keeps the callback alive until the subscribed entities are dropped. Store the returned Subscription in the host instead when it needs to cancel the listener earlier.

The submission is ordered by the item schema and contains only enabled items. Each item includes its name, Unanswered/Answered/Skipped status, and effective answer. It represents a validated local submission request; saving it remotely remains the host application’s responsibility.

#Controlling the state

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The host owns the step, so its own controls can drive the flow.
let mut previous = Button::new("Previous question").outline();
if app.controlled.step > 0 {
    previous = previous.on_press(Message::Offset(-1));
}
let mut next = Button::new("Next question").outline();
if app.controlled.step < 1 {
    next = next.on_press(Message::Offset(1));
}

let controlled = wired(
    app,
    Section::Controlled,
    [
        Question::new("First")
            .help("Driven from outside.")
            .choices([Choice::new("A"), Choice::new("B")]),
        Question::new("Second")
            .help("The control's own buttons move this too.")
            .choices([Choice::new("A"), Choice::new("B")]),
    ],
)
.show_progress(false);

let controlled = column![
    row![previous, next].spacing(8.0).align_y(Alignment::Center),
    controlled,
]
.spacing(12.0);

When a page owns the active item or needs to apply a saved answer after state creation, use the silent setters. They update the UI and focus as needed but do not emit user-interaction events.

use gpui_kit::component::questionnaire::QuestionnaireAnswer;

state.update(cx, |state, cx| {
    state
        .set_current_item("detail", window, cx)
        .expect("known enabled questionnaire item");
    state
        .set_answer(
            "direction",
            QuestionnaireAnswer::new().with_choices(["delegation"]),
            window,
            cx,
        )
        .expect("known questionnaire item");
    state
        .set_input_value("direction", "A controlled draft", window, cx)
        .expect("item has an input");
});
VerticalLayout {
    spacing: 12px;
    HorizontalLayout {
        spacing: 8px;
        alignment: start;
        Button {
            text: "Previous question";
            outline: true;
            enabled: root.controlled-step > 0;
            clicked => {
                root.controlled-step -= 1;
            }
        }
        Button {
            text: "Next question";
            outline: true;
            enabled: root.controlled-step < 1;
            clicked => {
                root.controlled-step += 1;
            }
        }
    }
    controlled := Questionnaire {
        show-progress: false;
        step <=> root.controlled-step;
        questions: [
            {
                prompt: "First",
                help: "Driven from outside.",
                choices: [
                    { title: "A", detail: "" },
                    { title: "B", detail: "" }
                ],
                free-text: ""
            },
            {
                prompt: "Second",
                help: "The control's own buttons move this too.",
                choices: [
                    { title: "A", detail: "" },
                    { title: "B", detail: "" }
                ],
                free-text: ""
            },
        ];
    }
}

Use choose, confirm_current, go_previous, go_next, skip_current, and submit for user intent. choose is what a click or shortcut runs, so on a single-choice item it also confirms; activate_choice changes the answer without confirming. Those paths emit the relevant QuestionnaireEvent values. A host can also use set_item_disabled and set_choice_disabled; disabling the current item moves focus to the next enabled item, or to the previous one when there is no next item.

#Reset

Reset restores the initial choices and input drafts, clears intentional skips, validation attempts, and completion, and returns to the initial current item. It also focuses the restored current item.

state.update(cx, |state, cx| {
    state.reset(window, cx);
});
SlintNo Slint version of this example yet.

External errors remain owner-managed across reset. If a reset should also remove a server error, clear it explicitly with clear_external_error.

reset returns to the snapshot the schema was built with, so a saved draft belongs in the definitions: InputState::default_value, with_default_selected, and with_current_item establish that baseline. Values applied later with set_answer, set_input_value, or set_current_item change the current state without moving the reset baseline.

#Conditional items

Questionnaire does not contain a branching engine. The host can derive an item’s disabled state from an earlier answer and synchronize it with set_item_disabled. This keeps conditional policy in the page while the Questionnaire continues to own ordering, focus, progress, validation, and submission.

fn sync_advanced_item(
    state: &Entity<QuestionnaireState>,
    window: &mut Window,
    cx: &mut App,
) {
    let enabled = state.read(cx).answer("direction").is_some_and(|answer| {
        answer
            .choices()
            .iter()
            .any(|choice| choice.as_ref() == "delegation")
    });

    state.update(cx, |state, cx| {
        let _ = state.set_item_disabled("advanced", !enabled, window, cx);
    });
}
SlintNo Slint version of this example yet.

Call this helper from the host’s answer-change handling or from the UI action that changes the earlier answer. A disabled conditional item is excluded from progress, navigation, validation, focus, shortcuts, and submission.

#Keyboard shortcuts

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The same choices with numbered badges instead of letters. The badges are
// labels: the keys belong to the application, which this demo does not bind.
let shortcuts = wired(
    app,
    Section::Shortcuts,
    [Question::new("Choose an answer or type your own")
        .help("The badges name the key that picks each choice.")
        .choices([
            Choice::new("First choice").detail("Press 1 to pick this."),
            Choice::new("Second choice").detail("Press 2 to pick this."),
            Choice::new("Third choice").detail("Press 3 to pick this."),
        ])
        .free_text("Type without triggering 1, 2, or 3…")],
)
.badges(["1", "2", "3"]);

Enable letter or number shortcuts on the state. Shortcuts apply only to the active item’s enabled choices. Repeated key events, text input, IME composition, and modified key presses are left untouched.

use gpui_kit::component::questionnaire::QuestionnaireShortcutMode;

let state = cx.new(|cx| {
    QuestionnaireState::new(items, cx)
        .expect("valid questionnaire schema")
        .with_shortcuts(QuestionnaireShortcutMode::Letters)
});
Questionnaire {
    badges: [
        "1",
        "2",
        "3"
    ];
    questions: [
        {
            prompt: "Choose an answer or type your own",
            help: "The badges name the key that picks each choice.",
            choices: [
                { title: "First choice", detail: "Press 1 to pick this." },
                { title: "Second choice", detail: "Press 2 to pick this." },
                { title: "Third choice", detail: "Press 3 to pick this." },
            ],
            free-text: "Type without triggering 1, 2, or 3…",
        },
    ];
}

Questionnaire handles radio movement according to the native single-choice interaction. Up and Down otherwise move through enabled choices and the freeform input in schema order; the input remains in that order when present. When a non-empty text input has focus, its normal text-editing behavior is preserved. Left and Right move between items only outside text inputs and single-choice radio controls; Right requires a confirmable current item.

Enter confirms a filled answer, and Space or Enter on an unselected choice chooses it and confirms its single-choice item. Command/Ctrl+Enter confirms the current item. An empty answer does not implicitly submit. Shortcut labels are assigned in enabled-choice order (A–Z or 1–9), and disabled choices receive no label.

#Progress

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The bar on its own, with the counter turned off, on the second question.
let progress = wired(
    app,
    Section::Progress,
    [
        Question::new("First question").choices([Choice::new("A"), Choice::new("B")]),
        Question::new("Second question").choices([Choice::new("A"), Choice::new("B")]),
        Question::new("Third question").choices([Choice::new("A"), Choice::new("B")]),
    ],
)
.show_counter(false);

QuestionnaireProgress follows the default presentation: “Question 2 of 4”. The same snapshot can drive an existing indicator instead.

QuestionnaireProgress::new(&state);

let progress = state.read(cx).progress();
let percent = if progress.total() == 0 {
    0.
} else {
    progress.current() as f32 / progress.total() as f32 * 100.
};
Progress::new("questionnaire-progress").value(percent);
Questionnaire {
    show-counter: false;
    questions: [
        {
            prompt: "First question",
            help: "",
            choices: [
                { title: "A", detail: "" },
                { title: "B", detail: "" }
            ],
            free-text: ""
        },
        {
            prompt: "Second question",
            help: "",
            choices: [
                { title: "A", detail: "" },
                { title: "B", detail: "" }
            ],
            free-text: ""
        },
        {
            prompt: "Third question",
            help: "",
            choices: [
                { title: "A", detail: "" },
                { title: "B", detail: "" }
            ],
            free-text: ""
        },
    ];
    step: 1;
}

current and total count only the enabled items, and both move when the host disables or re-enables a question. An indicator with one fixed label per step — a Stepper, for example — has to derive its steps from the same enabled set, or its labels and its selected step drift apart from the questionnaire.

#Sizes and theming

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The disc, the row padding and the prompt scale together, from the theme.
let small = wired(
    app,
    Section::Small,
    [Question::new("Small")
        .choices([Choice::new("First choice"), Choice::new("Second choice")])],
)
.size(theme::ControlSize::Small);

let large = wired(
    app,
    Section::Large,
    [Question::new("Large")
        .help("The disc, the row padding, and the prompt scale together.")
        .choices([
            Choice::new("First choice").detail("With a sentence under it."),
            Choice::new("Second choice"),
        ])],
)
.size(theme::ControlSize::Large);

let sizes = column![small, large].spacing(24.0);

Questionnaire takes the scale for the whole questionnaire, and every part of that questionnaire follows it — the root publishes the size under its state, so the compound parts do not have to be told individually. A part that names its own size keeps it.

use gpui_kit::component::{Sizable as _, Size};

Questionnaire::new(&state)
    .with_size(Size::Small)
    .child(QuestionnaireProgress::new(&state))
    .child(
        QuestionnaireItem::new(&state, "direction")
            .child(QuestionnaireTitle::new(&state, "direction"))
            .child(
                QuestionnaireChoices::new(&state, "direction")
                    // Follows the root; pass `with_size` here only to differ.
                    .child(QuestionnaireChoice::new(&state, "direction", "delegation")),
            ),
    );
VerticalLayout {
    spacing: 16px;
    Questionnaire {
        size: ControlSize.small;
        questions: [
            {
                prompt: "Small",
                help: "",
                choices: [
                    { title: "First choice", detail: "" },
                    { title: "Second choice", detail: "" },
                ],
                free-text: "",
            },
        ];
    }
    Questionnaire {
        size: ControlSize.large;
        questions: [
            {
                prompt: "Large",
                help: "The disc, the row padding, and the prompt scale together.",
                choices: [
                    { title: "First choice", detail: "With a sentence under it." },
                    { title: "Second choice", detail: "" },
                ],
                free-text: "",
            },
        ];
    }
}

The supported sizes are XSmall, Small, Medium (the default) and Large, plus Size::Size(value) for a custom scale. Answer text matches the Checkbox and Radio family’s label at the same size.

Spacing, typography, radius, border, input, primary, muted, destructive, and focus-ring values all come from the active theme’s semantic tokens, so an application changes the questionnaire’s shape by changing the theme. Use Styled methods or StyleRefinement for local adjustments; local style refinement is applied after the component defaults.

QuestionnaireChoiceDescription is the one part with no state of its own — it is a plain text slot for a custom choice body — so it defaults to Medium and takes with_size when a custom composition needs another scale.

#Card and Dialog composition

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The questionnaire keeps its progress and actions; the card owns the surface
// around them.
let card = Card::new()
    .title("Prototype brief")
    .description("The card owns the surface; the questionnaire owns the steps.")
    .body(wired(
        app,
        Section::Card,
        [Question::new("Who is the prototype for?").choices([
            Choice::new("The design team"),
            Choice::new("A new contributor"),
        ])],
    ));

The questionnaire owns the question flow; the container owns its surface and its close or cancel behavior. Put the whole composition — progress, every item, and the actions — inside the container, so moving to the next question stays visible.

use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants as _};

GroupBox::new()
    .outline()
    .title("Set up your workspace")
    .child(questionnaire);
Card {
    title: "Prototype brief";
    description: "The card owns the surface; the questionnaire owns the steps.";
    Questionnaire {
        show-counter: false;
        show-progress: false;
        questions: [
            {
                prompt: "Who is the prototype for?",
                help: "",
                choices: [
                    { title: "The design team", detail: "" },
                    { title: "A new contributor", detail: "" },
                ],
                free-text: "",
            },
        ];
    }
}

In a dialog, the footer carries the container’s own Cancel next to the questionnaire’s actions, and the host closes the dialog when the questionnaire reports a successful submit.

use gpui_kit::component::dialog::{
    Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle,
};
use gpui_kit::component::{WindowExt as _, questionnaire::QuestionnaireEvent};

let dialog_state = state.clone();
cx.subscribe_in(
    &dialog_state,
    window,
    |_, _, event: &QuestionnaireEvent, window, cx| {
        if matches!(event, QuestionnaireEvent::Submit(_)) {
            window.close_dialog(cx);
        }
    },
)
.detach();

Dialog::new(cx)
    .trigger(Button::new("open-questionnaire").outline().label("Open questionnaire"))
    .content(move |content, _, _| {
        content
            .child(DialogHeader::new().child(DialogTitle::new().child("Workspace setup")))
            .child(
                Questionnaire::new(&dialog_state)
                    // …progress and every item, as in Usage above
                    .child(
                        DialogFooter::new()
                            .child(DialogClose::new().child(
                                Button::new("cancel-questionnaire").outline().label("Cancel"),
                            ))
                            .child(
                                QuestionnaireActions::new(&dialog_state)
                                    .child(QuestionnairePrevious::new(&dialog_state))
                                    .child(QuestionnaireNext::new(&dialog_state))
                                    .child(QuestionnaireSubmit::new(&dialog_state)),
                            ),
                    ),
            )
    });
SlintNo Slint version of this example yet.

Cancel always closes. Submit closes only after the questionnaire has validated every enabled item, and the same event hands the validated QuestionnaireSubmission to application transport.

#Accessibility

Questionnaire uses the GPUI Form role for the root. QuestionnaireItem is an accessible group with its item label and description. The definition’s accessibility_label and description remain the semantic source for the item and choice, even when a custom child replaces the visible fallback content. QuestionnaireError is announced as an alert only while the item is invalid. Choice parts preserve radio and checkbox semantics, progress exposes current and total values, and navigation uses real buttons.

Inactive items and hidden actions are removed from keyboard navigation. On a successful transition focus moves to the new item; on validation failure focus moves to the selected or filled answer control, then to the first available control.

Always provide an accessible label for a freeform input with its definition’s accessibility_label; a visible label or equivalent custom composition can supplement it. The GPUI accessibility layer does not expose a direct aria-invalid builder. Questionnaire still exposes invalid state through its error alert, semantic group state, focus behavior, and destructive styling.

#Current scope

The questionnaire asks one question at a time: parts belonging to any question other than the current one render nothing, so a single page of several questions is not what this component builds. The schema is fixed at construction — questions and choices cannot be inserted or reordered at runtime, though any of them can be disabled — and validators run synchronously. Persistence, transport, and submission side effects belong to the containing page, which subscribes to QuestionnaireEvent.

#API reference

#QuestionChoice

One option in a question: a line, and a sentence that says what it means.

NameKindTypeDefault
titlefieldstring
detailfieldstring

#Question

One step. `free-text` is the placeholder of the write-your-own row, and an empty one hides that row.

NameKindTypeDefault
promptfieldstring
helpfieldstring
choicesfield[QuestionChoice]
free-textfieldstring

#Questionnaire

A stepped form. Mirrors GPUI Component's `Questionnaire`. The parts GPUI composes by hand — a progress bar, a "Question 2 of 4" counter, a prompt with a description, a choice list whose rows carry descriptions and keyboard badges, a write-your-own field, an error line, and the Back/Next pair — are properties here, so an example varies them without rebuilding the control.

NameKindTypeDefaultDescription
questionsin[Question]
stepin-outint0
answerin-outint0
free-answerin-outstringWhat the write-your-own row holds. The host reads it alongside `answer`.
errorinstringA validation message for the current step. Empty draws nothing.
badgesin[string]["A", "B", "C", "D", "E", "F", "G", "H"]The keyboard badges, one per choice. Replace them for numbered shortcuts.
show-badgesinbooltrue
show-progressinbooltrue
show-counterinbooltrue
sizeinControlSizeControlSize.medium
finishedcallback()
changedcallback(step: int, answer: int)