jsp-dsv0.1

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

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&rsquo;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>
  )
}