Blocks

Onboarding Blocks

Application UI

Production-shaped setup flows for converting a new signup into an activated workspace.

Activation

Workspace setup wizard

A responsive onboarding wizard with step progress, goal selection, invite collection, and a launch-ready summary. Adapt the steps to capture the few fields that unlock value in your product.

1440px

WorkspaceOnboarding.vuevue
<script setup>
import { computed, ref } from 'vue';
import { DomButton, DomCard } from '../../../lib/vue';

const step = ref(0);
const selectedGoals = ref(['Import data', 'Invite team']);
const workspaceName = ref('Northstar Product');
const selectedPlan = ref('team');
const inviteEmail = ref('');
const invites = ref(['maya@example.com', 'sam@example.com']);

const steps = [
	{ label: 'Workspace', description: 'Name the account and pick the right starting point.' },
	{ label: 'Goals', description: 'Tune the first-run experience to what the user wants done.' },
	{ label: 'Team', description: 'Invite collaborators before the workspace feels empty.' },
	{ label: 'Launch', description: 'Confirm the setup and guide the next action.' },
];

const plans = [
	{ value: 'solo', label: 'Solo', description: 'Personal workspace for one builder.' },
	{ value: 'team', label: 'Team', description: 'Shared projects, roles, and review flows.' },
	{ value: 'scale', label: 'Scale', description: 'SSO, audit history, and advanced controls.' },
];

const goals = ['Import data', 'Invite team', 'Publish a portal', 'Connect billing', 'Review analytics'];
const progress = computed(() => Math.round(((step.value + 1) / steps.length) * 100));
const isFirstStep = computed(() => step.value === 0);
const isLastStep = computed(() => step.value === steps.length - 1);
const selectedPlanLabel = computed(() => plans.find((plan) => plan.value === selectedPlan.value)?.label || 'Team');

function toggleGoal(goal) {
	selectedGoals.value = selectedGoals.value.includes(goal)
		? selectedGoals.value.filter((item) => item !== goal)
		: [...selectedGoals.value, goal];
}

function addInvite() {
	const nextInvite = inviteEmail.value.trim();
	if (!nextInvite || invites.value.includes(nextInvite)) return;
	invites.value = [...invites.value, nextInvite];
	inviteEmail.value = '';
}

function removeInvite(email) {
	invites.value = invites.value.filter((invite) => invite !== email);
}

function goBack() {
	if (!isFirstStep.value) step.value -= 1;
}

function goNext() {
	if (!isLastStep.value) step.value += 1;
}
</script>

