Skip to main content
Urbicon UI
Back to Recipes

Multi-Step Wizard

A three-step signup wizard: Next stays disabled until the current step is filled in, a review step reads every answer back, and Back returns without losing input.

Built with Stepper StepperStep Input Select RadioGroup RadioItem Textarea Checkbox Button Card Progress Alert

Live preview

SignupPage.svelte

Fill a step to unlock Next; Back keeps everything you typed.
  1. 1
    Account Personal info
  2. 2
    Preferences Your choices
  3. 3
    Review Confirm details
<script lang="ts">
  import {
    Alert,
    Button,
    Card,
    Checkbox,
    Input,
    Progress,
    RadioGroup,
    RadioItem,
    Select,
    Stepper,
    StepperStep,
    Textarea
  } from '@urbicon-ui/blocks';

  let step = $state(0);
  let submitted = $state(false);

  // Step 0: Account
  let fullName = $state('');
  let email = $state('');

  // Step 1: Preferences
  let plan = $state('');
  let region = $state<string | null>(null);

  // Step 2: Review
  let notes = $state('');
  let agreedToTerms = $state(false);

  const regionOptions = [
    { label: 'United States', value: 'us' },
    { label: 'Europe', value: 'eu' },
    { label: 'Asia Pacific', value: 'asia' }
  ];

  const planLabels: Record<string, string> = {
    starter: 'Starter',
    pro: 'Professional',
    enterprise: 'Enterprise'
  };

  let progress = $derived(submitted ? 100 : Math.round((step / 3) * 100));

  // What the current step requires, nothing else: Next stays disabled until
  // this passes, and on the last step it holds Submit until the terms are
  // accepted.
  let canNext = $derived.by(() => {
    if (step === 0) return fullName.trim() !== '' && email.trim() !== '';
    if (step === 1) return plan !== '' && region !== null;
    if (step === 2) return agreedToTerms;
    return false;
  });

  function next() {
    if (step < 2) {
      step += 1;
    } else if (canNext) {
      submitted = true;
    }
  }

  function back() {
    if (step > 0) step -= 1;
  }

  function reset() {
    step = 0;
    submitted = false;
    fullName = '';
    email = '';
    plan = '';
    region = null;
    notes = '';
    agreedToTerms = false;
  }
</script>

<!-- Centre it in your page's own layout; the card caps its own width. -->
<div class="w-full max-w-xl">
  <Card variant="elevated" padding="lg">
    <Progress value={progress} size="sm" intent="primary" class="mb-6" />

    <!-- The rail only shows position: steps are not clickable, so the gated
         Next is the only way forward. -->
    <Stepper bind:activeStep={step} orientation="horizontal">
      <StepperStep label="Account" description="Personal info" />
      <StepperStep label="Preferences" description="Your choices" />
      <StepperStep label="Review" description="Confirm details" />
    </Stepper>

    {#if submitted}
      <div class="mt-8">
        <Alert intent="success" variant="soft" title="All done!">
          Your wizard has been submitted successfully.
        </Alert>
        <Button variant="outlined" intent="neutral" onclick={reset} class="mt-4">
          Start Over
        </Button>
      </div>
    {:else}
      <div class="mt-8 space-y-5">
        {#if step === 0}
          <Input label="Full Name" bind:value={fullName} required placeholder="Jane Doe" />
          <Input label="Email" type="email" bind:value={email} required placeholder="jane@example.com" />
        {:else if step === 1}
          <RadioGroup bind:value={plan} label="Choose a plan">
            <RadioItem
              value="starter"
              label="Starter"
              description="For individuals and side projects"
            />
            <RadioItem value="pro" label="Professional" description="For growing teams" />
            <RadioItem
              value="enterprise"
              label="Enterprise"
              description="For large organizations"
            />
          </RadioGroup>
          <Select
            label="Region"
            options={regionOptions}
            bind:value={region}
            placeholder="Select a region"
          />
        {:else}
          <Textarea
            label="Additional Notes"
            bind:value={notes}
            autoResize
            showCounter
            maxlength={500}
            placeholder="Anything else we should know?"
          />
          <Card variant="quiet" padding="sm">
            <h3 class="text-text-primary mb-2 text-sm font-semibold">Summary</h3>
            <dl class="text-text-secondary space-y-1 text-sm">
              <div class="flex justify-between">
                <dt>Name</dt>
                <dd class="text-text-primary font-medium">{fullName}</dd>
              </div>
              <div class="flex justify-between">
                <dt>Email</dt>
                <dd class="text-text-primary font-medium">{email}</dd>
              </div>
              <div class="flex justify-between">
                <dt>Plan</dt>
                <dd class="text-text-primary font-medium">{planLabels[plan] ?? ''}</dd>
              </div>
              <div class="flex justify-between">
                <dt>Region</dt>
                <dd class="text-text-primary font-medium">
                  {regionOptions.find((o) => o.value === region)?.label ?? ''}
                </dd>
              </div>
            </dl>
          </Card>
          <Checkbox label="I agree to the terms and conditions" bind:checked={agreedToTerms} />
        {/if}
      </div>

      <div class="mt-6 flex justify-between">
        <Button variant="ghost" intent="neutral" onclick={back} disabled={step === 0}>Back</Button>
        <Button intent="primary" onclick={next} disabled={!canNext}>
          {step < 2 ? 'Next' : 'Submit'}
        </Button>
      </div>
    {/if}
  </Card>
</div>

Two decisions

The gate is the button, not the fields

Each step folds its requirements into canNext, and the disabled Next is where the user meets it. No field carries an error, because presence is all this wizard checks. When a step checks shape instead (an email format, a minimum length), hold the message back until there is input, as the Login Form recipe does, and fold that field's validity into canNext so the button and the message never disagree.

Back costs nothing because the page owns the values

Moving between steps unmounts the fields you leave, but every value lives in the page, not in the fields: Back returns to a filled step, and the review step reads the same variables straight into its summary. There is no store and nothing to collect on submit; the whole wizard is one component holding eight $state variables.

The three steps here run in a fixed order. When an answer decides what the next step even asks, derive the steps from the answers instead: Decision Tree Wizard builds its flow that way.