# Signup Steps

> A sign-up that asks one question at a time, starts with real progress and never ticks consent for anyone, in a card, beside a rail of what is left, or as one large question per screen.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/signup-steps).

- Page, with a live preview: https://shadcn.exchange/r/miseui/signup-steps
- Screenshot: https://shadcn.exchange/shot/miseui/signup-steps/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/signup-steps.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/signup-steps.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/motion.json`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/checkbox.json`, `https://miseui.junsu4621.workers.dev/r/input.json`, `https://miseui.junsu4621.workers.dev/r/kbd.json`, `https://miseui.junsu4621.workers.dev/r/label.json`, `https://miseui.junsu4621.workers.dev/r/steps.json`

## Files

### `registry/miseui/blocks/signup-steps.tsx`

```tsx
import type * as React from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { SignupConversation } from "./signup-steps-conversation"
import {
  useSignupFlow,
  type SignupCreditedStep,
  type SignupQuestionStep,
  type SignupStep,
  type SignupValues,
} from "./signup-steps-flow"
import { Confirmation, StepProgress } from "./signup-steps-parts"
import { QuestionForm } from "./signup-steps-question"
import { SignupRail } from "./signup-steps-rail"

type SignupStepsVariant = "card" | "rail" | "conversation"

const EMAIL_SHAPE = /^\S+@\S+$/

const DEFAULT_STEPS: readonly SignupStep[] = [
  { id: "start", label: "Started", credited: true },
  {
    id: "email",
    label: "Email",
    question: "What is your work email?",
    aside: "This is where your sign-in link goes.",
    fieldLabel: "Work email",
    hint: "There is no password to remember.",
    type: "email",
    autoComplete: "email",
    placeholder: "maya@northgate.studio",
    validate: (value) => (EMAIL_SHAPE.test(value.trim()) ? undefined : "Enter an email address, like maya@northgate.studio."),
  },
  {
    id: "workspace",
    label: "Workspace",
    question: "What should we call your workspace?",
    aside: "Your team will see this name.",
    fieldLabel: "Workspace name",
    hint: "You can change it later.",
    autoComplete: "organization",
    placeholder: "Northgate Studio",
    consent: "I agree to the Terms of Service and Privacy Policy.",
  },
]

function defaultConfirmation(values: SignupValues) {
  return {
    title: "Check your inbox.",
    description: values.email ? `We sent a sign-in link to ${values.email}.` : "We sent you a sign-in link.",
  }
}

interface SignupStepsProps extends React.ComponentProps<"section"> {
  /** `card` one card with a bar on top, `rail` steps in a side rail with what is left, `conversation` one large question per screen. */
  variant?: SignupStepsVariant
  /** Mark a first step `credited` to start the bar with real progress on it. */
  steps?: readonly SignupStep[]
  /** Called once with every answer, by step id. Return a promise to get the loading state; a rejection's message is shown inline. */
  onComplete?: (values: SignupValues) => void | Promise<void>
  /** Label of the last step's button. */
  submitLabel?: string
  /** What the final screen says. */
  confirmation?: (values: SignupValues) => { title: string; description: string }
}

function SignupSteps({
  variant,
  steps = DEFAULT_STEPS,
  onComplete,
  submitLabel = "Create workspace",
  confirmation = defaultConfirmation,
  className,
  ...props
}: SignupStepsProps) {
  const flow = useSignupFlow(steps, onComplete)
  if (flow.questions.length === 0) return null

  const shared = { steps, flow, submitLabel, finished: flow.done ? confirmation(flow.values) : null, className }
  if (variant === "rail") return <SignupRail {...shared} {...props} />
  if (variant === "conversation") return <SignupConversation {...shared} {...props} />

  const credited = steps.length - flow.questions.length
  const completed = flow.done ? steps.length : credited + flow.index

  return (
    <section data-slot="signup-steps" data-variant="card" className={cn("@container w-full bg-muted/40 dark:bg-background px-4 py-12", className)} {...props}>
      <div className="mx-auto flex w-full max-w-lg flex-col gap-8 rounded-2xl border bg-popover p-6 shadow-[0_16px_40px_-24px_color-mix(in_oklab,var(--mise)_24%,oklch(0_0_0/40%))] @md:p-8">
        <StepProgress steps={steps} completed={completed} />
        {flow.done ? <Confirmation {...confirmation(flow.values)} /> : <QuestionForm flow={flow} submitLabel={submitLabel} />}
      </div>
    </section>
  )
}

export {
  SignupSteps,
  type SignupCreditedStep,
  type SignupQuestionStep,
  type SignupStep,
  type SignupStepsProps,
  type SignupStepsVariant,
  type SignupValues,
}
```

