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@latest add https://uipkge.dev/r/lit/onboarding-wizard.json
Manifest:https://uipkge.dev/r/lit/onboarding-wizard.jsonInstalls to:src/uip/blocks/onboarding-wizard/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
initial-stepnumber1optional
initial-workspace-namestringAcme Incoptional
initial-invitesstring—optional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

WizardPreference
interface WizardPreference {
  id: string
  label: string
  description: string
  enabled: boolean
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-onboarding-wizard': UipOnboardingWizard
  }
}

Files installed (1)

  • src/uip/blocks/onboarding-wizard/onboarding-wizard.ts11.6 kB
    import { LitElement, css, html, nothing } from 'lit'
    import { Check, ChevronLeft, ChevronRight, Globe, Plus, X } from 'lucide'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/badge/badge'
    import '../../components/button/button'
    import '../../components/input/input'
    import '../../components/section-card/section-card'
    import '../../components/stepper/stepper'
    import '../../components/switch/switch'
    import type { UipInput } from '../../components/input/input'
    
    interface WizardPreference {
      id: string
      label: string
      description: string
      enabled: boolean
    }
    
    const STEPS = [{ title: 'Workspace' }, { title: 'Team' }, { title: 'Preferences' }, { title: 'Done' }]
    
    const DEFAULT_PREFERENCES: WizardPreference[] = [
      { 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,
      },
    ]
    
    /**
     * <uip-onboarding-wizard> — the registry OnboardingWizard block as a web component.
     *
     * Class strings are React's OnboardingWizard verbatim. React state becomes
     * @state fields. `initial-step` (React `initialStep`) jumps straight to a
     * step; `initial-workspace-name` and `initial-invites` (comma-separated)
     * pre-fill state. React's `className` maps to classes on the host. The slug
     * preview, invite chips, and preference switches behave as in React.
     */
    export class UipOnboardingWizard extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        initialStep: { type: Number, attribute: 'initial-step' },
        initialWorkspaceName: { attribute: 'initial-workspace-name' },
        initialInvites: { attribute: 'initial-invites' },
        step: { state: true },
        workspaceName: { state: true },
        invites: { state: true },
        inviteDraft: { state: true },
        inviteInvalid: { state: true },
        preferences: { state: true },
      }
    
      initialStep = 1
      initialWorkspaceName = 'Acme Inc'
      initialInvites = ''
      private step = 1
      private workspaceName = 'Acme Inc'
      private invites: string[] = []
      private inviteDraft = ''
      private inviteInvalid = false
      private preferences: WizardPreference[] = DEFAULT_PREFERENCES.map((p) => ({ ...p }))
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'onboarding-wizard')
        this.step = this.initialStep
        this.workspaceName = this.initialWorkspaceName
        this.invites = this.initialInvites
          ? this.initialInvites
              .split(',')
              .map((s) => s.trim())
              .filter(Boolean)
          : []
      }
    
      private get slug(): string {
        return (
          this.workspaceName
            .trim()
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/^-+|-+$/g, '') || 'your-workspace'
        )
      }
    
      private addInvite() {
        const email = this.inviteDraft.trim()
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || this.invites.includes(email)) {
          this.inviteInvalid = true
          return
        }
        this.inviteInvalid = false
        this.invites = [...this.invites, email]
        this.inviteDraft = ''
      }
    
      private removeInvite(email: string) {
        this.invites = this.invites.filter((e) => e !== email)
      }
    
      private onStepperInput(e: CustomEvent<{ value: number }>) {
        // Free backward navigation; forward goes through the footer button only.
        if (e.detail.value < this.step && this.step !== 4) this.step = e.detail.value
      }
    
      private goNext() {
        if (this.step < 4) this.step += 1
      }
    
      private reset() {
        this.step = 1
        this.workspaceName = ''
        this.invites = []
        this.inviteDraft = ''
      }
    
      render() {
        return html`
          <uip-section-card
            data-slot="onboarding-wizard"
            heading="Set up your workspace"
            description="Four quick steps and your team is ready to collaborate."
          >
            <uip-stepper
              .steps=${STEPS}
              value=${this.step}
              class="mb-6"
              @value-change=${this.onStepperInput}
            ></uip-stepper>
    
            ${this.step === 1
              ? html`
                  <div class="space-y-4">
                    <div class="space-y-2">
                      <label for="onboarding-workspace-name" class="text-sm font-medium">Workspace name</label>
                      <uip-input
                        id="onboarding-workspace-name"
                        .value=${this.workspaceName}
                        placeholder="Acme Inc"
                        autocomplete="off"
                        @input=${(e: Event) => {
                          this.workspaceName = (e.target as UipInput).value
                        }}
                      ></uip-input>
                    </div>
                    <div class="border-border bg-muted/40 flex items-center gap-2 rounded-md border px-3 py-2">
                      ${icon(Globe, 'globe', 'text-muted-foreground size-4 shrink-0')}
                      <p class="min-w-0 truncate text-xs">
                        <span class="text-muted-foreground">uipkge.dev/</span>
                        <span class="text-foreground font-medium">${this.slug}</span>
                      </p>
                    </div>
                  </div>
                `
              : nothing}
            ${this.step === 2
              ? html`
                  <div class="space-y-4">
                    <form
                      class="flex gap-2"
                      @submit=${(e: Event) => {
                        e.preventDefault()
                        this.addInvite()
                      }}
                    >
                      <uip-input
                        .value=${this.inviteDraft}
                        type="email"
                        placeholder="[email protected]"
                        status=${this.inviteInvalid ? 'error' : nothing}
                        class="flex-1"
                        @input=${(e: Event) => {
                          this.inviteDraft = (e.target as UipInput).value
                          this.inviteInvalid = false
                        }}
                      ></uip-input>
                      <uip-button type="submit" variant="outline" size="sm" class="shrink-0">
                        ${icon(Plus, 'plus')} Add
                      </uip-button>
                    </form>
                    ${this.inviteInvalid
                      ? html`<p role="alert" class="text-destructive text-xs">Enter a valid email address.</p>`
                      : nothing}
                    ${this.invites.length > 0
                      ? html`<div class="flex flex-wrap gap-2">
                          ${this.invites.map(
                            (email) => html`
                              <uip-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"
                                  @click=${() => this.removeInvite(email)}
                                >
                                  ${icon(X, 'x', 'size-3')}
                                </button>
                              </uip-badge>
                            `,
                          )}
                        </div>`
                      : html`<p class="text-muted-foreground text-xs">No teammates invited yet.</p>`}
                    <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"
                      @click=${this.goNext}
                    >
                      Skip for now
                    </button>
                  </div>
                `
              : nothing}
            ${this.step === 3
              ? html`
                  <div class="divide-y rounded-lg border">
                    ${this.preferences.map(
                      (pref) => html`
                        <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>
                          <uip-switch
                            aria-label=${pref.label}
                            .checked=${pref.enabled}
                            @checked-change=${(e: CustomEvent<boolean>) => {
                              const enabled = e.detail
                              this.preferences = this.preferences.map((p) =>
                                p.id === pref.id ? { ...p, enabled } : p,
                              )
                            }}
                          ></uip-switch>
                        </div>
                      `,
                    )}
                  </div>
                `
              : nothing}
            ${this.step === 4
              ? html`
                  <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"
                    >
                      ${icon(Check, 'check', 'size-8')}
                    </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">
                        ${this.invites.length > 0
                          ? `${this.workspaceName || 'Your workspace'} is ready and ${this.invites.length} invitation${this.invites.length === 1 ? '' : 's'} sent.`
                          : `${this.workspaceName || 'Your workspace'} is ready — invite teammates anytime from settings.`}
                      </p>
                    </div>
                    <uip-button variant="outline" size="sm" @click=${this.reset}>Set up another workspace</uip-button>
                  </div>
                `
              : nothing}
    
            <div slot="footer" class="flex w-full items-center justify-between">
              ${this.step > 1 && this.step < 4
                ? html`<uip-button
                    variant="ghost"
                    size="sm"
                    @click=${() => {
                      this.step -= 1
                    }}
                  >
                    ${icon(ChevronLeft, 'chevron-left')} Back
                  </uip-button>`
                : html`<span aria-hidden="true"></span>`}
              <span class="text-muted-foreground text-xs">Step ${this.step} of 4</span>
              ${this.step < 4
                ? html`<uip-button
                    size="sm"
                    ?disabled=${this.step === 1 && this.workspaceName.trim() === ''}
                    @click=${this.goNext}
                  >
                    ${this.step === 3 ? 'Finish' : 'Continue'} ${icon(ChevronRight, 'chevron-right')}
                  </uip-button>`
                : html`<span aria-hidden="true"></span>`}
            </div>
          </uip-section-card>
        `
      }
    }
    
    customElements.get('uip-onboarding-wizard') || customElements.define('uip-onboarding-wizard', UipOnboardingWizard)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-onboarding-wizard': UipOnboardingWizard
      }
    }
    

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