Onboarding
5 blocksOnboarding 01
Preview
npx nateui@latest add OnboardingSetupWizardDark
import { useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import Container from '@/components/composites/Container'
import IconFrame from '@/components/composites/IconFrame'
import Logo from '@/components/layouts/Logo'
import classNames from '@/utils/classNames'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Progress from '@/components/ui/Progress'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
import Upload from '@/components/ui/Upload'
import {
PiArrowLeft,
PiArrowRight,
PiBell,
PiCalendarBlank,
PiChartBar,
PiChartLineUp,
PiChatCircleText,
PiCheck,
PiCheckBold,
PiCheckCircleFill,
PiCheckSquare,
PiFileText,
PiKanban,
PiLightbulb,
PiMapTrifold,
PiNote,
PiRows,
PiSquaresFour,
PiUploadSimple,
PiUser,
PiUsersThree,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const logoSrc = `${assetBase}/logos/logo.svg`
const logoWhiteSrc = `${assetBase}/logos/logo-white.svg`
const totalSteps = 5
const stepDetails = [
{
title: 'Add profile details',
subtitle: 'Choose the details that will identify you in this workspace.',
},
{
title: 'Choose your work focus',
subtitle: 'Select the activity that best matches your starting view.',
},
{
title: 'Tune your starting topics',
subtitle: 'Optional: choose subjects to show in your first workspace view.',
},
{
title: 'Name your workspace',
subtitle: 'Choose a name and URL for the shared space.',
},
{
title: 'Pick a workspace view',
subtitle: 'Choose the layout you want to open first.',
},
]
const timezoneOptions = [
{ label: 'UTC-08:00 Pacific', value: 'pacific' },
{ label: 'UTC-05:00 Eastern', value: 'eastern' },
{ label: 'UTC+01:00 Central European', value: 'central-european' },
{ label: 'UTC+08:00 Singapore', value: 'singapore' },
]
const workFocusOptions = [
{ label: 'Planning', value: 'planning' },
{ label: 'Building', value: 'building' },
{ label: 'Designing', value: 'designing' },
{ label: 'Research', value: 'research' },
{ label: 'Operations', value: 'operations' },
{ label: 'Support', value: 'support' },
]
const interestOptions = [
{ label: 'Projects', value: 'projects', icon: PiKanban },
{ label: 'Notes', value: 'notes', icon: PiNote },
{ label: 'Tasks', value: 'tasks', icon: PiCheckSquare },
{ label: 'Files', value: 'files', icon: PiFileText },
{ label: 'Calendars', value: 'calendars', icon: PiCalendarBlank },
{ label: 'People', value: 'people', icon: PiUsersThree },
{ label: 'Reports', value: 'reports', icon: PiChartBar },
{ label: 'Roadmaps', value: 'roadmaps', icon: PiMapTrifold },
{ label: 'Ideas', value: 'ideas', icon: PiLightbulb },
{ label: 'Reviews', value: 'reviews', icon: PiChatCircleText },
{ label: 'Updates', value: 'updates', icon: PiBell },
{ label: 'Templates', value: 'templates', icon: PiSquaresFour },
{ label: 'Metrics', value: 'metrics', icon: PiChartLineUp },
]
const teamSizeOptions = [
'Solo',
'2-8',
'9-24',
'25-75',
'76-250',
'251-750',
'751-2,500',
'2,500+',
]
const workspaceViewOptions = [
{
label: 'Overview',
value: 'overview',
description: 'Recent activity and open work in one summary.',
icon: PiSquaresFour,
},
{
label: 'Board',
value: 'board',
description: 'A column for each stage, moved by drag.',
icon: PiKanban,
},
{
label: 'Timeline',
value: 'timeline',
description: 'Work plotted across dates and owners.',
icon: PiChartLineUp,
},
{
label: 'Table',
value: 'table',
description: 'Rows and columns with filters and sorting.',
icon: PiRows,
},
]
export default function OnboardingSetupWizard() {
const [currentStep, setCurrentStep] = useState(0)
const [displayName, setDisplayName] = useState('Jordan Lee')
const [roleLabel, setRoleLabel] = useState('Operations lead')
const [timezone, setTimezone] = useState('singapore')
const [updatesEnabled, setUpdatesEnabled] = useState(true)
const [workFocus, setWorkFocus] = useState('building')
const [interests, setInterests] = useState(['projects', 'notes', 'tasks'])
const [workspaceName, setWorkspaceName] = useState('Northstar')
const [workspaceSlug, setWorkspaceSlug] = useState('northstar')
const [teamSize, setTeamSize] = useState('9-24')
const [workspaceView, setWorkspaceView] = useState('overview')
const isComplete = currentStep === totalSteps
const activeStep = stepDetails[currentStep]
const progressPercent = isComplete
? 100
: ((currentStep + 1) / totalSteps) * 100
const handleNext = () => {
setCurrentStep((step) => Math.min(step + 1, totalSteps))
}
const handlePrevious = () => {
setCurrentStep((step) => Math.max(step - 1, 0))
}
const handleSkip = () => {
handleNext()
}
const handleKeyboardAction = (
event: ReactKeyboardEvent<HTMLElement>,
action: () => void,
) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
action()
}
}
const toggleInterest = (value: string) => {
setInterests((selected) =>
selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value],
)
}
const handleViewKeyDown = (
event: ReactKeyboardEvent<HTMLElement>,
index: number,
) => {
const { key } = event
if (key === 'Enter' || key === ' ') {
event.preventDefault()
setWorkspaceView(workspaceViewOptions[index].value)
return
}
const forward = key === 'ArrowRight' || key === 'ArrowDown'
const backward = key === 'ArrowLeft' || key === 'ArrowUp'
if (!forward && !backward) {
return
}
event.preventDefault()
const optionCount = workspaceViewOptions.length
const nextIndex = forward
? (index + 1) % optionCount
: (index - 1 + optionCount) % optionCount
setWorkspaceView(workspaceViewOptions[nextIndex].value)
const nextCard = event.currentTarget.parentElement?.children[nextIndex]
if (nextCard instanceof HTMLElement) {
nextCard.focus()
}
}
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<div className="mt-8">
<Upload
accept="image/png,image/jpeg"
className="w-full"
showList={false}
uploadLimit={1}
>
<div className="mb-4 flex items-center gap-4">
<Avatar
aria-hidden="true"
icon={<PiUser />}
size="lg"
/>
<div className="flex min-w-0 flex-col items-start gap-2">
<Button
icon={<PiUploadSimple aria-hidden="true" />}
size="sm"
type="button"
>
Add image
</Button>
</div>
</div>
</Upload>
<Form
className="mt-4"
onSubmit={(event) => event.preventDefault()}
>
<Form.Field
asterisk
htmlFor="onboarding-display-name"
label="Display name"
>
<Input
id="onboarding-display-name"
value={displayName}
onChange={(event) =>
setDisplayName(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor="onboarding-role-label"
label="Role label"
>
<Input
id="onboarding-role-label"
value={roleLabel}
onChange={(event) =>
setRoleLabel(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor="onboarding-timezone"
label="Time zone"
>
<Select
inputId="onboarding-timezone"
options={timezoneOptions}
placeholder="Choose a time zone"
value={timezoneOptions.find(
(option) => option.value === timezone,
)}
onChange={(option) =>
setTimezone(option.value as string)
}
/>
</Form.Field>
<Checkbox
checked={updatesEnabled}
onChange={(checked) =>
setUpdatesEnabled(checked)
}
>
Account notices by email
</Checkbox>
</Form>
</div>
)
case 1:
return (
<div className="mt-8 flex flex-col gap-4">
<p className="font-medium text-foreground">Pick one activity</p>
<Radio.Group
className="w-full gap-2"
name="onboarding-work-focus"
value={workFocus}
vertical
onChange={(value) => setWorkFocus(value as string)}
>
{workFocusOptions.map((option) => (
<div
className={classNames(
'w-full',
workFocus !== option.value && 'hover:bg-accent',
)}
key={option.value}
>
<Radio
className="w-full rounded-control border px-4 py-2"
value={option.value}
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
setWorkFocus(option.value),
)
}
>
{option.label}
</Radio>
</div>
))}
</Radio.Group>
</div>
)
case 2:
return (
<div className="mt-8 flex flex-wrap gap-2">
{interestOptions.map((interest) => {
const selected = interests.includes(interest.value)
return (
<div
className="relative inline-flex"
key={interest.value}
>
<Button
aria-pressed={selected}
icon={selected ? <PiCheckCircleFill className="text-xl text-primary" aria-hidden="true" /> : undefined}
type="button"
iconAlignment="end"
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
toggleInterest(interest.value),
)
}
className={classNames(
selected && 'bg-secondary hover:bg-secondary',
)}
onClick={() => toggleInterest(interest.value)}
>
{interest.label}
</Button>
</div>
)
})}
</div>
)
case 3:
return (
<div className="mt-8">
<Form
onSubmit={(event) => event.preventDefault()}
>
<Form.Field
asterisk
htmlFor="onboarding-workspace-name"
label="Workspace name"
>
<Input
id="onboarding-workspace-name"
value={workspaceName}
onChange={(event) =>
setWorkspaceName(event.target.value)
}
/>
</Form.Field>
<Form.Field
asterisk
htmlFor="onboarding-workspace-slug"
label="Workspace slug"
>
<Input
id="onboarding-workspace-slug"
value={workspaceSlug}
onChange={(event) =>
setWorkspaceSlug(event.target.value)
}
/>
</Form.Field>
</Form>
<div className="mt-8">
<p className="mb-2 font-medium text-foreground">
Starting team size
</p>
<div className="flex flex-wrap gap-2">
{teamSizeOptions.map((option) => {
const selected = teamSize === option
return (
<div
className="relative inline-flex"
key={option}
>
<Button
aria-pressed={selected}
type="button"
variant={selected ? 'subtle' : 'default'}
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
setTeamSize(option),
)
}
className={classNames(
selected && 'bg-secondary hover:bg-secondary',
)}
icon={selected ? <PiCheckCircleFill className="text-lg text-primary" aria-hidden="true" /> : undefined}
iconAlignment="end"
onClick={() => setTeamSize(option)}
>
{option}
</Button>
</div>
)
})}
</div>
</div>
</div>
)
case 4:
return (
<div className="mt-8">
<p className="mb-2 font-medium text-foreground">Starting view</p>
<div
aria-label="Workspace starting view"
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
role="radiogroup"
>
{workspaceViewOptions.map((option, index) => {
const selected = workspaceView === option.value
const ViewIcon = option.icon
return (
<Card
aria-checked={selected}
className={classNames(
'relative h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected
? 'border-primary ring'
: 'hover:border-primary',
)}
key={option.value}
role="radio"
tabIndex={selected ? 0 : -1}
bodyClass="flex h-full flex-col gap-8 p-4"
onClick={() => setWorkspaceView(option.value)}
onKeyDown={(event) =>
handleViewKeyDown(event, index)
}
>
<IconFrame size={40} variant="muted">
<ViewIcon
aria-hidden="true"
className={classNames(
'text-xl',
selected && 'text-primary',
)}
/>
</IconFrame>
<div className="space-y-1">
<p
className={classNames(
'font-semibold',
selected
? 'text-primary'
: 'text-card-foreground',
)}
>
{option.label}
</p>
<p className="pe-8 text-muted-foreground">
{option.description}
</p>
</div>
{selected ? (
<span
aria-hidden="true"
className="absolute bottom-4 end-4 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<PiCheck className="text-sm" />
</span>
) : null}
</Card>
)
})}
</div>
</div>
)
default:
return null
}
}
return (
<div className="flex h-screen flex-col overflow-hidden">
<header className="flex min-h-15 items-center gap-4 border-b px-4">
<div className="flex min-w-0 flex-1 items-center">
<Logo
alt="NateUI"
imgProps={{ className: 'h-7 dark:hidden' }}
src={logoSrc}
/>
<Logo
alt="NateUI"
imgProps={{ className: 'hidden h-7 dark:block' }}
src={logoWhiteSrc}
/>
</div>
<div className="flex min-w-0 flex-1 justify-center">
<div
aria-label={
isComplete
? 'Onboarding complete'
: `Step ${currentStep + 1} of ${totalSteps}`
}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progressPercent}
className="hidden w-full max-w-xs sm:block"
role="progressbar"
>
<Progress
percent={progressPercent}
showInfo={false}
size="sm"
/>
</div>
</div>
<div className="flex min-w-0 flex-1 justify-end">
{isComplete ? (
<span className="flex items-center gap-2 whitespace-nowrap">
<PiCheckCircleFill aria-hidden="true" className="text-base text-success" />
Setup complete
</span>
) : (
<span className="whitespace-nowrap">
{currentStep + 1} / {totalSteps}
</span>
)}
</div>
</header>
{isComplete ? (
<main className="flex flex-1 min-h-0 flex-col">
<Container
asElement="div"
className="flex w-full max-w-xl flex-1 flex-col items-center justify-center px-4 text-center sm:px-8"
size="lg"
>
<div className="rounded-full bg-palette-emerald-soft p-4">
<IconFrame
className="rounded-full bg-palette-emerald text-success/30 drop-shadow-[0_4px_6px_currentColor]"
size={64}
variant="muted"
>
<PiCheckBold
aria-hidden="true"
className="text-3xl text-primary-foreground"
/>
</IconFrame>
</div>
<h1 className="mt-8 text-2xl font-semibold text-foreground">
Ready to go!
</h1>
<p className="mt-1 text-muted-foreground">
The workspace can now be opened for the first task.
</p>
<div className="flex w-full max-w-sm flex-col items-center gap-4 mt-8">
<Button block type="button" variant="solid">
Open workspace
</Button>
<Button
type="button"
variant="ghost"
block
onClick={() => setCurrentStep(totalSteps - 1)}
>
Review selections
</Button>
</div>
</Container>
</main>
) : (
<>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<Container
asElement="div"
className="flex min-h-0 w-full max-w-xl flex-1 flex-col justify-center px-4 py-8 sm:px-8"
size="lg"
>
<div aria-live="polite">
<h4
className="text-xl font-semibold text-foreground"
>
{activeStep.title}
</h4>
<p className="mt-1 text-muted-foreground">
{activeStep.subtitle}
</p>
</div>
{renderStepContent()}
</Container>
</main>
<footer className="border-t py-2 min-h-15 flex items-center">
<Container
asElement="div"
className="flex w-full max-w-xl flex-wrap items-center justify-between gap-4 px-4 sm:px-8"
size="lg"
>
{currentStep === 0 ? (
<span aria-hidden="true" />
) : (
<Button
icon={<PiArrowLeft aria-hidden="true" />}
iconAlignment="start"
type="button"
variant="ghost"
onKeyDown={(event) =>
handleKeyboardAction(event, handlePrevious)
}
onClick={handlePrevious}
>
Previous
</Button>
)}
<div className="flex flex-wrap items-center gap-2">
{currentStep > 0 && (
<Button
type="button"
variant="ghost"
onKeyDown={(event) =>
handleKeyboardAction(event, handleSkip)
}
onClick={handleSkip}
>
Skip
</Button>
)}
<Button
icon={<PiArrowRight aria-hidden="true" />}
iconAlignment="end"
type="button"
variant="solid"
onKeyDown={(event) =>
handleKeyboardAction(event, handleNext)
}
onClick={handleNext}
>
Next step
</Button>
</div>
</Container>
</footer>
</>
)}
</div>
)
}
Onboarding 02
Preview
npx nateui@latest add OnboardingSetupAppShellDark
import { useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import AppShellSeamlessSide from '@/components/layouts/AppShellSeamlessSide'
import Header from '@/components/layouts/Header'
import Logo from '@/components/layouts/Logo'
import PageContainer from '@/components/layouts/PageContainer'
import Container from '@/components/composites/Container'
import IconFrame from '@/components/composites/IconFrame'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
import Steps from '@/components/ui/Steps'
import Upload from '@/components/ui/Upload'
import classNames from '@/utils/classNames'
import {
PiArrowLeft,
PiArrowRight,
PiBell,
PiCalendarBlank,
PiChartBar,
PiChartLineUp,
PiCheck,
PiCheckBold,
PiCheckCircleFill,
PiCheckSquare,
PiFileText,
PiKanban,
PiMapTrifold,
PiNote,
PiRows,
PiSquaresFour,
PiUploadSimple,
PiUser,
PiUsersThree,
PiQuestion
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const logoSrc = `${assetBase}/logos/logo.svg`
const logoWhiteSrc = `${assetBase}/logos/logo-white.svg`
const totalSteps = 5
const stepDetails = [
{
title: 'Add profile details',
subtitle: 'Choose the details that will identify you in this workspace.',
},
{
title: 'Choose your work focus',
subtitle: 'Select the activity that best matches your starting view.',
},
{
title: 'Tune your starting topics',
subtitle: 'Optional: choose subjects to show in your first workspace view.',
},
{
title: 'Name your workspace',
subtitle: 'Choose a name and URL for the shared space.',
},
{
title: 'Pick a workspace view',
subtitle: 'Choose the layout you want to open first.',
},
]
const sidebarStepDetails = [
{ title: 'Account', description: 'Name and time zone.' },
{ title: 'Work focus', description: 'Choose a starting activity.' },
{ title: 'Topics', description: 'Optional workspace subjects.' },
{ title: 'Workspace', description: 'Name and team size.' },
{ title: 'Starting view', description: 'Pick an opening layout.' },
]
const timezoneOptions = [
{ label: 'UTC-08:00 Pacific', value: 'pacific' },
{ label: 'UTC-05:00 Eastern', value: 'eastern' },
{ label: 'UTC+01:00 Central European', value: 'central-european' },
{ label: 'UTC+08:00 Singapore', value: 'singapore' },
]
const workFocusOptions = [
{ label: 'Planning', value: 'planning' },
{ label: 'Building', value: 'building' },
{ label: 'Designing', value: 'designing' },
{ label: 'Research', value: 'research' },
{ label: 'Operations', value: 'operations' },
{ label: 'Support', value: 'support' },
]
const interestOptions = [
{ label: 'Projects', value: 'projects', icon: PiKanban },
{ label: 'Notes', value: 'notes', icon: PiNote },
{ label: 'Tasks', value: 'tasks', icon: PiCheckSquare },
{ label: 'Files', value: 'files', icon: PiFileText },
{ label: 'Calendars', value: 'calendars', icon: PiCalendarBlank },
{ label: 'People', value: 'people', icon: PiUsersThree },
{ label: 'Reports', value: 'reports', icon: PiChartBar },
{ label: 'Roadmaps', value: 'roadmaps', icon: PiMapTrifold },
{ label: 'Ideas', value: 'ideas', icon: PiSquaresFour },
{ label: 'Reviews', value: 'reviews', icon: PiNote },
{ label: 'Updates', value: 'updates', icon: PiBell },
{ label: 'Templates', value: 'templates', icon: PiSquaresFour },
{ label: 'Metrics', value: 'metrics', icon: PiChartLineUp },
]
const teamSizeOptions = [
'Solo',
'2-8',
'9-24',
'25-75',
'76-250',
'251-750',
'751-2,500',
'2,500+',
]
const workspaceViewOptions = [
{
label: 'Overview',
value: 'overview',
description: 'Recent activity and open work in one summary.',
icon: PiSquaresFour,
},
{
label: 'Board',
value: 'board',
description: 'A column for each stage, moved by drag.',
icon: PiKanban,
},
{
label: 'Timeline',
value: 'timeline',
description: 'Work plotted across dates and owners.',
icon: PiChartLineUp,
},
{
label: 'Table',
value: 'table',
description: 'Rows and columns with filters and sorting.',
icon: PiRows,
},
]
export default function OnboardingSetupAppShell() {
const [currentStep, setCurrentStep] = useState(0)
const [displayName, setDisplayName] = useState('Jordan Lee')
const [roleLabel, setRoleLabel] = useState('Operations lead')
const [timezone, setTimezone] = useState('singapore')
const [updatesEnabled, setUpdatesEnabled] = useState(true)
const [workFocus, setWorkFocus] = useState('building')
const [interests, setInterests] = useState(['projects', 'notes', 'tasks'])
const [workspaceName, setWorkspaceName] = useState('Northstar')
const [workspaceSlug, setWorkspaceSlug] = useState('northstar')
const [teamSize, setTeamSize] = useState('9-24')
const [workspaceView, setWorkspaceView] = useState('overview')
const isComplete = currentStep === totalSteps
const activeStep = stepDetails[currentStep]
const progressPercent = isComplete
? 100
: ((currentStep + 1) / totalSteps) * 100
const handleNext = () => {
setCurrentStep((step) => Math.min(step + 1, totalSteps))
}
const handlePrevious = () => {
setCurrentStep((step) => Math.max(step - 1, 0))
}
const handleSkip = () => {
handleNext()
}
const handleTimelineStepChange = (step: number) => {
if (step < currentStep) {
setCurrentStep(step)
}
}
const handleKeyboardAction = (
event: ReactKeyboardEvent<HTMLElement>,
action: () => void,
) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
action()
}
}
const toggleInterest = (value: string) => {
setInterests((selected) =>
selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value],
)
}
const handleViewKeyDown = (
event: ReactKeyboardEvent<HTMLElement>,
index: number,
) => {
const { key } = event
if (key === 'Enter' || key === ' ') {
event.preventDefault()
setWorkspaceView(workspaceViewOptions[index].value)
return
}
const forward = key === 'ArrowRight' || key === 'ArrowDown'
const backward = key === 'ArrowLeft' || key === 'ArrowUp'
if (!forward && !backward) {
return
}
event.preventDefault()
const optionCount = workspaceViewOptions.length
const nextIndex = forward
? (index + 1) % optionCount
: (index - 1 + optionCount) % optionCount
setWorkspaceView(workspaceViewOptions[nextIndex].value)
const nextCard = event.currentTarget.parentElement?.children[nextIndex]
if (nextCard instanceof HTMLElement) {
nextCard.focus()
}
}
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<div className="mt-8">
<Upload
accept="image/png,image/jpeg"
aria-label="Upload profile image"
className="w-full"
showList={false}
uploadLimit={1}
>
<div className="flex items-center gap-4">
<Avatar
aria-hidden="true"
icon={<PiUser />}
size="lg"
/>
<div className="flex min-w-0 flex-col items-start gap-2">
<Button
icon={<PiUploadSimple aria-hidden="true" />}
size="sm"
type="button"
>
Add image
</Button>
</div>
</div>
</Upload>
<Form
className="mt-4"
onSubmit={(event) => event.preventDefault()}
>
<Form.Field
asterisk
htmlFor="onboarding-app-shell-display-name"
label="Display name"
>
<Input
id="onboarding-app-shell-display-name"
value={displayName}
onChange={(event) =>
setDisplayName(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor="onboarding-app-shell-role-label"
label="Role label"
>
<Input
id="onboarding-app-shell-role-label"
value={roleLabel}
onChange={(event) =>
setRoleLabel(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor="onboarding-app-shell-timezone"
label="Time zone"
>
<Select
inputId="onboarding-app-shell-timezone"
options={timezoneOptions}
placeholder="Choose a time zone"
value={timezoneOptions.find(
(option) => option.value === timezone,
)}
onChange={(option) =>
setTimezone(option.value as string)
}
/>
</Form.Field>
<Checkbox
checked={updatesEnabled}
onChange={(checked) =>
setUpdatesEnabled(checked)
}
>
Account notices by email
</Checkbox>
</Form>
</div>
)
case 1:
return (
<div className="mt-8 flex flex-col gap-4">
<p className="font-medium text-foreground">Pick one activity</p>
<Radio.Group
className="w-full gap-2"
name="onboarding-app-shell-work-focus"
value={workFocus}
vertical
onChange={(value) => setWorkFocus(value as string)}
>
{workFocusOptions.map((option) => (
<div
className={classNames(
'w-full',
workFocus !== option.value && 'hover:bg-accent',
)}
key={option.value}
>
<Radio
className="w-full rounded-control border px-4 py-2"
value={option.value}
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
setWorkFocus(option.value),
)
}
>
{option.label}
</Radio>
</div>
))}
</Radio.Group>
</div>
)
case 2:
return (
<div className="mt-8 flex flex-wrap gap-2">
{interestOptions.map((interest) => {
const selected = interests.includes(interest.value)
return (
<div
className="relative inline-flex"
key={interest.value}
>
<Button
aria-pressed={selected}
icon={selected ? <PiCheckCircleFill className="text-xl text-primary" aria-hidden="true" /> : undefined}
type="button"
iconAlignment="end"
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
toggleInterest(interest.value),
)
}
className={classNames(
selected && 'bg-secondary hover:bg-secondary',
)}
onClick={() => toggleInterest(interest.value)}
>
{interest.label}
</Button>
</div>
)
})}
</div>
)
case 3:
return (
<div className="mt-8">
<Form
onSubmit={(event) => event.preventDefault()}
>
<Form.Field
asterisk
htmlFor="onboarding-app-shell-workspace-name"
label="Workspace name"
>
<Input
id="onboarding-app-shell-workspace-name"
value={workspaceName}
onChange={(event) =>
setWorkspaceName(event.target.value)
}
/>
</Form.Field>
<Form.Field
asterisk
htmlFor="onboarding-app-shell-workspace-slug"
label="Workspace slug"
>
<Input
id="onboarding-app-shell-workspace-slug"
value={workspaceSlug}
onChange={(event) =>
setWorkspaceSlug(event.target.value)
}
/>
</Form.Field>
</Form>
<div className="mt-8">
<p className="mb-2 font-medium text-foreground">
Starting team size
</p>
<div className="flex flex-wrap gap-2">
{teamSizeOptions.map((option) => {
const selected = teamSize === option
return (
<div
className="relative inline-flex"
key={option}
>
<Button
aria-pressed={selected}
type="button"
variant={selected ? 'subtle' : 'default'}
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
setTeamSize(option),
)
}
className={classNames(
selected && 'bg-secondary hover:bg-secondary',
)}
icon={selected ? <PiCheckCircleFill className="text-lg text-primary" aria-hidden="true" /> : undefined}
iconAlignment="end"
onClick={() => setTeamSize(option)}
>
{option}
</Button>
</div>
)
})}
</div>
</div>
</div>
)
case 4:
return (
<div className="mt-8">
<p className="mb-2 font-medium text-foreground">Starting view</p>
<div
aria-label="Workspace starting view"
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
role="radiogroup"
>
{workspaceViewOptions.map((option, index) => {
const selected = workspaceView === option.value
const ViewIcon = option.icon
return (
<Card
aria-checked={selected}
className={classNames(
'relative h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected
? 'border-primary ring'
: 'hover:border-primary',
)}
key={option.value}
role="radio"
tabIndex={selected ? 0 : -1}
bodyClass="flex h-full flex-col gap-8 p-4"
onClick={() => setWorkspaceView(option.value)}
onKeyDown={(event) =>
handleViewKeyDown(event, index)
}
>
<IconFrame size={40} variant="muted">
<ViewIcon
aria-hidden="true"
className={classNames(
'text-xl',
selected && 'text-primary',
)}
/>
</IconFrame>
<div className="space-y-1">
<p
className={classNames(
'font-semibold',
selected
? 'text-primary'
: 'text-card-foreground',
)}
>
{option.label}
</p>
<p className="pe-8 text-muted-foreground">
{option.description}
</p>
</div>
{selected ? (
<span
aria-hidden="true"
className="absolute bottom-4 end-4 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<PiCheck className="text-sm" />
</span>
) : null}
</Card>
)
})}
</div>
</div>
)
default:
return null
}
}
const renderStepActions = () => (
<div className="mt-8 flex flex-wrap items-center justify-between gap-4 pt-4">
{currentStep === 0 ? (
<span aria-hidden="true" />
) : (
<Button
icon={<PiArrowLeft aria-hidden="true" />}
iconAlignment="start"
type="button"
variant="ghost"
onKeyDown={(event) =>
handleKeyboardAction(event, handlePrevious)
}
onClick={handlePrevious}
>
Previous
</Button>
)}
<div
className={classNames(
'flex flex-wrap items-center gap-2',
currentStep === 0 && 'w-full',
)}
>
{currentStep > 0 && (
<Button
type="button"
variant="ghost"
onKeyDown={(event) =>
handleKeyboardAction(event, handleSkip)
}
onClick={handleSkip}
>
Skip
</Button>
)}
<Button
block={currentStep === 0}
icon={<PiArrowRight aria-hidden="true" />}
iconAlignment="end"
type="button"
variant="solid"
onKeyDown={(event) =>
handleKeyboardAction(event, handleNext)
}
onClick={handleNext}
>
Next step
</Button>
</div>
</div>
)
return (
<AppShellSeamlessSide
sidebar={
<div className="hidden h-full bg-card p-2 lg:block">
<aside
aria-label="Onboarding setup progress"
className="flex h-[calc(100vh-1rem)] w-80 flex-col rounded-card bg-primary p-8 text-primary-foreground relative"
>
<img
alt=""
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-5 select-none"
src={`${assetBase}/thumbs/misc/img-16.png`}
/>
<div className="flex items-center">
<Logo
alt="NateUI"
imgProps={{ className: 'h-7' }}
src={logoWhiteSrc}
/>
</div>
<div className="flex flex-1 items-center py-8">
<div className="w-full" aria-label="Setup stages">
<Steps
aria-label="Setup stages"
className="[&_.step-connect]:bg-primary-foreground/30 [&_.step-item-icon-complete]:bg-primary-foreground [&_.step-item-icon-complete]:text-primary [&_.step-item-icon-current]:border-primary-foreground [&_.step-item-icon-current]:text-primary-foreground [&_.step-item-icon-pending]:border-primary-foreground/70 [&_.step-item-title:hover]:text-primary-foreground"
current={isComplete ? totalSteps : currentStep}
vertical
>
{sidebarStepDetails.map((step, index) => {
const isCompleted =
index <
(isComplete
? totalSteps
: currentStep)
return (
<Steps.Item
key={step.title}
customIcon={
isCompleted
? undefined
: index === currentStep
? <span className="h-2 w-2 rounded-full bg-primary-foreground" aria-hidden="true" />
: <span aria-hidden="true" />
}
description={
<span className="opacity-80">{step.description}</span>
}
onStepChange={
isCompleted
? () =>
handleTimelineStepChange(
index,
)
: undefined
}
title={step.title}
/>
)
})}
</Steps>
</div>
</div>
<div className="flex items-center justify-between gap-2 pt-4 text-xs">
<span className="flex items-center gap-1">
<span className="flex items-center gap-2">
<PiQuestion className="text-base" />
<span>Need help?</span>
</span>
<a
className="rounded-sm hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-primary-foreground/80 underline"
href="#help"
>
Contact Support
</a>
</span>
</div>
</aside>
</div>
}
header={
<Header
className="lg:hidden"
headerStart={[
{
component: (
<div className="flex items-center">
<Logo
alt="NateUI"
imgProps={{ className: 'h-7 dark:hidden' }}
src={logoSrc}
/>
<Logo
alt="NateUI"
imgProps={{ className: 'hidden h-7 dark:block' }}
src={logoWhiteSrc}
/>
</div>
),
},
]}
/>
}
>
<PageContainer footer={false}>
<div className="flex min-h-full flex-col">
<Container
asElement="div"
className="flex min-h-full w-full max-w-md flex-col"
size="sm"
>
<div className="flex flex-1 flex-col justify-center py-8">
{isComplete ? (
<div className="flex flex-col items-center text-center">
<div className="rounded-full bg-palette-emerald-soft p-4">
<IconFrame
className="rounded-full bg-palette-emerald text-success/30 drop-shadow-[0_4px_6px_currentColor]"
size={64}
variant="muted"
>
<PiCheckBold
aria-hidden="true"
className="text-3xl text-primary-foreground"
/>
</IconFrame>
</div>
<h1 className="mt-8 text-2xl font-semibold text-foreground">
Ready to go!
</h1>
<p className="mt-1 text-muted-foreground">
The workspace can now be opened for the first task.
</p>
<div className="mt-8 flex w-full max-w-sm flex-col items-center gap-4">
<Button block type="button" variant="solid">
Open workspace
</Button>
<Button
block
type="button"
variant="ghost"
onClick={() => setCurrentStep(totalSteps - 1)}
>
Review selections
</Button>
</div>
</div>
) : (
<>
<div aria-live="polite">
<h4 className="text-xl font-semibold text-foreground">
{activeStep.title}
</h4>
<p className="mt-1 text-muted-foreground">
{activeStep.subtitle}
</p>
</div>
{renderStepContent()}
{renderStepActions()}
</>
)}
</div>
</Container>
</div>
</PageContainer>
</AppShellSeamlessSide>
)
}
Onboarding 03
Preview
npx nateui@latest add OnboardingSideSplitDark
import { useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import AuthShellSplit from '@/components/layouts/AuthShellSplit'
import Container from '@/components/composites/Container'
import IconFrame from '@/components/composites/IconFrame'
import classNames from '@/utils/classNames'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Progress from '@/components/ui/Progress'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
import Upload from '@/components/ui/Upload'
import {
PiArrowLeft,
PiArrowRight,
PiBell,
PiCalendarBlank,
PiChartBar,
PiChartLineUp,
PiChatCircleText,
PiCheck,
PiCheckBold,
PiCheckCircleFill,
PiCheckSquare,
PiFileText,
PiKanban,
PiLightbulb,
PiMapTrifold,
PiNote,
PiRows,
PiSquaresFour,
PiUploadSimple,
PiUser,
PiUsersThree,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const logoSrc = `${assetBase}/logos/logo-collapsed.svg`
const logoWhiteSrc = `${assetBase}/logos/logo-white-collapsed.svg`
const mediaSrc = `${assetBase}/thumbs/misc/img-30.png`
const backgroundSrc = `${assetBase}/thumbs/misc/img-31.png`
const totalSteps = 5
const stepDetails = [
{
title: 'Add profile details',
subtitle: 'Choose the details that identify you in this workspace.',
},
{
title: 'Choose your work focus',
subtitle: 'Select the activity that best matches your starting view.',
},
{
title: 'Tune your starting topics',
subtitle: 'Optional: choose subjects to show in your first workspace view.',
},
{
title: 'Name your workspace',
subtitle: 'Choose a name and URL for the shared space.',
},
{
title: 'Pick a workspace view',
subtitle: 'Choose the layout you want to open first.',
},
]
const timezoneOptions = [
{ label: 'UTC-08:00 Pacific', value: 'pacific' },
{ label: 'UTC-05:00 Eastern', value: 'eastern' },
{ label: 'UTC+01:00 Central European', value: 'central-european' },
{ label: 'UTC+08:00 Singapore', value: 'singapore' },
]
const workFocusOptions = [
{ label: 'Planning', value: 'planning' },
{ label: 'Building', value: 'building' },
{ label: 'Designing', value: 'designing' },
{ label: 'Research', value: 'research' },
{ label: 'Operations', value: 'operations' },
{ label: 'Support', value: 'support' },
]
const interestOptions = [
{ label: 'Projects', value: 'projects', icon: PiKanban },
{ label: 'Notes', value: 'notes', icon: PiNote },
{ label: 'Tasks', value: 'tasks', icon: PiCheckSquare },
{ label: 'Files', value: 'files', icon: PiFileText },
{ label: 'Calendars', value: 'calendars', icon: PiCalendarBlank },
{ label: 'People', value: 'people', icon: PiUsersThree },
{ label: 'Reports', value: 'reports', icon: PiChartBar },
{ label: 'Roadmaps', value: 'roadmaps', icon: PiMapTrifold },
{ label: 'Ideas', value: 'ideas', icon: PiLightbulb },
{ label: 'Reviews', value: 'reviews', icon: PiChatCircleText },
{ label: 'Updates', value: 'updates', icon: PiBell },
{ label: 'Templates', value: 'templates', icon: PiSquaresFour },
{ label: 'Metrics', value: 'metrics', icon: PiChartLineUp },
]
const teamSizeOptions = [
'Solo',
'2-8',
'9-24',
'25-75',
'76-250',
'251-750',
'751-2,500',
'2,500+',
]
const workspaceViewOptions = [
{
label: 'Overview',
value: 'overview',
description: 'Recent activity and open work in one summary.',
icon: PiSquaresFour,
},
{
label: 'Board',
value: 'board',
description: 'A column for each stage, moved by drag.',
icon: PiKanban,
},
{
label: 'Timeline',
value: 'timeline',
description: 'Work plotted across dates and owners.',
icon: PiChartLineUp,
},
{
label: 'Table',
value: 'table',
description: 'Rows and columns with filters and sorting.',
icon: PiRows,
},
]
const focusRingClass =
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background'
export default function OnboardingSideSplit() {
const [currentStep, setCurrentStep] = useState(0)
const [displayName, setDisplayName] = useState('Jordan Lee')
const [roleLabel, setRoleLabel] = useState('Operations lead')
const [timezone, setTimezone] = useState('singapore')
const [updatesEnabled, setUpdatesEnabled] = useState(true)
const [workFocus, setWorkFocus] = useState('building')
const [interests, setInterests] = useState(['projects', 'notes', 'tasks'])
const [workspaceName, setWorkspaceName] = useState('Northstar')
const [workspaceSlug, setWorkspaceSlug] = useState('northstar')
const [teamSize, setTeamSize] = useState('9-24')
const [workspaceView, setWorkspaceView] = useState('overview')
const isComplete = currentStep === totalSteps
const activeStep = stepDetails[currentStep]
const progressPercent = isComplete
? 100
: ((currentStep + 1) / totalSteps) * 100
const handleNext = () => {
setCurrentStep((step) => Math.min(step + 1, totalSteps))
}
const handlePrevious = () => {
setCurrentStep((step) => Math.max(step - 1, 0))
}
const handleKeyboardAction = (
event: ReactKeyboardEvent<HTMLElement>,
action: () => void,
) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
action()
}
}
const handleWorkFocusKeyDown = (
event: ReactKeyboardEvent<HTMLElement>,
index: number,
) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setWorkFocus(workFocusOptions[index].value)
return
}
const forward = event.key === 'ArrowDown' || event.key === 'ArrowRight'
const backward = event.key === 'ArrowUp' || event.key === 'ArrowLeft'
if (!forward && !backward) {
return
}
event.preventDefault()
const optionCount = workFocusOptions.length
const nextIndex = forward
? (index + 1) % optionCount
: (index - 1 + optionCount) % optionCount
setWorkFocus(workFocusOptions[nextIndex].value)
const nextRadio = event.currentTarget
.closest('.radio-group')
?.querySelectorAll('input[type="radio"]')[nextIndex]
if (nextRadio instanceof HTMLElement) {
nextRadio.focus()
}
}
const toggleInterest = (value: string) => {
setInterests((selected) =>
selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value],
)
}
const handleViewKeyDown = (
event: ReactKeyboardEvent<HTMLElement>,
index: number,
) => {
const { key } = event
if (key === 'Enter' || key === ' ') {
event.preventDefault()
setWorkspaceView(workspaceViewOptions[index].value)
return
}
const forward = key === 'ArrowRight' || key === 'ArrowDown'
const backward = key === 'ArrowLeft' || key === 'ArrowUp'
if (!forward && !backward) {
return
}
event.preventDefault()
const optionCount = workspaceViewOptions.length
const nextIndex = forward
? (index + 1) % optionCount
: (index - 1 + optionCount) % optionCount
setWorkspaceView(workspaceViewOptions[nextIndex].value)
const nextCard = event.currentTarget.parentElement?.children[nextIndex]
if (nextCard instanceof HTMLElement) {
nextCard.focus()
}
}
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<div className="mt-8">
<Upload
accept="image/png,image/jpeg"
className="w-full"
showList={false}
uploadLimit={1}
>
<div className="mb-4 flex items-center gap-4">
<Avatar
aria-hidden="true"
icon={<PiUser />}
size="lg"
/>
<div className="flex min-w-0 flex-col items-start gap-2">
<Button
className={focusRingClass}
icon={<PiUploadSimple aria-hidden="true" />}
size="sm"
type="button"
>
Add image
</Button>
</div>
</div>
</Upload>
<Form
className="mt-4"
onSubmit={(event) => event.preventDefault()}
>
<Form.Field
asterisk
htmlFor="onboarding-side-split-display-name"
label="Display name"
>
<Input
id="onboarding-side-split-display-name"
value={displayName}
onChange={(event) =>
setDisplayName(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor="onboarding-side-split-role-label"
label="Role label"
>
<Input
id="onboarding-side-split-role-label"
value={roleLabel}
onChange={(event) =>
setRoleLabel(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor="onboarding-side-split-timezone"
label="Time zone"
labelId="onboarding-side-split-timezone"
>
<Select
inputId="onboarding-side-split-timezone"
options={timezoneOptions}
placeholder="Choose a time zone"
value={timezoneOptions.find(
(option) => option.value === timezone,
)}
onChange={(option) =>
setTimezone(option.value as string)
}
/>
</Form.Field>
<Checkbox
checked={updatesEnabled}
onChange={(checked) =>
setUpdatesEnabled(checked)
}
>
Account notices by email
</Checkbox>
</Form>
</div>
)
case 1:
return (
<div className="mt-8 flex flex-col gap-4">
<p className="font-medium text-foreground">Pick one activity</p>
<Radio.Group
className="w-full gap-2"
name="onboarding-side-split-work-focus"
value={workFocus}
vertical
onChange={(value) => setWorkFocus(value as string)}
>
{workFocusOptions.map((option, index) => (
<div
className={classNames(
'w-full rounded-control',
workFocus !== option.value && 'hover:bg-accent',
)}
key={option.value}
>
<Radio
className={classNames(
'w-full rounded-control border px-4 py-2',
focusRingClass,
)}
tabIndex={
workFocus === option.value ? 0 : -1
}
value={option.value}
onKeyDown={(event) =>
handleWorkFocusKeyDown(event, index)
}
>
{option.label}
</Radio>
</div>
))}
</Radio.Group>
</div>
)
case 2:
return (
<div className="mt-8 flex flex-wrap gap-2">
{interestOptions.map((interest) => {
const selected = interests.includes(interest.value)
return (
<div
className="relative inline-flex"
key={interest.value}
>
<Button
aria-pressed={selected}
className={focusRingClass}
icon={
selected ? (
<PiCheckCircleFill
aria-hidden="true"
className="text-xl text-primary"
/>
) : undefined
}
type="button"
iconAlignment="end"
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
toggleInterest(interest.value),
)
}
onClick={() => toggleInterest(interest.value)}
>
{interest.label}
</Button>
</div>
)
})}
</div>
)
case 3:
return (
<div className="mt-8">
<Form
onSubmit={(event) => event.preventDefault()}
>
<Form.Field
asterisk
htmlFor="onboarding-side-split-workspace-name"
label="Workspace name"
>
<Input
id="onboarding-side-split-workspace-name"
value={workspaceName}
onChange={(event) =>
setWorkspaceName(event.target.value)
}
/>
</Form.Field>
<Form.Field
asterisk
htmlFor="onboarding-side-split-workspace-slug"
label="Workspace slug"
>
<Input
id="onboarding-side-split-workspace-slug"
value={workspaceSlug}
onChange={(event) =>
setWorkspaceSlug(event.target.value)
}
/>
</Form.Field>
</Form>
<div className="mt-8">
<p className="mb-2 font-medium text-foreground">
Starting team size
</p>
<div className="flex flex-wrap gap-2">
{teamSizeOptions.map((option) => {
const selected = teamSize === option
return (
<div
className="relative inline-flex"
key={option}
>
<Button
aria-pressed={selected}
className={focusRingClass}
type="button"
variant={selected ? 'subtle' : 'default'}
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
setTeamSize(option),
)
}
icon={
selected ? (
<PiCheckCircleFill
aria-hidden="true"
className="text-lg text-primary"
/>
) : undefined
}
iconAlignment="end"
onClick={() => setTeamSize(option)}
>
{option}
</Button>
</div>
)
})}
</div>
</div>
</div>
)
case 4:
return (
<div className="mt-8">
<p className="mb-2 font-medium text-foreground">Starting view</p>
<div
aria-label="Workspace starting view"
className="grid grid-cols-1 gap-4"
role="radiogroup"
>
{workspaceViewOptions.map((option, index) => {
const selected = workspaceView === option.value
const ViewIcon = option.icon
return (
<div
aria-checked={selected}
className={classNames(
'flex items-center justify-between border rounded-card p-2 h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
selected
? 'ring border-primary'
: 'hover:border-primary',
)}
key={option.value}
role="radio"
tabIndex={selected ? 0 : -1}
onClick={() => setWorkspaceView(option.value)}
onKeyDown={(event) =>
handleViewKeyDown(event, index)
}
>
<div className="flex items-center gap-2">
<IconFrame size={28} variant="muted">
<ViewIcon
aria-hidden="true"
className={classNames(
'text-xl',
selected && 'text-primary',
)}
/>
</IconFrame>
<p
className={classNames(
'font-semibold',
selected
? 'text-primary'
: 'text-card-foreground',
)}
>
{option.label}
</p>
</div>
{selected ? (
<span
aria-hidden="true"
className="h-4 w-4 flex items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<PiCheck />
</span>
) : null}
</div>
)
})}
</div>
</div>
)
default:
return null
}
}
const renderFooter = () => {
if (isComplete) {
return null
}
return (
<footer className="px-8 pb-8 sm:px-12">
<div className="flex items-center justify-between mx-auto w-full max-w-[380px] xl:max-w-[450px]">
{
currentStep > 0 ? (
<Button
icon={<PiArrowLeft aria-hidden="true" />}
iconAlignment="start"
type="button"
variant="ghost"
onKeyDown={(event) =>
handleKeyboardAction(event, handlePrevious)
}
onClick={handlePrevious}
>
Previous
</Button>
)
: (
<div />
)
}
<Button
className={focusRingClass}
icon={<PiArrowRight aria-hidden="true" />}
iconAlignment="end"
type="button"
block={currentStep === 0}
variant="solid"
onKeyDown={(event) =>
handleKeyboardAction(event, handleNext)
}
onClick={handleNext}
>
Next step
</Button>
</div>
</footer>
)
}
const renderLeftColumn = () => (
<section
aria-labelledby="onboarding-side-split-title"
className="w-full"
>
{isComplete ? (
<main className="flex flex-col items-center text-center">
<div className="rounded-full bg-palette-emerald-soft p-4">
<IconFrame
className="rounded-full bg-palette-emerald text-success/30 drop-shadow-[0_4px_6px_currentColor]"
size={64}
variant="muted"
>
<PiCheckBold
aria-hidden="true"
className="text-3xl text-primary-foreground"
/>
</IconFrame>
</div>
<h4
className="mt-8 text-xl font-semibold text-foreground"
id="onboarding-side-split-title"
>
Ready to go!
</h4>
<p className="mt-1 text-muted-foreground">
The workspace can now be opened for the first task.
</p>
<div className="mt-8 flex w-full flex-col items-center gap-4">
<Button
block
className={focusRingClass}
type="button"
variant="solid"
>
Open workspace
</Button>
<Button
block
className={focusRingClass}
type="button"
variant="ghost"
onClick={() => setCurrentStep(totalSteps - 1)}
>
Review selections
</Button>
</div>
</main>
) : (
<main aria-live="polite">
<h4
className="text-xl font-semibold leading-tight text-foreground"
id="onboarding-side-split-title"
>
{activeStep.title}
</h4>
<p className="mt-1 text-muted-foreground">
{activeStep.subtitle}
</p>
{renderStepContent()}
</main>
)}
</section>
)
return (
<AuthShellSplit
className="h-screen"
header={
<div className="flex items-center justify-between gap-4 p-2">
<div>
<img
alt=""
className="h-7 w-auto dark:hidden"
src={logoSrc}
/>
<img
alt="NateUI"
className="hidden h-7 w-auto dark:block"
src={logoWhiteSrc}
/>
</div>
<div className="flex min-w-0 items-center gap-2 px-4">
<div
aria-label={
isComplete
? 'Onboarding complete'
: `Step ${currentStep + 1} of ${totalSteps}`
}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progressPercent}
className="w-24"
role="progressbar"
>
<Progress
percent={progressPercent}
showInfo={false}
/>
</div>
<span className="flex items-center gap-2 whitespace-nowrap font-medium text-muted-foreground">
{isComplete ? 'Setup complete' : `${currentStep + 1} of ${totalSteps}`}
</span>
{isComplete ? (
<PiCheckCircleFill
aria-hidden="true"
className="text-base text-success"
/>
) : null}
</div>
</div>
}
sideContent={
<aside
className="relative hidden flex-col overflow-hidden rounded-card bg-background bg-cover bg-center bg-no-repeat lg:flex"
style={{ backgroundImage: `url(${backgroundSrc})` }}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-background/90"
/>
<div className="relative min-h-0 flex-1 overflow-hidden mask-b-from-75%">
<div
aria-hidden="true"
className="absolute left-24 top-24 h-full w-full rounded-[30px] bg-inverse/10 p-2 xl:left-12 xl:top-12 xl:rounded-[40px]"
>
<img
alt=""
className="h-full w-full rounded-[24px] object-cover object-left-top"
src={mediaSrc}
/>
</div>
</div>
</aside>
}
footer={renderFooter()}
>
<Container
asElement="div"
className="w-full"
size="lg"
>
{renderLeftColumn()}
</Container>
</AuthShellSplit>
)
}
Onboarding 04
Preview
npx nateui@latest add OnboardingSetupDialogDark
import { useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import classNames from '@/utils/classNames'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Dialog from '@/components/ui/Dialog'
import Form from '@/components/ui/Form'
import IconFrame from '@/components/composites/IconFrame'
import Input from '@/components/ui/Input'
import Progress from '@/components/ui/Progress'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
import Upload from '@/components/ui/Upload'
import {
PiArrowLeft,
PiArrowRight,
PiBell,
PiCalendarBlank,
PiChartBar,
PiChartLineUp,
PiChatCircleText,
PiCheck,
PiCheckBold,
PiCheckCircleFill,
PiCheckSquare,
PiFileText,
PiKanban,
PiLightbulb,
PiMapTrifold,
PiNote,
PiRows,
PiSquaresFour,
PiUploadSimple,
PiUser,
PiUsersThree,
} from 'react-icons/pi'
const totalSteps = 5
const dialogTitleId = 'onboarding-dialog-title'
const stepDetails = [
{
title: 'Add profile details',
subtitle: 'Choose the details that will identify you in this workspace.',
},
{
title: 'Choose your work focus',
subtitle: 'Select the activity that best matches your starting view.',
},
{
title: 'Tune your starting topics',
subtitle: 'Optional: choose subjects to show in your first workspace view.',
},
{
title: 'Name your workspace',
subtitle: 'Choose a name and URL for the shared space.',
},
{
title: 'Pick a workspace view',
subtitle: 'Choose the layout you want to open first.',
},
]
const timezoneOptions = [
{ label: 'UTC-08:00 Pacific', value: 'pacific' },
{ label: 'UTC-05:00 Eastern', value: 'eastern' },
{ label: 'UTC+01:00 Central European', value: 'central-european' },
{ label: 'UTC+08:00 Singapore', value: 'singapore' },
]
const workFocusOptions = [
{ label: 'Planning', value: 'planning' },
{ label: 'Building', value: 'building' },
{ label: 'Designing', value: 'designing' },
{ label: 'Research', value: 'research' },
{ label: 'Operations', value: 'operations' },
{ label: 'Support', value: 'support' },
]
const interestOptions = [
{ label: 'Projects', value: 'projects', icon: PiKanban },
{ label: 'Notes', value: 'notes', icon: PiNote },
{ label: 'Tasks', value: 'tasks', icon: PiCheckSquare },
{ label: 'Files', value: 'files', icon: PiFileText },
{ label: 'Calendars', value: 'calendars', icon: PiCalendarBlank },
{ label: 'People', value: 'people', icon: PiUsersThree },
{ label: 'Reports', value: 'reports', icon: PiChartBar },
{ label: 'Roadmaps', value: 'roadmaps', icon: PiMapTrifold },
{ label: 'Ideas', value: 'ideas', icon: PiLightbulb },
{ label: 'Reviews', value: 'reviews', icon: PiChatCircleText },
{ label: 'Updates', value: 'updates', icon: PiBell },
{ label: 'Templates', value: 'templates', icon: PiSquaresFour },
{ label: 'Metrics', value: 'metrics', icon: PiChartLineUp },
]
const teamSizeOptions = [
'Solo',
'2-8',
'9-24',
'25-75',
'76-250',
'251-750',
'751-2,500',
'2,500+',
]
const workspaceViewOptions = [
{
label: 'Overview',
value: 'overview',
description: 'Recent activity and open work in one summary.',
icon: PiSquaresFour,
},
{
label: 'Board',
value: 'board',
description: 'A column for each stage, moved by drag.',
icon: PiKanban,
},
{
label: 'Timeline',
value: 'timeline',
description: 'Work plotted across dates and owners.',
icon: PiChartLineUp,
},
{
label: 'Table',
value: 'table',
description: 'Rows and columns with filters and sorting.',
icon: PiRows,
},
]
export default function OnboardingSetupDialog() {
const [isOpen, setIsOpen] = useState(true)
const [currentStep, setCurrentStep] = useState(0)
const [displayName, setDisplayName] = useState('Jordan Lee')
const [roleLabel, setRoleLabel] = useState('Operations lead')
const [timezone, setTimezone] = useState('singapore')
const [updatesEnabled, setUpdatesEnabled] = useState(true)
const [workFocus, setWorkFocus] = useState('building')
const [interests, setInterests] = useState(['projects', 'notes', 'tasks'])
const [workspaceName, setWorkspaceName] = useState('Northstar')
const [workspaceSlug, setWorkspaceSlug] = useState('northstar')
const [teamSize, setTeamSize] = useState('9-24')
const [workspaceView, setWorkspaceView] = useState('overview')
const isComplete = currentStep === totalSteps
const activeStep = stepDetails[Math.min(currentStep, totalSteps - 1)]
const progressPercent = isComplete
? 100
: ((currentStep + 1) / totalSteps) * 100
const handleNext = () => {
setCurrentStep((step) => Math.min(step + 1, totalSteps))
}
const handlePrevious = () => {
setCurrentStep((step) => Math.max(step - 1, 0))
}
const handleSkip = () => {
handleNext()
}
const handleOpen = () => {
setCurrentStep(0)
setIsOpen(true)
}
const handleClose = () => {
setIsOpen(false)
}
const handleKeyboardAction = (
event: ReactKeyboardEvent<HTMLElement>,
action: () => void,
) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
action()
}
}
const toggleInterest = (value: string) => {
setInterests((selected) =>
selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value],
)
}
const handleViewKeyDown = (
event: ReactKeyboardEvent<HTMLElement>,
index: number,
) => {
const { key } = event
if (key === 'Enter' || key === ' ') {
event.preventDefault()
setWorkspaceView(workspaceViewOptions[index].value)
return
}
const forward = key === 'ArrowRight' || key === 'ArrowDown'
const backward = key === 'ArrowLeft' || key === 'ArrowUp'
if (!forward && !backward) {
return
}
event.preventDefault()
const optionCount = workspaceViewOptions.length
const nextIndex = forward
? (index + 1) % optionCount
: (index - 1 + optionCount) % optionCount
setWorkspaceView(workspaceViewOptions[nextIndex].value)
const nextCard = event.currentTarget.parentElement?.children[nextIndex]
if (nextCard instanceof HTMLElement) {
nextCard.focus()
}
}
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<div className="mt-8">
<Upload
accept="image/png,image/jpeg"
className="w-full"
showList={false}
uploadLimit={1}
>
<div className="mb-4 flex items-center gap-4">
<Avatar
aria-hidden="true"
icon={<PiUser />}
size="lg"
/>
<div className="flex min-w-0 flex-col items-start gap-2">
<Button
icon={<PiUploadSimple aria-hidden="true" />}
size="sm"
type="button"
>
Add image
</Button>
</div>
</div>
</Upload>
<Form
className="mt-4"
onSubmit={(event) => event.preventDefault()}
>
<Form.Field
asterisk
htmlFor="onboarding-dialog-display-name"
label="Display name"
>
<Input
id="onboarding-dialog-display-name"
value={displayName}
onChange={(event) =>
setDisplayName(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor="onboarding-dialog-role-label"
label="Role label"
>
<Input
id="onboarding-dialog-role-label"
value={roleLabel}
onChange={(event) =>
setRoleLabel(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor="onboarding-dialog-timezone"
label="Time zone"
>
<Select
inputId="onboarding-dialog-timezone"
options={timezoneOptions}
placeholder="Choose a time zone"
value={timezoneOptions.find(
(option) => option.value === timezone,
)}
onChange={(option) =>
setTimezone(option.value as string)
}
/>
</Form.Field>
<Checkbox
checked={updatesEnabled}
onChange={(checked) =>
setUpdatesEnabled(checked)
}
>
Account notices by email
</Checkbox>
</Form>
</div>
)
case 1:
return (
<div className="mt-8 flex flex-col gap-4">
<p className="font-medium text-foreground">
Pick one activity
</p>
<Radio.Group
className="w-full gap-2"
name="onboarding-dialog-work-focus"
value={workFocus}
vertical
onChange={(value) => setWorkFocus(value as string)}
>
{workFocusOptions.map((option) => (
<div
className={classNames(
'w-full',
workFocus !== option.value &&
'hover:bg-accent',
)}
key={option.value}
>
<Radio
className="w-full rounded-control border px-4 py-2"
value={option.value}
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
setWorkFocus(option.value),
)
}
>
{option.label}
</Radio>
</div>
))}
</Radio.Group>
</div>
)
case 2:
return (
<div className="mt-8 flex flex-wrap gap-2">
{interestOptions.map((interest) => {
const selected = interests.includes(interest.value)
return (
<div
className="relative inline-flex"
key={interest.value}
>
<Button
aria-pressed={selected}
icon={
selected ? (
<PiCheckCircleFill
aria-hidden="true"
className="text-xl text-primary"
/>
) : undefined
}
iconAlignment="end"
type="button"
variant={
selected ? 'subtle' : 'default'
}
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
toggleInterest(interest.value),
)
}
onClick={() =>
toggleInterest(interest.value)
}
>
{interest.label}
</Button>
</div>
)
})}
</div>
)
case 3:
return (
<div className="mt-8">
<Form
onSubmit={(event) => event.preventDefault()}
>
<Form.Field
asterisk
htmlFor="onboarding-dialog-workspace-name"
label="Workspace name"
>
<Input
id="onboarding-dialog-workspace-name"
value={workspaceName}
onChange={(event) =>
setWorkspaceName(event.target.value)
}
/>
</Form.Field>
<Form.Field
asterisk
htmlFor="onboarding-dialog-workspace-slug"
label="Workspace slug"
>
<Input
id="onboarding-dialog-workspace-slug"
value={workspaceSlug}
onChange={(event) =>
setWorkspaceSlug(event.target.value)
}
/>
</Form.Field>
</Form>
<div className="mt-8">
<p className="mb-2 font-medium text-foreground">
Starting team size
</p>
<div className="flex flex-wrap gap-2">
{teamSizeOptions.map((option) => {
const selected = teamSize === option
return (
<div
className="relative inline-flex"
key={option}
>
<Button
aria-pressed={selected}
icon={
selected ? (
<PiCheckCircleFill
aria-hidden="true"
className="text-lg text-primary"
/>
) : undefined
}
iconAlignment="end"
type="button"
variant={
selected
? 'subtle'
: 'default'
}
onKeyDown={(event) =>
handleKeyboardAction(event, () =>
setTeamSize(option),
)
}
onClick={() =>
setTeamSize(option)
}
>
{option}
</Button>
</div>
)
})}
</div>
</div>
</div>
)
case 4:
return (
<div className="mt-8">
<p className="mb-2 font-medium text-foreground">Starting view</p>
<div
aria-label="Workspace starting view"
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
role="radiogroup"
>
{workspaceViewOptions.map((option, index) => {
const selected = workspaceView === option.value
const ViewIcon = option.icon
return (
<Card
aria-checked={selected}
className={classNames(
'relative h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected
? 'border-primary ring'
: 'hover:border-primary',
)}
key={option.value}
role="radio"
tabIndex={selected ? 0 : -1}
bodyClass="flex h-full flex-col gap-8 p-4"
onClick={() =>
setWorkspaceView(option.value)
}
onKeyDown={(event) =>
handleViewKeyDown(event, index)
}
>
<IconFrame size={40} variant="muted">
<ViewIcon
aria-hidden="true"
className={classNames(
'text-xl',
selected && 'text-primary',
)}
/>
</IconFrame>
<div className="space-y-1">
<p
className={classNames(
'font-semibold',
selected
? 'text-primary'
: 'text-card-foreground',
)}
>
{option.label}
</p>
<p className="pe-8 text-muted-foreground">
{option.description}
</p>
</div>
{selected ? (
<span
aria-hidden="true"
className="absolute bottom-4 end-4 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<PiCheck className="text-sm" />
</span>
) : null}
</Card>
)
})}
</div>
</div>
)
default:
return null
}
}
const renderDialogHeader = () => (
<header className="flex items-start justify-between gap-4 border-b px-6 py-4 pe-12">
<div className="min-w-0">
<h2
className="text-lg font-semibold text-foreground"
id={dialogTitleId}
>
Workspace setup
</h2>
<p className="mt-1 text-xs text-muted-foreground">
{isComplete ? 'Complete' : `Step ${currentStep + 1} of ${totalSteps}`}
</p>
</div>
<div
aria-label="Onboarding progress"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progressPercent}
className="w-24 shrink-0 pt-1"
role="progressbar"
>
<Progress
percent={progressPercent}
showInfo={false}
size="sm"
/>
</div>
</header>
)
const renderCompletion = () => (
<div className="mx-auto flex w-full max-w-sm flex-col justify-center items-center text-center h-full">
<div className="rounded-full bg-palette-emerald-soft p-4">
<IconFrame
className="rounded-full bg-palette-emerald text-success/30 drop-shadow-[0_4px_6px_currentColor]"
size={64}
variant="muted"
>
<PiCheckBold
aria-hidden="true"
className="text-3xl text-primary-foreground"
/>
</IconFrame>
</div>
<h3 className="mt-8 text-2xl font-semibold text-foreground">
Ready to go!
</h3>
<p className="mt-1 text-muted-foreground">
The workspace can now be opened for the first task.
</p>
<div className="mt-8 flex w-full flex-col items-center gap-4">
<Button
block
type="button"
variant="solid"
onClick={handleClose}
>
Open workspace
</Button>
<Button
block
type="button"
variant="ghost"
onClick={() => setCurrentStep(totalSteps - 1)}
>
Review selections
</Button>
</div>
</div>
)
const renderDialogFooter = () => {
if (isComplete) {
return null
}
return (
<footer className="flex flex-wrap items-center justify-between gap-4 border-t px-6 py-4">
{currentStep === 0 ? (
<span aria-hidden="true" />
) : (
<Button
icon={<PiArrowLeft aria-hidden="true" />}
iconAlignment="start"
type="button"
variant="ghost"
onKeyDown={(event) =>
handleKeyboardAction(event, handlePrevious)
}
onClick={handlePrevious}
>
Previous
</Button>
)}
<div className="flex flex-wrap items-center gap-2">
{currentStep > 0 && (
<Button
type="button"
variant="ghost"
onKeyDown={(event) =>
handleKeyboardAction(event, handleSkip)
}
onClick={handleSkip}
>
Skip
</Button>
)}
<Button
icon={<PiArrowRight aria-hidden="true" />}
iconAlignment="end"
type="button"
variant="solid"
onKeyDown={(event) =>
handleKeyboardAction(event, handleNext)
}
onClick={handleNext}
>
Next step
</Button>
</div>
</footer>
)
}
return (
<main className="relative flex min-h-screen items-center justify-center bg-background p-4 sm:p-8">
<Button
className="relative"
onClick={handleOpen}
>
Reopen setup flow
</Button>
<Dialog
aria-labelledby={dialogTitleId}
className="flex max-h-[calc(100dvh-2rem)] min-h-[34rem] flex-col overflow-hidden p-0"
overlayClassName="bg-white/50"
closable={false}
isOpen={isOpen}
lockScroll
onClose={handleClose}
shouldCloseOnEsc={false}
shouldCloseOnOverlayClick={false}
height={680}
width={560}
>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{isComplete ? (
renderCompletion()
) : (
<div aria-live="polite" className="mx-auto w-full max-w-lg">
<h4 className="text-xl font-semibold text-foreground">
{activeStep.title}
</h4>
<p className="mt-1 text-muted-foreground">
{activeStep.subtitle}
</p>
{renderStepContent()}
</div>
)}
</div>
{renderDialogFooter()}
</Dialog>
</main>
)
}
Onboarding 05
Preview
npx nateui@latest add OnboardingGuideDialogDark
import { useCallback, useEffect, useRef, useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import classNames from '@/utils/classNames'
import IconFrame from '@/components/composites/IconFrame'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import {
PiCalendarFill ,
PiCaretRight,
PiMagnifyingGlass,
PiUserCircleFill,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const guidePanels = [
{
title: 'Welcome to NateUI',
description: 'Take a quick look at the workspace before you begin.',
icons: [],
logo: `${assetBase}/logos/logo-collapsed.svg`,
washTokens: [
'--color-chart-1',
'--color-chart-4',
'--color-chart-7',
'--color-chart-9',
],
},
{
title: 'Set dates for upcoming work',
description:
'Due dates place work on the calendar and show what is scheduled next.',
icons: [PiCalendarFill ],
logo: null,
washTokens: [
'--color-chart-3',
'--color-chart-6',
'--color-chart-2',
'--color-chart-8',
],
},
{
title: 'Make ownership explicit',
description:
'Assign an owner to projects and tasks so responsibility is visible in the workspace.',
icons: [PiUserCircleFill],
logo: null,
washTokens: [
'--color-chart-5',
'--color-chart-1',
'--color-chart-8',
'--color-chart-4',
],
},
{
title: 'Search the whole workspace',
description:
'Find work by name, then narrow the results by project, owner, or status.',
icons: [PiMagnifyingGlass],
logo: null,
washTokens: [
'--color-chart-7',
'--color-chart-3',
'--color-chart-9',
'--color-chart-2',
],
},
]
// Entry owns the total transition. Exit is 75% of that duration, while the
// copy duration subtracts its entry delay so every moving layer finishes
// before the outgoing panel is removed.
const panelTransitionMs = 300
const panelLeaveMs = Math.round(panelTransitionMs * 0.75)
const panelCopyEnterDelayMs = 50
const panelCopyEnterMs = panelTransitionMs - panelCopyEnterDelayMs
const panelTransitionReleaseMs = 32
const dialogTitleId = 'onboarding-guide-dialog-title'
const dialogDescriptionId = 'onboarding-guide-dialog-description'
const stripePatternId = 'onboarding-guide-dialog-stripes'
const createMediaWash = (tokens: string[]) =>
[
`radial-gradient(ellipse at 20% 15%, color-mix(in oklab, var(${tokens[0]}) 38%, transparent) 0%, transparent 62%)`,
`radial-gradient(ellipse at 85% 10%, color-mix(in oklab, var(${tokens[1]}) 34%, transparent) 0%, transparent 58%)`,
`radial-gradient(ellipse at 70% 85%, color-mix(in oklab, var(${tokens[2]}) 30%, transparent) 0%, transparent 64%)`,
`radial-gradient(ellipse at 8% 88%, color-mix(in oklab, var(${tokens[3]}) 24%, transparent) 0%, transparent 60%)`,
].join(', ')
const GuideWash = ({ panelIndex }: { panelIndex: number }) => {
const panel = guidePanels[panelIndex]
return (
<div
aria-hidden="true"
className="h-full w-full opacity-90 dark:opacity-70"
style={{ backgroundImage: createMediaWash(panel.washTokens) }}
/>
)
}
const GuideIllustration = ({ panelIndex }: { panelIndex: number }) => {
const panel = guidePanels[panelIndex]
return (
<div className="flex h-full items-center justify-center gap-8">
{panel.logo && (
<IconFrame
aria-hidden="true"
className="rounded-[24px] bg-card/70 backdrop-blur-md"
size={80}
variant="elavated"
>
<img
alt=""
className="h-12 w-12"
src={panel.logo}
/>
</IconFrame>
)}
{panel.icons.map((Icon, index) => (
<IconFrame
aria-hidden="true"
className="rounded-[24px] bg-card/70 backdrop-blur-md"
key={`${panelIndex}-${index}`}
size={80}
variant="elavated"
>
<Icon className="text-3xl text-card-foreground" />
</IconFrame>
))}
</div>
)
}
const GuideCopy = ({
active,
panelIndex,
onAdvance,
}: {
active: boolean
panelIndex: number
onAdvance: () => void
}) => {
const panel = guidePanels[panelIndex]
const isLastPanel = panelIndex === guidePanels.length - 1
return (
<div className="flex h-full min-h-0 flex-col px-8 pt-8 pb-4">
<h2
className="text-xl font-semibold text-foreground"
id={active ? dialogTitleId : undefined}
>
{panel.title}
</h2>
<p
className="mt-2 text-muted-foreground"
id={active ? dialogDescriptionId : undefined}
>
{panel.description}
</p>
<Button
className="mt-8 self-start"
icon={<PiCaretRight aria-hidden="true" />}
iconAlignment="end"
tabIndex={active ? undefined : -1}
type="button"
variant="solid"
onClick={onAdvance}
>
{isLastPanel ? 'Finish guide' : 'Continue'}
</Button>
</div>
)
}
export default function OnboardingGuideDialog() {
const [isOpen, setIsOpen] = useState(true)
const [currentPanel, setCurrentPanel] = useState(0)
const [outgoingPanel, setOutgoingPanel] = useState<number | null>(null)
const [phase, setPhase] = useState<'idle' | 'preparing' | 'animating'>(
'idle',
)
const releaseTimerRef = useRef<number | null>(null)
const finishTimerRef = useRef<number | null>(null)
const isLastPanel = currentPanel === guidePanels.length - 1
const clearTransitionTimers = useCallback(() => {
if (releaseTimerRef.current !== null) {
window.clearTimeout(releaseTimerRef.current)
releaseTimerRef.current = null
}
if (finishTimerRef.current !== null) {
window.clearTimeout(finishTimerRef.current)
finishTimerRef.current = null
}
}, [])
useEffect(() => clearTransitionTimers, [clearTransitionTimers])
const handleClose = () => {
clearTransitionTimers()
setOutgoingPanel(null)
setPhase('idle')
setIsOpen(false)
}
const handleOpen = () => {
clearTransitionTimers()
setCurrentPanel(0)
setOutgoingPanel(null)
setPhase('idle')
setIsOpen(true)
}
const handlePanelChange = (panelIndex: number) => {
if (panelIndex === currentPanel || phase !== 'idle') {
return
}
clearTransitionTimers()
setOutgoingPanel(currentPanel)
setCurrentPanel(panelIndex)
setPhase('preparing')
// One frame at the entry offset, then release so the transition runs.
// A timer rather than requestAnimationFrame, which does not fire while
// the tab is hidden and would strand the panel faded out.
releaseTimerRef.current = window.setTimeout(() => {
setPhase('animating')
releaseTimerRef.current = null
}, panelTransitionReleaseMs)
finishTimerRef.current = window.setTimeout(() => {
setOutgoingPanel(null)
setPhase('idle')
finishTimerRef.current = null
}, panelTransitionReleaseMs + panelTransitionMs)
}
const handleAdvance = () => {
if (isLastPanel) {
handleClose()
return
}
handlePanelChange(currentPanel + 1)
}
const handlePanelKeyDown = (
event: ReactKeyboardEvent<HTMLButtonElement>,
panelIndex: number,
) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handlePanelChange(panelIndex)
}
}
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4 sm:p-8">
<div
aria-hidden="true"
className="absolute inset-4 overflow-hidden rounded-card border-2 border-dashed sm:inset-8"
>
<svg className="h-full w-full text-muted" fill="none">
<defs>
<pattern
height="8"
id={stripePatternId}
patternTransform="rotate(45)"
patternUnits="userSpaceOnUse"
width="8"
>
<line
stroke="currentColor"
strokeWidth="2"
x1="0"
x2="0"
y1="0"
y2="8"
/>
</pattern>
</defs>
<rect
fill={`url(#${stripePatternId})`}
height="100%"
width="100%"
/>
</svg>
</div>
<Button className="relative" type="button" onClick={handleOpen}>
Open feature guide
</Button>
<Dialog
aria-describedby={dialogDescriptionId}
aria-labelledby={dialogTitleId}
className="flex max-h-dvh flex-col overflow-hidden p-0"
closable
height={500}
isOpen={isOpen}
lockScroll
shouldCloseOnEsc
shouldCloseOnOverlayClick
width={400}
onClose={handleClose}
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<section
aria-hidden="true"
className="relative h-56 shrink-0 overflow-hidden bg-card"
>
{outgoingPanel !== null && (
<>
<div
className={classNames(
'pointer-events-none absolute inset-0 transition-opacity ease-in-out motion-reduce:transition-none',
phase === 'animating'
? 'opacity-0'
: 'opacity-100 transition-none',
)}
style={{
transitionDuration: `${panelTransitionMs}ms`,
}}
>
<GuideWash panelIndex={outgoingPanel} />
</div>
<div
className={classNames(
'pointer-events-none absolute inset-0 transition-[opacity,translate] ease-in motion-reduce:transition-none',
phase === 'animating'
? '-translate-y-6 opacity-0'
: 'translate-y-0 opacity-100 transition-none',
)}
style={{
transitionDuration: `${panelLeaveMs}ms`,
}}
>
<GuideIllustration
panelIndex={outgoingPanel}
/>
</div>
</>
)}
<div
className={classNames(
'absolute inset-0 transition-opacity ease-in-out motion-reduce:transition-none',
phase === 'preparing'
? 'opacity-0 transition-none'
: 'opacity-100',
)}
style={{
transitionDuration: `${panelTransitionMs}ms`,
}}
>
<GuideWash panelIndex={currentPanel} />
</div>
<div
className={classNames(
'relative h-full transition-[opacity,translate] ease-out motion-reduce:transition-none',
phase === 'preparing'
? 'translate-y-6 opacity-0 transition-none'
: 'translate-y-0 opacity-100',
)}
style={{
transitionDuration: `${panelTransitionMs}ms`,
}}
>
<GuideIllustration panelIndex={currentPanel} />
</div>
</section>
<div className="relative min-h-0 flex-1 overflow-hidden">
{outgoingPanel !== null && (
<div
aria-hidden="true"
className={classNames(
'pointer-events-none absolute inset-0 transition-[opacity,translate] ease-in motion-reduce:transition-none',
phase === 'animating'
? '-translate-x-4 opacity-0'
: 'translate-x-0 opacity-100 transition-none',
)}
inert
style={{
transitionDuration: `${panelLeaveMs}ms`,
}}
>
<GuideCopy
active={false}
panelIndex={outgoingPanel}
onAdvance={handleAdvance}
/>
</div>
)}
<div
className={classNames(
'relative h-full transition-[opacity,translate] ease-out motion-reduce:transition-none',
phase === 'preparing'
? 'translate-x-4 opacity-0 transition-none'
: 'translate-x-0 opacity-100',
)}
style={{
transitionDelay:
phase === 'animating'
? `${panelCopyEnterDelayMs}ms`
: '0ms',
transitionDuration: `${panelCopyEnterMs}ms`,
}}
>
<GuideCopy
active
panelIndex={currentPanel}
onAdvance={handleAdvance}
/>
</div>
</div>
</div>
<nav
aria-label="Guide panels"
className="flex shrink-0 justify-center px-8 pb-8"
>
<div className="flex items-center" role="group">
{guidePanels.map((panel, panelIndex) => {
const active = currentPanel === panelIndex
return (
<button
aria-current={active ? 'step' : undefined}
aria-label={`Go to panel ${panelIndex + 1}: ${panel.title}`}
className={classNames(
'flex h-4 w-8 items-center justify-center rounded-control focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
key={panel.title}
type="button"
onKeyDown={(event) =>
handlePanelKeyDown(event, panelIndex)
}
onClick={() =>
handlePanelChange(panelIndex)
}
>
<span
aria-hidden="true"
className="relative block h-1.5 w-full"
>
<span
className={classNames(
'absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-muted-foreground/30 transition-opacity duration-300 ease-out motion-reduce:transition-none',
active
? 'opacity-0'
: 'opacity-100',
)}
/>
<span
className={classNames(
'absolute inset-0 origin-center rounded-full bg-primary transition-[opacity,scale] duration-300 ease-out motion-reduce:transition-none',
active
? 'scale-x-100 opacity-100'
: 'scale-x-25 opacity-0',
)}
/>
</span>
</button>
)
})}
</div>
</nav>
</div>
</Dialog>
</main>
)
}