Menu

Change language

GitHub repo

Onboarding Wizard

blockauth

Multi-step workspace setup flow in a SectionCard: a four-step Stepper (Workspace → Team → Preferences → Done) with check icons on completed steps, a workspace-name input with live URL slug preview, teammate invites as removable email chips with a skip link, preference Switch rows, and a success panel with a glowing check circle. Footer carries Back/Continue actions plus a Step X of 4 counter. Data is stubbed inline — wire your API of choice.

Installation

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/onboarding-wizard.json
Manifest:https://uipkge.dev/r/svelte/onboarding-wizard.jsonInstalls to:src/lib/components/blocks/onboarding-wizard/

Variants

Loading interactive previews…

Files installed (1)

  • src/lib/components/blocks/onboarding-wizard/OnboardingWizard.svelte8.3 kB
    <script lang="ts">
      import { Check, ChevronLeft, ChevronRight, Globe, Plus, X } from '@lucide/svelte'
      import { Badge } from '$lib/components/ui/badge'
      import { Button } from '$lib/components/ui/button'
      import { Input } from '$lib/components/ui/input'
      import { SectionCard } from '$lib/components/ui/section-card'
      import { Stepper, type StepperStepConfig } from '$lib/components/ui/stepper'
      import { Switch } from '$lib/components/ui/switch'
    
      interface Preference {
        id: string
        label: string
        description: string
        enabled: boolean
      }
    
      interface OnboardingWizardProps {
        /** Jump straight to a step (1 Workspace · 2 Team · 3 Preferences · 4 Done). */
        initialStep?: number
        /** Pre-fill the workspace name so later steps read naturally in isolation. */
        initialWorkspaceName?: string
        /** Pre-seed invited teammates. */
        initialInvites?: string[]
        class?: string
      }
    
      let { initialStep = 1, initialWorkspaceName = 'Acme Inc', initialInvites = [], class: className }: OnboardingWizardProps =
        $props()
    
      const steps: StepperStepConfig[] = [
        { id: 1, title: 'Workspace' },
        { id: 2, title: 'Team' },
        { id: 3, title: 'Preferences' },
        { id: 4, title: 'Done' },
      ]
    
      // svelte-ignore state_referenced_locally: initial* props seed local state once by design.
      let step = $state(initialStep)
      // svelte-ignore state_referenced_locally: initial* props seed local state once by design.
      let workspaceName = $state(initialWorkspaceName)
      // svelte-ignore state_referenced_locally: initial* props seed local state once by design.
      let invites = $state<string[]>([...initialInvites])
      let inviteDraft = $state('')
      let inviteInvalid = $state(false)
    
      let preferences = $state<Preference[]>([
        {
          id: 'digests',
          label: 'Email digests',
          description: 'A daily summary of workspace activity.',
          enabled: true,
        },
        {
          id: 'updates',
          label: 'Product updates',
          description: 'Occasional notes about new features and improvements.',
          enabled: false,
        },
        {
          id: 'reports',
          label: 'Weekly reports',
          description: 'Usage analytics delivered every Monday morning.',
          enabled: true,
        },
      ])
    
      const slug = $derived.by(() => {
        const cleaned = workspaceName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
        return cleaned || 'your-workspace'
      })
    
      // The stepper only ever emits backward navigation (completed steps are the
      // clickable ones); still, lock the header once the flow is Done so a stray
      // click can't move the indicator while the success panel is showing.
      const stepperSteps = $derived(steps.map((s) => ({ ...s, disabled: step === 4 })))
    
      function addInvite() {
        const email = inviteDraft.trim()
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || invites.includes(email)) {
          inviteInvalid = true
          return
        }
        inviteInvalid = false
        invites.push(email)
        inviteDraft = ''
      }
    
      function removeInvite(email: string) {
        invites = invites.filter((e) => e !== email)
      }
    
      function onStepperInput(value: number) {
        // Free backward navigation; forward goes through the footer button only.
        if (value < step && step !== 4) step = value
      }
    
      function goNext() {
        if (step < 4) step += 1
      }
    
      function reset() {
        step = 1
        workspaceName = ''
        invites = []
        inviteDraft = ''
      }
    </script>
    
    <SectionCard
      data-slot="onboarding-wizard"
      title="Set up your workspace"
      description="Four quick steps and your team is ready to collaborate."
      class={className}
    >
      <Stepper steps={stepperSteps} value={step} class="mb-6" onValueChange={onStepperInput} />
    
      <!-- Step 1 · Workspace -->
      {#if step === 1}
        <div class="space-y-4">
          <div class="space-y-2">
            <label for="onboarding-workspace-name" class="text-sm font-medium">Workspace name</label>
            <Input
              id="onboarding-workspace-name"
              bind:value={workspaceName}
              placeholder="Acme Inc"
              autocomplete="off"
            />
          </div>
          <div class="border-border bg-muted/40 flex items-center gap-2 rounded-md border px-3 py-2">
            <Globe class="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
            <p class="min-w-0 truncate text-xs">
              <span class="text-muted-foreground">uipkge.dev/</span>
              <span class="text-foreground font-medium">{slug}</span>
            </p>
          </div>
        </div>
      {:else if step === 2}
        <!-- Step 2 · Team -->
        <div class="space-y-4">
          <form
            class="flex gap-2"
            onsubmit={(e) => {
              e.preventDefault()
              addInvite()
            }}
          >
            <Input
              bind:value={inviteDraft}
              type="email"
              placeholder="teammate@company.com"
              status={inviteInvalid ? 'error' : undefined}
              class="flex-1"
              onValueChange={() => (inviteInvalid = false)}
            />
            <Button type="submit" variant="outline" size="sm" class="shrink-0">
              <Plus aria-hidden="true" />
              Add
            </Button>
          </form>
          {#if inviteInvalid}
            <p role="alert" class="text-destructive text-xs">Enter a valid email address.</p>
          {/if}
          {#if invites.length > 0}
            <div class="flex flex-wrap gap-2">
              {#each invites as email (email)}
                <Badge variant="secondary" class="gap-1 py-1 pr-1 pl-2.5">
                  {email}
                  <button
                    type="button"
                    aria-label={`Remove ${email}`}
                    class="hover:bg-foreground/10 focus-visible:ring-ring min-h-6 rounded-full p-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
                    onclick={() => removeInvite(email)}
                  >
                    <X class="size-3" aria-hidden="true" />
                  </button>
                </Badge>
              {/each}
            </div>
          {:else}
            <p class="text-muted-foreground text-xs">No teammates invited yet.</p>
          {/if}
          <button
            type="button"
            class="text-muted-foreground hover:text-foreground focus-visible:ring-ring min-h-6 text-xs underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
            onclick={goNext}
          >
            Skip for now
          </button>
        </div>
      {:else if step === 3}
        <!-- Step 3 · Preferences -->
        <div class="divide-y rounded-lg border">
          {#each preferences as pref (pref.id)}
            <div class="flex items-center justify-between gap-4 p-4">
              <div class="min-w-0">
                <p class="text-sm font-medium">{pref.label}</p>
                <p class="text-muted-foreground mt-0.5 text-xs">{pref.description}</p>
              </div>
              <Switch bind:checked={pref.enabled} aria-label={pref.label} />
            </div>
          {/each}
        </div>
      {:else}
        <!-- Step 4 · Done -->
        <div class="space-y-4 py-6 text-center">
          <div
            class="border-success/30 bg-success/10 text-success mx-auto flex size-16 items-center justify-center rounded-full border shadow-xs"
          >
            <Check class="size-8" aria-hidden="true" />
          </div>
          <div>
            <p class="text-lg font-semibold">You're all set</p>
            <p class="text-muted-foreground mx-auto mt-1 max-w-sm text-sm">
              {#if invites.length > 0}
                {workspaceName || 'Your workspace'} is ready and {invites.length} invitation{invites.length === 1
                  ? ''
                  : 's'} sent.
              {:else}
                {workspaceName || 'Your workspace'} is ready — invite teammates anytime from settings.
              {/if}
            </p>
          </div>
          <Button variant="outline" size="sm" onclick={reset}>Set up another workspace</Button>
        </div>
      {/if}
    
      {#snippet footer()}
        <div class="flex w-full items-center justify-between">
          {#if step > 1 && step < 4}
            <Button variant="ghost" size="sm" onclick={() => (step -= 1)}>
              <ChevronLeft aria-hidden="true" />
              Back
            </Button>
          {:else}
            <span aria-hidden="true"></span>
          {/if}
          <span class="text-muted-foreground text-xs">Step {step} of 4</span>
          {#if step < 4}
            <Button size="sm" disabled={step === 1 && workspaceName.trim() === ''} onclick={goNext}>
              {step === 3 ? 'Finish' : 'Continue'}
              <ChevronRight aria-hidden="true" />
            </Button>
          {:else}
            <span aria-hidden="true"></span>
          {/if}
        </div>
      {/snippet}
    </SectionCard>
    

Raw manifest:https://uipkge.dev/r/svelte/onboarding-wizard.json