### `registry/miseui/blocks/signup-steps-conversation.tsx`

```tsx
import type * as React from "react"
import { useId } from "react"
import { ArrowLeft, Loader2 } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Input } from "@/registry/miseui/ui/input"
import { Kbd } from "@/registry/miseui/ui/kbd"
import { Label } from "@/registry/miseui/ui/label"
import { useFocusOnChange, type SignupFlow } from "./signup-steps-flow"
import { Confirmation, ConsentField, ProgressTrack } from "./signup-steps-parts"
import type { SignupVariantProps } from "./signup-steps-rail"

const STEP_TRAVEL_PX = 16

/** The answer just given, read back so this feels like being listened to. A password is never read back. */
function lastAnswer(flow: SignupFlow) {
  const previous = flow.questions[flow.index - 1]
  if (!previous || previous.type === "password") return null
  return flow.values[previous.id]?.trim() || null
}

/** Large type on one hairline: the question is the page, so the field should not look like a form to fill in. */
function LargeField({ flow, fieldRef }: { flow: SignupFlow; fieldRef: React.RefObject<HTMLInputElement | null> }) {
  const baseId = useId()
  const { step, error } = flow
  const fieldId = `${baseId}-field`
  const noteId = `${baseId}-note`
  const note = error ?? step.hint
  return (
    <div className="flex flex-col gap-6">
      <div className="flex flex-col gap-2">
        <Label htmlFor={fieldId} className="sr-only">
          {step.fieldLabel}
        </Label>
        <Input
          ref={fieldRef}
          id={fieldId}
          type={step.type ?? "text"}
          autoComplete={step.autoComplete}
          placeholder={step.placeholder}
          value={flow.values[step.id] ?? ""}
          readOnly={flow.pending}
          onChange={(event) => flow.change(event.target.value)}
          aria-invalid={error ? true : undefined}
          aria-describedby={note ? noteId : undefined}
          className="h-12 rounded-none border-0 border-b-2 bg-transparent px-0 text-xl shadow-none focus-visible:border-foreground focus-visible:ring-0 md:text-xl"
        />
        {note ? (
          <p
            id={noteId}
            role={error ? "alert" : undefined}
            className={cn("text-[13px] leading-snug text-pretty", error ? "text-destructive" : "text-muted-foreground")}
          >
            {note}
          </p>
        ) : null}
      </div>
      {step.consent ? <ConsentField flow={flow} text={step.consent} id={`${baseId}-consent`} /> : null}
    </div>
  )
}

function ConversationQuestion({ flow, submitLabel }: { flow: SignupFlow; submitLabel: string }) {
  const reduceMotion = useReducedMotion()
  const fieldRef = useFocusOnChange<HTMLInputElement>(flow.index)
  const isLast = flow.index === flow.questions.length - 1
  const answer = lastAnswer(flow)

  const submit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    flow.next()
  }

  return (
    <form noValidate onSubmit={submit} className="flex flex-col gap-8">
      <motion.div
        key={flow.step.id}
        className="flex flex-col gap-6"
        initial={reduceMotion ? false : { opacity: 0, y: flow.direction * STEP_TRAVEL_PX }}
        animate={{ opacity: 1, y: 0 }}
        transition={SPRING_DEFAULT}
      >
        {answer ? <p className="text-[15px] leading-relaxed break-words text-muted-foreground">Got it: {answer}</p> : null}
        <h2 className="text-display text-3xl leading-[1.12]! text-balance @2xl:text-4xl">
          {flow.step.question}
          {flow.step.aside ? <span className="text-muted-foreground"> {flow.step.aside}</span> : null}
        </h2>
        <LargeField flow={flow} fieldRef={fieldRef} />
      </motion.div>
      <div className="flex flex-wrap items-center gap-3">
        <Button type="submit" size="lg" disabled={flow.pending} aria-busy={flow.pending || undefined}>
          {flow.pending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
          {isLast ? submitLabel : "Continue"}
        </Button>
        {flow.index > 0 ? (
          <Button type="button" size="lg" variant="ghost" disabled={flow.pending} onClick={flow.back}>
            <ArrowLeft /> Back
          </Button>
        ) : null}
        <span className="hidden items-center gap-2 text-[13px] text-muted-foreground pointer-fine:flex">
          or press <Kbd>Enter</Kbd>
        </span>
      </div>
    </form>
  )
}

function SignupConversation({ steps, flow, submitLabel, finished, className, ...props }: SignupVariantProps) {
  const credited = steps.length - flow.questions.length
  const completed = flow.done ? steps.length : credited + flow.index
  return (
    <section data-slot="signup-steps" data-variant="conversation" className={cn("@container flex min-h-[560px] w-full flex-col bg-background", className)} {...props}>
      <ProgressTrack total={steps.length} completed={completed} className="rounded-none" />
      <div className="mx-auto flex w-full max-w-2xl min-w-0 flex-1 flex-col justify-center px-6 py-12">
        {finished ? <Confirmation {...finished} /> : <ConversationQuestion flow={flow} submitLabel={submitLabel} />}
      </div>
    </section>
  )
}

export { SignupConversation, lastAnswer }
```

