Onboardingpage
Three short steps from sign-up to a ready workspace.
Pages- Preview
- /preview/onboarding
- Guidance
- draft · v0.1.0
Preview
The real page, full screen, in a frame. Switch width here and mode at the foot of the side nav.
Guidance
Rules
- Three short steps at most before people reach the product, each asking one group of questions.
- Show progress: step names and Step 2 of 4. The current step has
aria-current="step". - Move focus to each step's heading, so screen reader users hear where they are.
- Back keeps what was entered. Optional steps offer Skip for now, and say it can be done later.
- The last step says what's ready and gives the one next action.
- Don't ask for anything the product doesn't use yet.
Built from
Code
Copy it into a product and change the words; the layout and tokens carry over.
app/preview/onboarding/onboarding.tsx
"use client"
import * as React from "react"
import { Check } from "lucide-react"
import { cn } from "cn"
import { Text } from "@/registry/jsp/text"
import { Button } from "@/registry/ui/button"
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/registry/ui/field"
import { Input } from "@/registry/ui/input"
import { RadioGroup, RadioGroupItem } from "@/registry/ui/radio-group"
import { Textarea } from "@/registry/ui/textarea"
const STEPS = ["About you", "Workspace", "Invite", "Done"] as const
/**
* Onboarding: three short steps and a finish, one question group per step. Progress is a list with
* aria-current="step"; Back never loses answers; Skip is offered where a step is optional. Focus moves to each
* step's heading so screen readers hear where they are.
*/
export function Onboarding() {
const [step, setStep] = React.useState(0)
const heading = React.useRef<HTMLHeadingElement>(null)
const go = (next: number) => {
setStep(next)
requestAnimationFrame(() => heading.current?.focus())
}
const last = step === STEPS.length - 1
return (
<div className="flex min-h-dvh flex-col bg-surface-subtle">
<header className="flex items-center justify-between gap-component-md border-b border-border-subtle bg-surface-page px-component-xl py-component-md">
<Text as="span" textStyle="label">
Review Hub
</Text>
<nav aria-label="Setup progress">
<ol className="flex items-center gap-component-xs">
{STEPS.map((label, i) => (
<li key={label} aria-current={i === step ? "step" : undefined} className="flex items-center gap-component-xs">
<span
aria-hidden
className={cn(
"flex size-control-xs items-center justify-center rounded-full border",
i < step && "border-border-strong bg-surface-inverse text-text-inverse",
i === step && "border-border-strong text-text-default",
i > step && "border-border-subtle text-text-subtle",
)}
>
{i < step ? <Check className="size-icon-xs" /> : <Text as="span" textStyle="caption">{i + 1}</Text>}
</span>
<Text as="span" textStyle="body-sm" color={i === step ? "default" : "subtle"} className="hidden md:inline">
{label}
</Text>
<span className="sr-only">{i < step ? "(done)" : i === step ? "(current step)" : ""}</span>
</li>
))}
</ol>
</nav>
</header>
<main className="flex flex-1 items-start justify-center p-component-xl md:pt-layout-xl">
<div className="flex w-full max-w-site-form flex-col gap-layout-sm rounded-container border border-border-subtle bg-surface-page p-component-xl shadow-raised md:p-layout-md">
<div className="flex flex-col gap-component-xs">
<Text as="p" textStyle="caption" color="subtle">
Step {step + 1} of {STEPS.length}
</Text>
<Text asChild textStyle="heading-3">
<h1 ref={heading} tabIndex={-1} className="outline-none">
{["Welcome – tell us about you", "Name your workspace", "Invite your team", "You're all set"][step]}
</h1>
</Text>
</div>
{step === 0 ? (
<FieldGroup>
<Field>
<FieldLabel htmlFor="ob-name">Your name</FieldLabel>
<Input id="ob-name" autoComplete="name" />
</Field>
<FieldSet>
<FieldLegend variant="label">What will you use Review Hub for?</FieldLegend>
<RadioGroup defaultValue="client">
{[
["client", "Getting sign-off from clients"],
["team", "Reviewing work inside my team"],
["both", "Both"],
].map(([value, label]) => (
<Field key={value} orientation="horizontal">
<RadioGroupItem value={value} id={`ob-use-${value}`} />
<FieldLabel htmlFor={`ob-use-${value}`}>{label}</FieldLabel>
</Field>
))}
</RadioGroup>
</FieldSet>
</FieldGroup>
) : null}
{step === 1 ? (
<FieldGroup>
<Field>
<FieldLabel htmlFor="ob-workspace">Workspace name</FieldLabel>
<Input id="ob-workspace" defaultValue="JSP Studio" />
<FieldDescription>Usually your company or studio. You can change it later.</FieldDescription>
</Field>
</FieldGroup>
) : null}
{step === 2 ? (
<FieldGroup>
<Field>
<FieldLabel htmlFor="ob-invite">Email addresses</FieldLabel>
<Textarea id="ob-invite" placeholder="One per line" />
<FieldDescription>They’ll get an email with a link to join. You can do this later.</FieldDescription>
</Field>
</FieldGroup>
) : null}
{last ? <Text color="subtle">Your workspace is ready. Create a project to upload your first screens.</Text> : null}
<div className="flex flex-wrap items-center gap-component-xs">
{step > 0 && !last ? (
<Button variant="ghost" onClick={() => go(step - 1)}>
Back
</Button>
) : null}
<span className="flex-1" />
{step === 2 ? (
<Button variant="ghost" onClick={() => go(step + 1)}>
Skip for now
</Button>
) : null}
{last ? (
<Button onClick={() => go(0)}>Create a project</Button>
) : (
<Button onClick={() => go(step + 1)}>{step === 2 ? "Send invites" : "Continue"}</Button>
)}
</div>
</div>
</main>
</div>
)
}