Question

The question, its answer controls, and the cancel and submit buttons render inside a Card. Ask one question, or step through several in the same card.

Which orders should the export include?

The orders table has 184,000 rows.

Question 1 of 3
<script lang="ts">
    import ArrowLeft from '@lucide/svelte/icons/arrow-left';
    import { Button } from '@sivir-ui/svelte/components/button';
    import type { QuestionAnswer } from '@sivir-ui/svelte/components/question';
    import * as Question from '@sivir-ui/svelte/components/question';

    const questions = [
        {
            title: 'Which orders should the export include?',
            description: 'The orders table has 184,000 rows.',
            options: [
                {
                    value: 'last-30-days',
                    label: 'Last 30 days',
                    description: 'About 12,400 orders.'
                },
                {
                    value: 'quarter',
                    label: 'This quarter',
                    description: 'About 41,000 orders, including refunds.'
                },
                {
                    value: 'all',
                    label: 'All orders',
                    description: 'Every row. The export runs in the background.'
                }
            ]
        },
        {
            title: 'How should amounts be formatted?',
            description: 'Finance opens this file in a spreadsheet.',
            options: [
                {
                    value: 'cents',
                    label: 'Integer cents',
                    description: 'Matches the database, such as 129900.'
                },
                {
                    value: 'decimal',
                    label: 'Decimal dollars',
                    description: 'Two decimal places, such as 1299.00.'
                },
                {
                    value: 'currency',
                    label: 'Formatted currency',
                    description: 'With symbol and separators, such as $1,299.00.'
                }
            ]
        },
        {
            title: 'Where should the file go?',
            description: 'Download links expire after seven days.',
            options: [
                {
                    value: 'link',
                    label: 'Download link',
                    description: 'Post a link in this chat when the file is ready.'
                },
                {
                    value: 'bucket',
                    label: 'Shared bucket',
                    description: 'Upload to s3://finance-exports/orders/.'
                },
                {
                    value: 'email',
                    label: 'Email to finance',
                    description: 'Send the file to finance@example.com.'
                }
            ]
        }
    ];
    let step = $state(0);
    let answers = $state<QuestionAnswer[]>(['', '', '']);
    const complete = $derived(step === questions.length);
    const question = $derived(questions[Math.min(step, questions.length - 1)]);

    function next() {
        if (!complete) {
            step += 1;
        }
    }

    function restart() {
        answers = ['', '', ''];
        step = 0;
    }
</script>