### `registry/miseui/blocks/signup-steps-flow.ts`

```ts
import { useEffect, useRef, useState } from "react"

interface SignupCreditedStep {
  id: string
  /** Short name under the progress bar. */
  label: string
  /** Already counted as done when the flow opens. Use it only for something the person has really done, such as starting. */
  credited: true
}

interface SignupQuestionStep {
  id: string
  label: string
  credited?: false
  /** The one question on this screen. */
  question: string
  /** A second sentence in the same heading, in the muted tone: why it is being asked. */
  aside?: string
  fieldLabel: string
  hint?: string
  type?: "text" | "email" | "password" | "url" | "tel"
  autoComplete?: string
  placeholder?: string
  /** Return a message to hold the person here. By default the field only has to be filled in. */
  validate?: (value: string) => string | undefined
  /** A box that must be ticked to move on. It always starts unticked. */
  consent?: string
}

type SignupStep = SignupCreditedStep | SignupQuestionStep
type SignupValues = Readonly<Record<string, string>>

const REQUIRED_MESSAGE = "Fill this in to continue."
const CONSENT_MESSAGE = "Tick the box to continue."
const COMPLETE_FAILED = "Something went wrong. Try again."

function checkStep(step: SignupQuestionStep, value: string, consented: boolean) {
  const problem = step.validate ? step.validate(value) : value.trim() ? undefined : REQUIRED_MESSAGE
  return problem ?? (step.consent && !consented ? CONSENT_MESSAGE : undefined)
}

function isQuestion(step: SignupStep): step is SignupQuestionStep {
  return !step.credited
}

/** The last step's request: its loading state, the failure if any, and the switch to the final screen. */
function useCompletion(onComplete: ((values: SignupValues) => void | Promise<void>) | undefined, setError: (message: string) => void) {
  const [pending, setPending] = useState(false)
  const [done, setDone] = useState(false)

  const finish = async (values: SignupValues) => {
    setPending(true)
    try {
      await onComplete?.(values)
      setDone(true)
    } catch (failure) {
      setError(failure instanceof Error ? failure.message : COMPLETE_FAILED)
    } finally {
      setPending(false)
    }
  }

  return { pending, done, finish }
}

function useSignupFlow(steps: readonly SignupStep[], onComplete?: (values: SignupValues) => void | Promise<void>) {
  const questions = steps.filter(isQuestion)
  const [{ index, direction }, setPosition] = useState<{ index: number; direction: 1 | -1 }>({ index: 0, direction: 1 })
  const [values, setValues] = useState<SignupValues>({})
  const [consents, setConsents] = useState<Readonly<Record<string, boolean>>>({})
  const [error, setError] = useState<string>()
  const { pending, done, finish } = useCompletion(onComplete, setError)
  const step = questions[index]

  const next = () => {
    if (pending) return
    const problem = checkStep(step, values[step.id] ?? "", consents[step.id] ?? false)
    setError(problem)
    if (problem) return
    if (index === questions.length - 1) return void finish(values)
    setPosition({ index: index + 1, direction: 1 })
  }

  const back = () => {
    setError(undefined)
    setPosition({ index: index - 1, direction: -1 })
  }

  /** Back to an answered question, for a rail of steps. A later one would skip the work in between, so it is ignored. */
  const goTo = (target: number) => {
    if (pending || target >= index) return
    setError(undefined)
    setPosition({ index: Math.max(0, target), direction: -1 })
  }

  const change = (value: string) => {
    setError(undefined)
    setValues((current) => ({ ...current, [step.id]: value }))
  }

  const toggleConsent = (checked: boolean) => {
    setError(undefined)
    setConsents((current) => ({ ...current, [step.id]: checked }))
  }

  return { questions, step, index, direction, values, consents, error, pending, done, next, back, goTo, change, toggleConsent }
}

type SignupFlow = ReturnType<typeof useSignupFlow>

/** Moves focus on a step change only. On first paint it would scroll a page that has the block further down. */
function useFocusOnChange<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null)
  // The last key, not the first: going back to the first question has to move focus too, because its Back button is gone.
  const lastKey = useRef(key)
  useEffect(() => {
    if (key === lastKey.current) return
    lastKey.current = key
    ref.current?.focus()
  }, [key])
  return ref
}

export {
  useFocusOnChange,
  useSignupFlow,
  type SignupCreditedStep,
  type SignupFlow,
  type SignupQuestionStep,
  type SignupStep,
  type SignupValues,
}
```