<template>
	<div class="w-full max-w-5xl mx-auto overflow-hidden rounded-3xl border border-border bg-background text-fg shadow-2xl shadow-black/10">
		<div class="grid min-h-[38rem] lg:grid-cols-[18rem_1fr]">
			<aside class="border-b border-border skin-raised p-5 lg:border-b-0 lg:border-r">
				<div class="flex items-center gap-3">
					<span class="grid size-10 shrink-0 place-items-center rounded-2xl bg-primary text-sm font-bold text-primary-fg">NS</span>
					<div class="min-w-0">
						<p class="truncate text-sm font-semibold tracking-tight">Northstar</p>
						<p class="text-xs text-muted-fg">Workspace setup</p>
					</div>
				</div>

				<div class="mt-6">
					<div class="h-2 overflow-hidden rounded-full bg-secondary">
						<div class="h-full rounded-full bg-primary transition-all" :style="{ width: `${progress}%` }"></div>
					</div>
					<p class="mt-2 text-xs font-medium text-muted-fg">{{ progress }}% complete</p>
				</div>

				<ol class="mt-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
					<li v-for="(item, index) in steps" :key="item.label">
						<button
							type="button"
							class="flex w-full items-start gap-3 rounded-2xl px-3 py-3 text-left transition hover:bg-secondary"
							:class="index === step ? 'bg-secondary text-secondary-fg' : 'text-muted-fg'"
							@click="step = index"
						>
							<span
								class="grid size-7 shrink-0 place-items-center rounded-full border text-xs font-semibold"
								:class="index <= step ? 'border-primary bg-primary text-primary-fg' : 'border-border bg-background text-muted-fg'"
							>
								{{ index + 1 }}
							</span>
							<span class="min-w-0">
								<span class="block text-sm font-semibold">{{ item.label }}</span>
								<span class="mt-0.5 hidden text-xs leading-5 text-muted-fg lg:block">{{ item.description }}</span>
							</span>
						</button>
					</li>
				</ol>
			</aside>

			<section class="flex min-w-0 flex-col">
				<header class="border-b border-border px-5 py-4 sm:px-8">
					<p class="text-xs font-semibold uppercase tracking-[0.16em] text-muted-fg">Onboarding</p>
					<h3 class="mt-1 text-2xl font-semibold tracking-tight">{{ steps[step].label }}</h3>
					<p class="mt-2 max-w-2xl text-sm leading-6 text-muted-fg">{{ steps[step].description }}</p>
				</header>

				<div class="flex-1 p-5 sm:p-8">
					<div v-if="step === 0" class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
						<div class="space-y-5">
							<label class="block">
								<span class="text-sm font-medium">Workspace name</span>
								<input
									v-model="workspaceName"
									class="mt-2 block w-full rounded-xl border border-input bg-background px-3 py-2.5 text-sm outline-none transition placeholder:text-muted-fg focus:border-ring focus:ring-2 focus:ring-ring/20"
									placeholder="Acme customer portal"
								/>
							</label>

							<div>
								<p class="text-sm font-medium">Starting plan</p>
								<div class="mt-2 grid gap-3">
									<button
										v-for="plan in plans"
										:key="plan.value"
										type="button"
										class="rounded-2xl border p-4 text-left transition hover:border-ring"
										:class="selectedPlan === plan.value ? 'border-primary bg-primary/10' : 'border-border bg-background'"
										@click="selectedPlan = plan.value"
									>
										<span class="flex items-center justify-between gap-3">
											<span class="font-semibold">{{ plan.label }}</span>
											<span v-if="selectedPlan === plan.value" class="rounded-full bg-primary px-2 py-0.5 text-xs font-medium text-primary-fg">Selected</span>
										</span>
										<span class="mt-1 block text-sm leading-6 text-muted-fg">{{ plan.description }}</span>
									</button>
								</div>
							</div>
						</div>

						<DomCard class="self-start">
							<p class="text-sm font-semibold">Why this step matters</p>
							<p class="mt-2 text-sm leading-6 text-muted-fg">Keep setup short, but capture the account shape early. It helps route users into the right empty states, default permissions, and upgrade path.</p>
						</DomCard>
					</div>

					<div v-else-if="step === 1" class="space-y-4">
						<div class="grid gap-3 sm:grid-cols-2">
							<button
								v-for="goal in goals"
								:key="goal"
								type="button"
								class="flex items-center justify-between gap-3 rounded-2xl border p-4 text-left transition hover:border-ring"
								:class="selectedGoals.includes(goal) ? 'border-primary bg-primary/10' : 'border-border bg-background'"
								@click="toggleGoal(goal)"
							>
								<span class="font-medium">{{ goal }}</span>
								<span
									class="grid size-6 shrink-0 place-items-center rounded-full border text-xs"
									:class="selectedGoals.includes(goal) ? 'border-primary bg-primary text-primary-fg' : 'border-border text-muted-fg'"
								>
									<svg v-if="selectedGoals.includes(goal)" class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
										<path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
									</svg>
								</span>
							</button>
						</div>
						<p class="text-sm leading-6 text-muted-fg">Use these selections to personalize the dashboard, checklist, and first email sequence after signup.</p>
					</div>

					<div v-else-if="step === 2" class="space-y-5">
						<form class="flex flex-col gap-3 sm:flex-row" @submit.prevent="addInvite">
							<label class="min-w-0 flex-1">
								<span class="sr-only">Invite email</span>
								<input
									v-model="inviteEmail"
									type="email"
									class="block w-full rounded-xl border border-input bg-background px-3 py-2.5 text-sm outline-none transition placeholder:text-muted-fg focus:border-ring focus:ring-2 focus:ring-ring/20"
									placeholder="teammate@example.com"
								/>
							</label>
							<DomButton type="submit">Add invite</DomButton>
						</form>

						<div class="overflow-hidden rounded-2xl border border-border">
							<div v-for="invite in invites" :key="invite" class="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0">
								<div class="min-w-0">
									<p class="truncate text-sm font-medium">{{ invite }}</p>
									<p class="text-xs text-muted-fg">Editor access</p>
								</div>
								<button type="button" class="rounded-lg px-2 py-1 text-sm text-muted-fg hover:bg-secondary hover:text-fg" @click="removeInvite(invite)">Remove</button>
							</div>
						</div>
					</div>

					<div v-else class="grid gap-5 lg:grid-cols-3">
						<DomCard class="lg:col-span-2">
							<p class="text-sm font-semibold">Ready to launch</p>
							<dl class="mt-4 grid gap-4 sm:grid-cols-3">
								<div>
									<dt class="text-xs font-medium uppercase tracking-[0.14em] text-muted-fg">Workspace</dt>
									<dd class="mt-1 text-sm font-semibold">{{ workspaceName || 'Untitled workspace' }}</dd>
								</div>
								<div>
									<dt class="text-xs font-medium uppercase tracking-[0.14em] text-muted-fg">Plan</dt>
									<dd class="mt-1 text-sm font-semibold">{{ selectedPlanLabel }}</dd>
								</div>
								<div>
									<dt class="text-xs font-medium uppercase tracking-[0.14em] text-muted-fg">Invites</dt>
									<dd class="mt-1 text-sm font-semibold">{{ invites.length }}</dd>
								</div>
							</dl>
							<div class="mt-5 flex flex-wrap gap-2">
								<span v-for="goal in selectedGoals" :key="goal" class="rounded-full bg-secondary px-3 py-1 text-xs font-medium text-secondary-fg">{{ goal }}</span>
							</div>
						</DomCard>

						<DomCard>
							<p class="text-sm font-semibold">Next best action</p>
							<p class="mt-2 text-sm leading-6 text-muted-fg">Send the user straight to the first task that matches their goals, not a blank dashboard.</p>
							<DomButton class="mt-4 w-full">Open workspace</DomButton>
						</DomCard>
					</div>
				</div>

				<footer class="flex flex-col-reverse gap-3 border-t border-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-8">
					<DomButton variant="secondary" :disabled="isFirstStep" @click="goBack">Back</DomButton>
					<div class="flex items-center justify-between gap-3 sm:justify-end">
						<p class="text-sm text-muted-fg">Step {{ step + 1 }} of {{ steps.length }}</p>
						<DomButton v-if="!isLastStep" @click="goNext">Continue</DomButton>
						<DomButton v-else>Finish setup</DomButton>
					</div>
				</footer>
			</section>
		</div>
	</div>
</template>