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.
Installation
Copy the command below and run it in your terminal.
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.
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.
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
No features selected
Free text
Conversation takeover
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.
<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>