### `registry/miseui/blocks/signup-steps-parts.tsx`

```tsx
import type * as React from "react"
import { useEffect, useId, useRef } from "react"
import { Check } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { cn } from "@/registry/miseui/lib/utils"
import { Checkbox } from "@/registry/miseui/ui/checkbox"
import { Input } from "@/registry/miseui/ui/input"
import { Label } from "@/registry/miseui/ui/label"
import type { SignupFlow, SignupStep } from "@/registry/miseui/blocks/signup-steps-flow"

const PERCENT = 100
const CONFIRMATION_START = { opacity: 0, scale: 0.6 }
const CONFIRMATION_END = { opacity: 1, scale: 1 }

interface StepProgressProps {
  steps: readonly SignupStep[]
  /** How many steps count as finished, credited ones included. */
  completed: number
}

function ProgressTrack({ total, completed, className }: { total: number; completed: number; className?: string }) {
  const reduceMotion = useReducedMotion()
  const percent = Math.round((completed / total) * PERCENT)
  return (
    <div
      role="progressbar"
      aria-label="Sign-up progress"
      aria-valuemin={0}
      aria-valuemax={PERCENT}
      aria-valuenow={percent}
      aria-valuetext={`Step ${Math.min(completed + 1, total)} of ${total}`}
      className={cn("h-1 overflow-hidden rounded-full bg-muted", className)}
    >
      {/* Sliding a full-width bar, not scaling it: scale would squash the rounded leading edge. */}
      <motion.div
        data-testid="progress-fill"
        data-reduced={reduceMotion ? "true" : "false"}
        className="h-full rounded-full bg-mise"
        initial={false}
        animate={{ x: `${percent - PERCENT}%` }}
        transition={reduceMotion ? { duration: 0 } : SPRING_DEFAULT}
      />
    </div>
  )
}

function StepProgress({ steps, completed }: StepProgressProps) {
  return (
    <div className="flex flex-col gap-2">
      <ProgressTrack total={steps.length} completed={completed} />
      <ol className="grid text-[13px]" style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}>
        {steps.map((step, index) => (
          <li
            key={step.id}
            aria-current={index === completed ? "step" : undefined}
            className={cn(
              "flex min-w-0 items-center gap-1",
              index === completed ? "font-medium text-foreground" : "text-muted-foreground",
            )}
          >
            {index < completed ? <Check aria-label="Done" className="size-3.5 shrink-0 text-foreground" /> : null}
            <span className="truncate">{step.label}</span>
          </li>
        ))}
      </ol>
    </div>
  )
}

interface QuestionFieldsProps {
  flow: SignupFlow
  fieldRef: React.RefObject<HTMLInputElement | null>
}

function QuestionFields({ flow, fieldRef }: QuestionFieldsProps) {
  const baseId = useId()
  const { step, error } = flow
  const fieldId = `${baseId}-field`
  const noteId = `${baseId}-note`
  const note = error ?? step.hint

  return (
    <div className="flex flex-col gap-6">
      <div className="flex flex-col gap-2">
        <Label htmlFor={fieldId}>{step.fieldLabel}</Label>
        <Input
          ref={fieldRef}
          id={fieldId}
          type={step.type ?? "text"}
          autoComplete={step.autoComplete}
          placeholder={step.placeholder}
          value={flow.values[step.id] ?? ""}
          readOnly={flow.pending}
          onChange={(event) => flow.change(event.target.value)}
          aria-invalid={error ? true : undefined}
          aria-describedby={note ? noteId : undefined}
        />
        {/* The error takes the hint's place, so the buttons below do not jump when it appears. */}
        {note ? (
          <p
            id={noteId}
            role={error ? "alert" : undefined}
            className={cn("text-[13px] leading-snug text-pretty", error ? "text-destructive" : "text-muted-foreground")}
          >
            {note}
          </p>
        ) : null}
      </div>
      {step.consent ? <ConsentField flow={flow} text={step.consent} id={`${baseId}-consent`} /> : null}
    </div>
  )
}

function ConsentField({ flow, text, id }: { flow: SignupFlow; text: string; id: string }) {
  return (
    <div className="flex items-start gap-3">
      <span className="flex h-5 items-center">
        <Checkbox
          id={id}
          checked={flow.consents[flow.step.id] ?? false}
          disabled={flow.pending}
          onCheckedChange={(checked) => flow.toggleConsent(checked === true)}
        />
      </span>
      <Label htmlFor={id} className="text-[13px] leading-5 font-normal">
        {text}
      </Label>
    </div>
  )
}

interface ConfirmationProps {
  title: string
  description: string
}

/** Takes focus so a screen reader hears the result instead of silence after the form disappears. */
function Confirmation({ title, description }: ConfirmationProps) {
  const reduceMotion = useReducedMotion()
  const headingRef = useRef<HTMLHeadingElement>(null)
  useEffect(() => headingRef.current?.focus(), [])
  return (
    <div className="flex flex-col items-start gap-6">
      <motion.span
        data-testid="confirmation-mark"
        className="grid size-12 place-items-center rounded-full bg-muted"
        initial={reduceMotion ? false : CONFIRMATION_START}
        animate={CONFIRMATION_END}
        transition={SPRING_DEFAULT}
      >
        <Check aria-hidden="true" className="size-6" />
      </motion.span>
      <h2 ref={headingRef} tabIndex={-1} className="text-display text-3xl leading-[1.12]! text-balance break-words outline-none">
        {title} <span className="text-muted-foreground">{description}</span>
      </h2>
    </div>
  )
}

export { Confirmation, ConsentField, ProgressTrack, QuestionFields, StepProgress }
```

