Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Account setup

AdvancedNew

A setup dialog whose steps fold open on fields you edit where they stand. Phone numbers and dates are laid out as you type.

account-setup/account-setup.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Onboarding that asks for a profile in a few groups, where some groups are required and the rest can wait.
  • Values the person mostly reads back and fixes one at a time, so each field opens in place instead of a whole form.
  • Showing at a glance which steps are done: each step’s badge says In Progress, Incomplete or Completed.
  • A setup that may be skipped and picked up later: closing it leaves a button that brings it back as it was.

When not to use

  • One or two fields. Ask for them inline, or with a plain form.
  • Steps that depend on each other’s answers. Use a wizard that walks through them in order.
  • Long free text or uploads: a row holds one line. Use a full form or a settings page.
  • Anything that must be filled in before the product works at all and can’t be skipped. Use a blocking form with the reason on it.

You can add this component with the shadcn CLI or by hand:

Into components/account-setup of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/account-setup.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

onboarding-dialog.tsx
import { AccountSetup } from './account-setup';export function OnboardingDialog() {  return (    <AccountSetup      autoplay={false}      defaultOpen="required"      onProceed={(values) => console.log('Saved', values)}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A dialog of three steps, each with a badge that says where it stands. Open a step and its fields appear; press a value and it becomes a bordered input with a clear button and a check. Phone numbers lay themselves out as you type, dates fill a dd/mm/yyyy mask, and a step’s badge turns Completed once every field is filled.

  • Each step is a disclosure button with a badge: In Progress for the next step to do or one already begun, Incomplete for the others, and Completed once every field holds a whole value.
  • A step opens with the dialog growing from its middle. The dialog is a stack of blocks, each on a layer of its own. Each frame, the panel is clipped to the part that is open and the blocks below move by the part that is closed, so no height is ever animated.
  • A value and its input share one box, with the input hidden underneath until it opens. Opening a field therefore changes no markup, and no style ever reaches its icons.
  • A phone number keeps its digits and lays them out as (123) 456-7894. A date shows what is left of its dd/mm/yyyy mask, faded, after the digits typed so far. A value that is not whole yet, such as nine digits or a 31st of February, keeps the field open with a red outline.
  • Proceed opens the first required step that is not done, at its first empty field; once those steps are done, it passes every value on. Skip and the cross fold the dialog into a button that brings it back.
  • Every colour is a light-dark() token on the root, so the dialog follows the page’s color-scheme, or the colorScheme prop. At night it is dark grey on a near-black stage, the editing ring turns light, the invalid ring a lighter red, and the demo’s I-beam turns light.
PropTypeDefaultDescription
sectionsSetupSection[]SECTIONSThe steps, each with its fields (sections.tsx). Proceed waits for the steps marked required.
titlestring'Account Setup'The dialog’s title, which also names it for screen readers.
descriptionstring'Complete simple steps to get started.'The line under the title.
defaultValuesRecord<string, string>{}The values to start from, by field id.
defaultOpenstring | nullnullThe id of the step open at first (required, profile or optional in the default steps), or null for none.
defaultEditing{ field: string; draft?: string; invalid?: boolean } | nullnullA field open as an input at first, with its text so far (the saved value when left out) and whether it shows as not whole yet. Its step should be the one open. Turns the demo off.
defaultClosedbooleanfalseStarts closed, as the button that brings the dialog back. Turns the demo off.
onChange(values: Record<string, string>) => void—Called with every value each time a field is saved.
onProceed(values: Record<string, string>) => void—Called with every value when Proceed is pressed with all required steps done.
onClose() => void—Called when the dialog closes, by Skip, the cross or Proceed.
autoplaybooleantrueWhile on screen and untouched, a ghost pointer fills in most of the first step, then opens the other two in turn.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

account-setup.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,} from 'react';import { FieldRow, type RowPart } from './field-row';import { FOLD, opennessAt, playFold, type Fold, type FoldPlayer, type Openness } from './fold';import { hintFor, isFilled, nextDraft } from './format';import { GhostPointer, runDemo } from './ghost-pointer';import { CloseIcon, InfoIcon, SetupIcon } from './icons';import { DEMO_TEXT, SECTIONS, type SetupField, type SetupSection } from './sections';import { useAutoplay } from './use-autoplay';import styles from './account-setup.module.css';/* * Account setup: a dialog of steps to fill in, each a section that folds open on a list of fields.
account-setup.module.css
/* Account setup (account-setup.tsx): a grey stage with the dialog in its middle, or at night a   near-black stage with the dialog in dark grey.   Colours are the tokens below, each light-dark(light, dark): the component takes the   color-scheme it inherits from the page (light unless the page says otherwise), or the one its   colorScheme prop sets (data-color-scheme).   Sizes are written as design pixels times --u, so the whole dialog scales from one value.   Memory. A phone keeps the page's pixels in tiles the width of the screen, about 7 MB each, and   keeps a second copy of any layer or tile painted again. So nothing the demo changes is painted   twice in a big picture (see Layers below). The stage is one layout boundary of a fixed size, so   a change inside it (a value typed, a panel joining the layout) lays it out alone. And no style   reaches an icon while the demo plays: Safari lays out each drawing a style change reaches, and   two at once lay out the whole page. So a hidden input goes with opacity and stacking, not with   visibility or inert, which its icons would inherit. */.root {  --u: 0.9px;  /* The stage and its edge; the dialog's blocks, the fields and the buttons on them. */  --surface: #c7c7c7;  --edge: rgb(0 0 0 / 0.06);  --surface-2: #fff;  /* Text: titles and values; labels, descriptions and icons; placeholders and the date's mask.
field-row.tsx
'use client';import { useId } from 'react';import { DATE_MASK, nextDraft } from './format';import { CheckIcon, CloseIcon } from './icons';import type { SetupField } from './sections';import styles from './account-setup.module.css';/* * One field of a step: an icon, a label and the value. Pressing the value (or anywhere on the row) * turns it into a bordered input around the same box, with a cross that clears it and a check that * saves it; Enter saves too and Escape cancels. The value and the input are both always there, the * input under the value until it opens, so opening a field changes no markup and no style reaches * its icons. A date shows what is left of its mask, faded, after the digits typed so far. */export type RowPart = 'value' | 'input' | 'confirm';export function FieldRow({  field,  value,  draft,  invalid,  demo,
fold.ts
import { spring } from './spring';/* * The fold: a section opening or closing, played with transforms rather than by animating heights. * * The dialog is a stack of white blocks: its header, each step's heading, the open step's panel, * the footer (rounded at the ends), and under the footer the dialog's shadow. While a fold plays, * every panel involved (the one opening, the one closing) is laid out at its full height, once. * Each panel then has an openness, 0 to 1, and the dialog shows only the open part of each: a * panel is clipped from the bottom to its open part, every block below it moves up by its closed * part, and since the dialog stays centred on the stage, every block also moves down by half the * closed parts. Every block keeps a layer of its own, so each frame only moves layers and resizes * a panel's clip; both are written in the same frame (an animation would run the moves on the * compositor and the clip on the page, a frame apart, and a seam would flicker between them). * Once it ends, the closed panels leave the layout and the blocks drop their moves: the same * picture, now laid out. * * The blocks are marked in the markup with `data-fold`, in order from the top; a panel in the * layout carries its section's id there (a panel out of it has no mark). Pieces that move with * the dialog's bottom edge, wherever they are in the markup, are marked `data-fold-end`. *//** How open each panel is, 0 to 1, by section id. */export type Openness = Record<string, number>;
format.ts
/* * What each kind of field accepts. A phone number and a date take digits only and lay them out as * they are typed: "(212) 555-0147", "14/03/1996". A field counts as filled once its value is * whole: ten digits, a real day, an address with an @ and a domain, or any text at all. */export type FieldKind = 'text' | 'phone' | 'date' | 'email';/** The date's mask: what is left of it shows, faded, after the digits typed so far. */export const DATE_MASK = 'dd/mm/yyyy';const MOST_DIGITS: Partial<Record<FieldKind, number>> = { phone: 10, date: 8 };const digitsOf = (text: string, most: number) => text.replace(/\D/g, '').slice(0, most);/** "(212) 555-0147", as far as the digits go. */function formatPhone(digits: string) {  if (!digits) return '';  if (digits.length <= 3) return `(${digits}`;  if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;  return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;}/** "14/03/1996", as far as the digits go. */
sections.tsx
import type { ReactNode } from 'react';import type { FieldKind } from './format';import {  AtIcon,  BriefcaseIcon,  BuildingIcon,  CakeIcon,  CogIcon,  GlobeIcon,  IdCardIcon,  LinkIcon,  MailIcon,  NoteIcon,  PhoneIcon,  PinIcon,  QuillIcon,  UserIcon,} from './icons';/* * The steps of the setup and their fields. Each step is a section that folds open; each field is * a row whose value turns into an input when pressed. */
icons.tsx
/* * The setup's icons: outlined, drawn on a 20 × 20 grid with round caps, in the current colour. * They are decorative; the controls that hold them carry the labels. */type IconProps = { className?: string };const outline = {  viewBox: '0 0 20 20',  fill: 'none',  stroke: 'currentColor',  strokeWidth: 1.5,  strokeLinecap: 'round' as const,  strokeLinejoin: 'round' as const,  'aria-hidden': true,};/** A person with a small cog: setting up an account. */export function SetupIcon({ className }: IconProps) {  return (    <svg className={className} {...outline}>      <circle cx="8.5" cy="6.3" r="3.2" />      <path d="M2.8 16.8c.4-3.2 2.8-5.3 5.7-5.3 1 0 1.9.2 2.7.7" />      <circle cx="14.8" cy="15.2" r="1.5" />
spring.ts
/* * A spring as a curve over time. A spring pulls a value to its target and lets it settle: fast at * first, then slowing down, overshooting a little and coming back if it is bouncy. The fold reads * the curve frame by frame. */export type Spring = {  /** How long it takes to settle, in ms. */  duration: number;  /** Where the curve is at `t`, from 0 to 1 of the duration. */  at: (t: number) => number;};/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring {  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.  const damping = 1 - bounce;  const omega = (2 * Math.PI * 1000) / response;  const position = (t: number) => {    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);    const wobble = omega * Math.sqrt(1 - damping * damping);
ghost-pointer.tsx
'use client';import type { Ref } from 'react';import styles from './ghost-pointer.module.css';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. The pointer takes the shape a real one would: an arrow, a hand over buttons, an * I-beam over text. Its shapes are drawn once, each in a small layer of its own, and swapped by * scaling those layers to nothing and back: a change of opacity or of a rule matched through the * pointer would reach the drawings, and Safari would lay out the whole page again. Stopping the * demo (the reader takes over, or it scrolls away) ends the step in progress at once: its promise * rejects, which ends the script right there, and the pointer fades. */export type Shape = 'arrow' | 'hand' | 'text';/** A point in the stage's own pixels. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;
ghost-pointer.module.css
/* The demo's pointer (ghost-pointer.tsx): an arrow, a hand over buttons, an I-beam over text. Its   tip sits at the point it is moved to. Each shape sits in a small layer of its own, inside a   layer that paints nothing and stays one between moves. */.ghost {  /* The arrow and the hand keep the system's look on both schemes, edged so they read on dark     and light alike; the I-beam, a bare stroke, turns light at night to show on the dark fields. */  --pointer: #1c1c1e;  --pointer-edge: #fff;  --hand-edge: #141414;  --beam: #141414;  position: absolute;  z-index: 40;  top: 0;  left: 0;  width: 0;  height: 0;  pointer-events: none;  opacity: 0;  transition: opacity 0.3s ease;  will-change: transform;}@supports (color: light-dark(#fff, #000)) {  .ghost {
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • Each step is a heading that holds a disclosure button with aria-expanded. Its panel is a region labelled by that button, and the badge is read as part of the button’s name.
  • Enter on a value opens its input. Enter or the check saves, Escape cancels, and focus returns to the value.
  • An input whose value is not whole is marked aria-invalid and points to a hint that says what to fix. Saves, completions, cancels and closing are announced in a polite live region.
  • Under reduced motion, steps open and close at once and the demo never plays.