<div class="w-full max-w-xl">
    <Question.Root
        variant="inset"
        bind:value={answers[step]}
        required={!complete}
        onSubmit={next}
    >
        <Question.Content {step}>
            {#if complete}
                <Question.Title>Ready to export</Question.Title>
                <Question.Description>
                    Check your choices before the export starts. Go back to change one.
                </Question.Description>
                <dl class="grid gap-3 px-3 pt-3 pb-3">
                    {#each questions as item, index (item.title)}
                        <div class="grid gap-1">
                            <dt class="text-xs text-foreground-muted">{item.title}</dt>
                            <dd class="m-0 text-sm font-medium">
                                {item.options.find((option) => option.value === answers[index])?.label}
                            </dd>
                        </div>
                    {/each}
                </dl>
            {:else}
                <Question.Title>{question.title}</Question.Title>
                <Question.Description>{question.description}</Question.Description>
                <Question.Options>
                    {#each question.options as option (option.value)}
                        <Question.Option {...option} />
                    {/each}
                </Question.Options>
            {/if}
        </Question.Content>
        <Question.Actions>
            <span class="me-auto text-xs tabular-nums text-foreground-muted" role="status">
                {complete ? 'All questions answered' : `Question ${step + 1} of ${questions.length}`}
            </span>
            <Question.Cancel
                disabled={step === 0}
                onclick={(event) => {
                    event.preventDefault();
                    step -= 1;
                }}
            >
                <ArrowLeft size={14} aria-hidden="true" />
                Back
            </Question.Cancel>
            {#if complete}
                <Button type="button" variant="primary" size="md" onclick={restart}>
                    Start again
                </Button>
            {:else}
                <Question.Submit label={step === questions.length - 1 ? 'Finish' : 'Next'} />
            {/if}
        </Question.Actions>
    </Question.Root>
</div>

Installation

Copy the command below and run it in your terminal.

bunx @sivir-ui/svelte add question

Usage

onSubmit is required and receives the answer. onCancel runs when Question.Cancel is pressed. To ask mid-conversation, render Question.Root in place of Composer.Root and keep the composer's value in their shared parent, so the unsent draft survives the swap.

import * as Question from '@sivir-ui/svelte/components/question';
import type { QuestionAnswer } from '@sivir-ui/svelte/components/question';

let answer: QuestionAnswer | undefined = $state();

<Question.Root
  variant="inset"
  bind:value={answer}
  onSubmit={(value) => continueAgent(value)}
  onCancel={() => skipQuestion()}
>
  <Question.Content>
    <Question.Title>Which environment should I deploy to?</Question.Title>
    <Question.Description>Production deploys need a second reviewer.</Question.Description>
    <Question.Options>
      <Question.Option value="preview" label="Preview" />
      <Question.Option value="production" label="Production" />
    </Question.Options>
  </Question.Content>
  <Question.Actions>
    <Question.Cancel>Skip question</Question.Cancel>
    <Question.Submit />
  </Question.Actions>
</Question.Root>

type defaults to "single", which answers with a string. Use type="multiple" for a string array, or type="text" with Question.Input. An answer is required unless you set required={false}; submitting without one shows a validation message. Async submit handlers are awaited and cannot run twice while unresolved. Changing type resets the bound answer to the new type's empty value.

Each option shows its position as a key hint. While focus is inside the question and not in a text field, pressing 1 through 9 selects that option, or toggles it with type="multiple".

Composition

variant="inset" on Question.Root places the content on a recessed surface inside the Card frame. The default variant is a plain Card. Place Question.Actions after Question.Content to keep the buttons in the footer. Question.Description and Question.Actions are optional.

import * as Question from '@sivir-ui/svelte/components/question';

<Question.Root variant="inset" bind:value={answer} onSubmit={next}>
  <Question.Content step={index}>
    <Question.Title>{question.title}</Question.Title>
    <Question.Options>
      {#each question.options as option (option.value)}
        <Question.Option {...option} />
      {/each}
    </Question.Options>
  </Question.Content>
  <Question.Actions class="justify-between">
    <Question.Cancel disabled={index === 0} onclick={(event) => {
      event.preventDefault();
      back();
    }}>Back</Question.Cancel>
    <Question.Submit label="Next" />
  </Question.Actions>
</Question.Root>

Question.Content groups the title, description, and answer controls in one fieldset. Pass the current step index as step to animate between questions: the outgoing step blurs away, the next one slides in from the direction of travel, and the height eases to fit. A higher index moves forward and a lower index moves back. Without step, content changes in place.

The motion reads theme tokens: --motion-duration-step-in, --motion-duration-step-out, --motion-step-x, and --motion-step-blur. The themeโ€™s motion preset scales the durations, and reduced motion turns the animation off.

Content animates step changes but does not store answers or handle navigation, so keep the step index and each answer in the parent, as in the example above. Keep a flow to one answer type, or key the Root per question when mixing types, because changing type on an existing Root clears its answer. When Cancel acts as Back, call event.preventDefault() in its onclick so the Root's onCancel does not run.

Examples

Multiple choice

Which features should I build first?

Select each feature to include in the 0.2.0 release.

No features selected

<script lang="ts">
    import type { QuestionAnswer } from '@sivir-ui/svelte/components/question';
    import * as Question from '@sivir-ui/svelte/components/question';

    let answer = $state<QuestionAnswer>([]);
    let summary = $state('');

    const capabilities = [
        {
            value: 'voice',
            label: 'Voice input',
            description: 'Users can speak commands and questions aloud.'
        },
        {
            value: 'video',
            label: 'Video output',
            description: 'Responses include visual demos and annotated screenshots.'
        },
        {
            value: 'offline',
            label: 'Offline mode',
            description: 'Cache conversations locally; sync when reconnected.'
        },
        {
            value: 'sharing',
            label: 'Share conversations',
            description: 'Generate shareable links with optional access controls.'
        }
    ];
</script>

<div class="flex w-full max-w-2xl flex-col gap-3">
    <Question.Root
        variant="inset"
        type="multiple"
        bind:value={answer}
        onSubmit={(value) => {
            summary = Array.isArray(value) ? value.join(', ') : value;
        }}
    >
        <Question.Content>
            <Question.Title>Which features should I build first?</Question.Title>
            <Question.Description>
                Select each feature to include in the 0.2.0 release.
            </Question.Description>
            <Question.Options>
                {#each capabilities as capability (capability.value)}
                    <Question.Option {...capability} />
                {/each}
            </Question.Options>
        </Question.Content>
        <Question.Actions>
            <Question.Submit
                label={`Add ${Array.isArray(answer) ? answer.length : 0} feature${Array.isArray(answer) && answer.length !== 1 ? 's' : ''}`}
            />
        </Question.Actions>
    </Question.Root>

    <p class="min-h-5 text-sm text-foreground-muted" role="status">
        {summary ? `Selected: ${summary}` : 'No features selected'}
    </p>
</div>

Free text

What is the main issue you encountered?

Describe the problem in detail so the investigation can focus on the right area.

<script lang="ts">
    import { Button } from '@sivir-ui/svelte/components/button';
    import * as Question from '@sivir-ui/svelte/components/question';

    let answer = $state('');
    let submitted = $state('');
    let showFeedback = $state(false);
</script>

<div class="flex w-full max-w-2xl flex-col gap-3">
    {#if !showFeedback}
        <Question.Root
            variant="inset"
            type="text"
            bind:value={answer}
            onSubmit={(value) => {
                submitted = Array.isArray(value) ? value.join(', ') : value;
                showFeedback = true;
            }}
        >
            <Question.Content>
                <Question.Title>What is the main issue you encountered?</Question.Title>
                <Question.Description>
                    Describe the problem in detail so the investigation can focus on the right area.
                </Question.Description>
                <Question.Input
                    placeholder="Example: Build fails with 'module not found' error when running bun build"
                />
            </Question.Content>
            <Question.Actions>
                <Question.Submit />
            </Question.Actions>
        </Question.Root>
    {:else}
        <div class="rounded-[var(--radius-lg)] border border-border bg-panel/60 p-4 shadow-xs">
            <p class="mb-2 text-sm font-medium">Reported issue:</p>
            <p class="text-sm text-foreground-muted">{submitted}</p>
            <Button
                variant="ghost"
                class="mt-4 w-full"
                onclick={() => {
                    showFeedback = false;
                    submitted = '';
                    answer = '';
                }}
            >
                Report another issue
            </Button>
        </div>
    {/if}
</div>

Conversation takeover

Write the migration that renames account_id to customer_id. Don't run it in production yet.
The migration and its rollback are ready. Which environment should run it first?

Where should I run the migration first?

I'll stop after the first run and report the row counts.

<script lang="ts">
    import { Button } from '@sivir-ui/svelte/components/button';
    import * as Composer from '@sivir-ui/svelte/components/composer';
    import * as Conversation from '@sivir-ui/svelte/components/conversation';
    import * as Message from '@sivir-ui/svelte/components/message';
    import type { QuestionAnswer } from '@sivir-ui/svelte/components/question';
    import * as Question from '@sivir-ui/svelte/components/question';

    let asking = $state(true);
    let answer = $state<QuestionAnswer>();
    let draft = $state('Keep the migration reversible.');
    let sentPrompt = $state('');
    let shouldFocusQuestion = $state(false);

    function answerQuestion(value: QuestionAnswer) {
        answer = value;
        asking = false;
    }

    function sendPrompt(value: string) {
        sentPrompt = value;
        draft = '';
    }
</script>

<div
    class="flex h-[34rem] w-full max-w-3xl flex-col gap-3 rounded-[var(--radius-xl)] border border-border bg-panel p-3"
>
    <Conversation.Root class="min-h-0 flex-1">
        <Conversation.Content aria-label="Migration planning conversation">
            <Message.Root from="user">
                <Message.Content>
                    Write the migration that renames account_id to customer_id. Don't run it in
                    production yet.
                </Message.Content>
            </Message.Root>
            <Message.Root from="assistant">
                <Message.Content>
                    The migration and its rollback are ready. Which environment should run it first?
                </Message.Content>
            </Message.Root>
            {#if answer}
                <Message.Root from="user">
                    <Message.Content>
                        Use {Array.isArray(answer) ? answer.join(', ') : answer}.
                    </Message.Content>
                </Message.Root>
            {/if}
            {#if sentPrompt}
                <Message.Root from="user">
                    <Message.Content>{sentPrompt}</Message.Content>
                </Message.Root>
            {/if}
        </Conversation.Content>
        <Conversation.ScrollButton />
    </Conversation.Root>

    <div>
        {#if asking}
            <Question.Root
                variant="inset"
                bind:value={answer}
                autofocus={shouldFocusQuestion}
                onSubmit={answerQuestion}
            >
                <Question.Content>
                    <Question.Title>Where should I run the migration first?</Question.Title>
                    <Question.Description>
                        I'll stop after the first run and report the row counts.
                    </Question.Description>
                    <Question.Options>
                        <Question.Option
                            value="preview"
                            label="Preview environment"
                            description="A disposable database copy that resets nightly."
                        />
                        <Question.Option
                            value="staging"
                            label="Staging environment"
                            description="Shared with QA and seeded weekly from production."
                        />
                    </Question.Options>
                </Question.Content>
                <Question.Actions>
                    <Question.Cancel onclick={() => (asking = false)}
                        >Skip question</Question.Cancel
                    >
                    <Question.Submit />
                </Question.Actions>
            </Question.Root>
        {:else}
            <Composer.Root bind:value={draft} onSubmit={sendPrompt}>
                <Composer.Input aria-label="Message the agent" />
                <Composer.Toolbar>
                    <Composer.Actions>
                        <Button
                            variant="quiet"
                            size="md"
                            onclick={() => {
                                shouldFocusQuestion = true;
                                asking = true;
                            }}
                        >
                            Ask again
                        </Button>
                    </Composer.Actions>
                    <Composer.Submit />
                </Composer.Toolbar>
            </Composer.Root>
        {/if}
    </div>
</div>

Answer or skip the question to restore the composer with its draft intact.

Anatomy

Question.Root - Holds the answer and validates it on submit.

Question.Content - Groups the question and animates between steps.

Question.Title - Shows the question.

Question.Description - Adds context below the question.

Question.Options - Lists the answer choices.

Question.Option - One choice, with an optional description.

Question.Input - Collects a written answer.

Question.Actions - Holds the cancel and submit buttons.

Question.Cancel - Calls onCancel.

Question.Submit - Submits the answer.