### `registry/miseui/blocks/signup-steps-question.tsx`

```tsx
import type * as React from "react"
import { ArrowLeft, Loader2 } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { Button } from "@/registry/miseui/ui/button"
import { useFocusOnChange, type SignupFlow } from "./signup-steps-flow"
import { QuestionFields } from "./signup-steps-parts"

const STEP_TRAVEL_PX = 16

interface QuestionFormProps {
  flow: SignupFlow
  submitLabel: string
}

/** One form, so Enter advances from anywhere. Only the question itself moves; the buttons stay under the thumb. */
function QuestionForm({ flow, submitLabel }: QuestionFormProps) {
  const reduceMotion = useReducedMotion()
  const fieldRef = useFocusOnChange<HTMLInputElement>(flow.index)
  const isLast = flow.index === flow.questions.length - 1

  const submit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    flow.next()
  }

  return (
    <form noValidate onSubmit={submit} className="flex flex-col gap-8">
      <motion.div
        key={flow.step.id}
        className="flex flex-col gap-8"
        initial={reduceMotion ? false : { opacity: 0, x: flow.direction * STEP_TRAVEL_PX }}
        animate={{ opacity: 1, x: 0 }}
        transition={SPRING_DEFAULT}
      >
        <h2 className="text-display text-3xl leading-[1.12]! text-balance">
          {flow.step.question}
          {flow.step.aside ? <span className="text-muted-foreground"> {flow.step.aside}</span> : null}
        </h2>
        <QuestionFields flow={flow} fieldRef={fieldRef} />
      </motion.div>
      <div className="flex items-center gap-3">
        {flow.index > 0 ? (
          <Button type="button" variant="ghost" disabled={flow.pending} onClick={flow.back}>
            <ArrowLeft /> Back
          </Button>
        ) : null}
        <Button type="submit" disabled={flow.pending} aria-busy={flow.pending || undefined} className="ml-auto">
          {flow.pending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
          {isLast ? submitLabel : "Continue"}
        </Button>
      </div>
    </form>
  )
}

export { QuestionForm }
```

### `registry/miseui/blocks/signup-steps-rail.tsx`

```tsx
import type * as React from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { Steps, type StepItem } from "@/registry/miseui/ui/steps"
import type { SignupFlow, SignupStep } from "./signup-steps-flow"
import { Confirmation } from "./signup-steps-parts"
import { QuestionForm } from "./signup-steps-question"

interface SignupVariantProps extends React.ComponentProps<"section"> {
  steps: readonly SignupStep[]
  flow: SignupFlow
  submitLabel: string
  finished: { title: string; description: string } | null
}

/** The shared question form stays 36 px with a mouse. A checkbox is a button too, so this names the kit's buttons. */
const TOUCH_TARGETS_CLASS = "pointer-coarse:[&_[data-slot=button]]:h-10 pointer-coarse:[&_input]:h-10"

function questionsLeft(flow: SignupFlow) {
  if (flow.done) return "Nothing left."
  const left = flow.questions.length - flow.index
  return `${left} ${left === 1 ? "question" : "questions"} left.`
}

/** A step ahead says what it is for. The current one already says so in its heading, and a finished one is done. */
function railItems(steps: readonly SignupStep[], current: number): readonly StepItem[] {
  return steps.map((step, index) => ({
    title: step.label,
    description: index > current && !step.credited ? step.aside : undefined,
  }))
}

function SignupRail({ steps, flow, submitLabel, finished, className, ...props }: SignupVariantProps) {
  const credited = steps.length - flow.questions.length
  const current = flow.done ? steps.length : credited + flow.index
  const items = railItems(steps, current)
  // Credited steps were never questions. A press on one goes to the first question, and goTo clamps it there.
  const select = flow.done || flow.pending ? undefined : (index: number) => flow.goTo(index - credited)

  return (
    <section data-slot="signup-steps" data-variant="rail" className={cn("@container w-full bg-muted/40 dark:bg-background", className)} {...props}>
      <div className="px-4 py-12 @md:px-6 @2xl:py-16">
        <div className="mx-auto grid w-full max-w-4xl grid-cols-[minmax(0,1fr)] overflow-hidden rounded-2xl border bg-popover @3xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
          <aside data-testid="signup-rail" className="hidden flex-col gap-8 border-r bg-muted p-8 @3xl:flex dark:bg-card">
            <p className="text-display text-2xl leading-[1.12]! text-balance">
              {questionsLeft(flow)} {flow.done ? null : <span className="text-muted-foreground">Then your workspace is ready.</span>}
            </p>
            <Steps aria-label="Sign-up steps" variant="vertical" effect="draw" steps={items} current={current} onStepSelect={select} />
          </aside>
          <div className={cn("flex min-w-0 flex-col gap-8 p-6 @md:p-8 @3xl:min-h-[480px] @3xl:justify-center @3xl:p-12", TOUCH_TARGETS_CLASS)}>
            <div data-testid="signup-rail-compact" className="@3xl:hidden">
              <Steps aria-label="Sign-up progress" variant="compact" steps={items} current={current} />
            </div>
            {finished ? <Confirmation {...finished} /> : <QuestionForm flow={flow} submitLabel={submitLabel} />}
          </div>
        </div>
      </div>
    </section>
  )
}

export { SignupRail, type SignupVariantProps }
```
