# Signup Steps

A one-question-at-a-time signup flow for name, email, and password, with a progress indicator.

Signup Steps is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/signup-steps

**React**

**Example**

```tsx title="components/blocks/signup-steps.tsx"
"use client"

import { CheckIcon } from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

const EMAIL = /.+@.+\..+/
const UPPERCASE = /[A-Z]/
const LOWERCASE = /[a-z]/
const DIGIT = /\d/

const rules = [
  {
    label: "At least 8 characters",
    test: (value: string) => value.length >= 8,
  },
  {
    label: "Upper and lowercase letters",
    test: (value: string) => UPPERCASE.test(value) && LOWERCASE.test(value),
  },
  { label: "At least one number", test: (value: string) => DIGIT.test(value) },
]

const steps = [
  {
    key: "name",
    title: "What should we call you?",
    description: "Your name appears across suiss products and services.",
    placeholder: "Full name",
    type: "text",
    autoComplete: "name",
    valid: (value: string) => value.trim().length > 1,
  },
  {
    key: "email",
    title: "What's your email address?",
    description: "This will be your suiss Account.",
    placeholder: "name@example.com",
    type: "email",
    autoComplete: "email",
    valid: (value: string) => EMAIL.test(value),
  },
  {
    key: "password",
    title: "Create a password.",
    description: "A strong password keeps your account safe.",
    placeholder: "Password",
    type: "password",
    autoComplete: "new-password",
    valid: (value: string) =>
      rules.filter((rule) => rule.test(value)).length >= 3,
  },
] as const

type Values = Record<(typeof steps)[number]["key"], string>

export function SignupSteps() {
  const [index, setIndex] = React.useState(0)
  const [direction, setDirection] = React.useState(1)
  const [values, setValues] = React.useState<Values>({
    name: "",
    email: "",
    password: "",
  })
  const [done, setDone] = React.useState(false)
  const step = steps[index] ?? steps[0]
  const value = values[step.key]
  const ready = step.valid(value)

  const go = (delta: number) => {
    setDirection(delta)
    if (index + delta > steps.length - 1) return setDone(true)
    setIndex(Math.max(0, index + delta))
  }

  if (done) {
    return (
      <section className="flex min-h-140 items-center justify-center p-6">
        <div className="flex max-w-105 flex-col items-center gap-3.5 text-center transition-[opacity,translate] duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-4.5 starting:opacity-0 motion-reduce:transition-none">
          <span className="flex size-19 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_25%)] text-white dark:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]">
            <CheckIcon className="size-9.5" />
          </span>
          <h1 className="text-4xl font-semibold tracking-tight">
            Welcome, {values.name.split(" ")[0]}.
          </h1>
          <p className="text-lg text-muted-foreground">
            Your account is ready. Verify {values.email} to finish.
          </p>
          <button
            type="button"
            onClick={() => {
              setDone(false)
              setIndex(0)
            }}
            className="rounded-xs text-lg text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Start over ›
          </button>
        </div>
      </section>
    )
  }

  return (
    <section className="flex min-h-140 flex-col items-center px-6 py-9">
      <ol aria-label="Progress" className="flex gap-2">
        {steps.map((item, position) => (
          <li
            key={item.key}
            aria-current={position === index ? "step" : undefined}
            data-reached={position <= index ? "" : undefined}
            className="h-1.5 w-1.5 rounded-full bg-secondary transition-[width,background-color] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] data-reached:bg-primary aria-[current=step]:w-7 motion-reduce:transition-none"
          >
            <span className="sr-only">
              Step {position + 1}
              {position < index ? ", complete" : ""}
            </span>
          </li>
        ))}
      </ol>
      <div className="flex w-115 max-w-full flex-1 flex-col justify-center py-8">
        <form
          key={step.key}
          noValidate
          onSubmit={(event) => {
            event.preventDefault()
            if (ready) go(1)
          }}
          style={{ "--from": `${direction * 40}px` } as React.CSSProperties}
          className="flex flex-col gap-3.5 text-center transition-[opacity,translate] duration-[450ms,550ms] ease-[ease,cubic-bezier(0.32,0.72,0,1)] starting:translate-x-(--from) starting:opacity-0 rtl:starting:-translate-x-(--from) motion-reduce:transition-none"
        >
          <p className="text-sm font-semibold text-muted-foreground">
            Step {index + 1} of {steps.length}
          </p>
          <h1 className="text-4xl font-semibold tracking-tight text-balance md:text-5xl">
            {step.title}
          </h1>
          <p className="text-lg text-muted-foreground">{step.description}</p>
          <div className="mt-4 overflow-hidden rounded-xl border border-input bg-background has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50">
            <Input
              autoFocus={index > 0}
              type={step.type}
              value={value}
              onChange={(event) =>
                setValues((current) => ({
                  ...current,
                  [step.key]: event.target.value,
                }))
              }
              placeholder={step.placeholder}
              aria-label={step.placeholder}
              autoComplete={step.autoComplete}
              className="h-13 rounded-none bg-transparent px-4 text-lg focus-visible:border-transparent focus-visible:ring-0 aria-invalid:ring-0"
            />
          </div>
          {step.key === "password" && value && (
            <ul className="flex flex-col gap-1.5 text-start">
              {rules.map((rule) => {
                const met = rule.test(value)
                return (
                  <li
                    key={rule.label}
                    data-met={met ? "" : undefined}
                    className="group/rule flex items-center gap-2 text-sm text-muted-foreground data-met:text-foreground"
                  >
                    <span className="flex size-4 items-center justify-center rounded-full border-[1.5px] border-border text-white transition-[background-color,border-color] duration-250 group-data-met/rule:border-transparent group-data-met/rule:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_25%)] dark:group-data-met/rule:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]">
                      <CheckIcon
                        className="size-2.5 scale-0 transition-[scale] duration-250 group-data-met/rule:scale-100 motion-reduce:transition-none"
                      />
                    </span>
                    {rule.label}
                    <span className="sr-only">
                      {met ? "(met)" : "(not met)"}
                    </span>
                  </li>
                )
              })}
            </ul>
          )}
        </form>
      </div>
      <div className="flex justify-center gap-4">
        {index > 0 && (
          <Button size="lg" variant="outline" onClick={() => go(-1)}>
            Back
          </Button>
        )}
        <Button
          size="lg"
          disabled={!ready}
          onClick={() => go(1)}
          className="min-w-35"
        >
          {index === steps.length - 1 ? "Create account" : "Continue"}
        </Button>
      </div>
    </section>
  )
}
```

A signup flow that asks one question at a time, name, then email, then password, with progress dots above. Each step slides in from the direction of travel, Continue stays disabled until the answer is valid, and the last step becomes Create account. The finish screen greets the user by first name. Use it for onboarding where a long form would feel heavy.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/signup-steps
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks signup-steps
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Input](https://ui.suiss.app/docs/components/input).

3. Copy the source into `components/blocks/signup-steps.tsx`:

```tsx title="components/blocks/signup-steps.tsx"
"use client"

import { CheckIcon } from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

const EMAIL = /.+@.+\..+/
const UPPERCASE = /[A-Z]/
const LOWERCASE = /[a-z]/
const DIGIT = /\d/

const rules = [
  {
    label: "At least 8 characters",
    test: (value: string) => value.length >= 8,
  },
  {
    label: "Upper and lowercase letters",
    test: (value: string) => UPPERCASE.test(value) && LOWERCASE.test(value),
  },
  { label: "At least one number", test: (value: string) => DIGIT.test(value) },
]

const steps = [
  {
    key: "name",
    title: "What should we call you?",
    description: "Your name appears across suiss products and services.",
    placeholder: "Full name",
    type: "text",
    autoComplete: "name",
    valid: (value: string) => value.trim().length > 1,
  },
  {
    key: "email",
    title: "What's your email address?",
    description: "This will be your suiss Account.",
    placeholder: "name@example.com",
    type: "email",
    autoComplete: "email",
    valid: (value: string) => EMAIL.test(value),
  },
  {
    key: "password",
    title: "Create a password.",
    description: "A strong password keeps your account safe.",
    placeholder: "Password",
    type: "password",
    autoComplete: "new-password",
    valid: (value: string) =>
      rules.filter((rule) => rule.test(value)).length >= 3,
  },
] as const

type Values = Record<(typeof steps)[number]["key"], string>

export function SignupSteps() {
  const [index, setIndex] = React.useState(0)
  const [direction, setDirection] = React.useState(1)
  const [values, setValues] = React.useState<Values>({
    name: "",
    email: "",
    password: "",
  })
  const [done, setDone] = React.useState(false)
  const step = steps[index] ?? steps[0]
  const value = values[step.key]
  const ready = step.valid(value)

  const go = (delta: number) => {
    setDirection(delta)
    if (index + delta > steps.length - 1) return setDone(true)
    setIndex(Math.max(0, index + delta))
  }

  if (done) {
    return (
      <section className="flex min-h-140 items-center justify-center p-6">
        <div className="flex max-w-105 flex-col items-center gap-3.5 text-center transition-[opacity,translate] duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-4.5 starting:opacity-0 motion-reduce:transition-none">
          <span className="flex size-19 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_25%)] text-white dark:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]">
            <CheckIcon className="size-9.5" />
          </span>
          <h1 className="text-4xl font-semibold tracking-tight">
            Welcome, {values.name.split(" ")[0]}.
          </h1>
          <p className="text-lg text-muted-foreground">
            Your account is ready. Verify {values.email} to finish.
          </p>
          <button
            type="button"
            onClick={() => {
              setDone(false)
              setIndex(0)
            }}
            className="rounded-xs text-lg text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Start over ›
          </button>
        </div>
      </section>
    )
  }

  return (
    <section className="flex min-h-140 flex-col items-center px-6 py-9">
      <ol aria-label="Progress" className="flex gap-2">
        {steps.map((item, position) => (
          <li
            key={item.key}
            aria-current={position === index ? "step" : undefined}
            data-reached={position <= index ? "" : undefined}
            className="h-1.5 w-1.5 rounded-full bg-secondary transition-[width,background-color] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] data-reached:bg-primary aria-[current=step]:w-7 motion-reduce:transition-none"
          >
            <span className="sr-only">
              Step {position + 1}
              {position < index ? ", complete" : ""}
            </span>
          </li>
        ))}
      </ol>
      <div className="flex w-115 max-w-full flex-1 flex-col justify-center py-8">
        <form
          key={step.key}
          noValidate
          onSubmit={(event) => {
            event.preventDefault()
            if (ready) go(1)
          }}
          style={{ "--from": `${direction * 40}px` } as React.CSSProperties}
          className="flex flex-col gap-3.5 text-center transition-[opacity,translate] duration-[450ms,550ms] ease-[ease,cubic-bezier(0.32,0.72,0,1)] starting:translate-x-(--from) starting:opacity-0 rtl:starting:-translate-x-(--from) motion-reduce:transition-none"
        >
          <p className="text-sm font-semibold text-muted-foreground">
            Step {index + 1} of {steps.length}
          </p>
          <h1 className="text-4xl font-semibold tracking-tight text-balance md:text-5xl">
            {step.title}
          </h1>
          <p className="text-lg text-muted-foreground">{step.description}</p>
          <div className="mt-4 overflow-hidden rounded-xl border border-input bg-background has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50">
            <Input
              autoFocus={index > 0}
              type={step.type}
              value={value}
              onChange={(event) =>
                setValues((current) => ({
                  ...current,
                  [step.key]: event.target.value,
                }))
              }
              placeholder={step.placeholder}
              aria-label={step.placeholder}
              autoComplete={step.autoComplete}
              className="h-13 rounded-none bg-transparent px-4 text-lg focus-visible:border-transparent focus-visible:ring-0 aria-invalid:ring-0"
            />
          </div>
          {step.key === "password" && value && (
            <ul className="flex flex-col gap-1.5 text-start">
              {rules.map((rule) => {
                const met = rule.test(value)
                return (
                  <li
                    key={rule.label}
                    data-met={met ? "" : undefined}
                    className="group/rule flex items-center gap-2 text-sm text-muted-foreground data-met:text-foreground"
                  >
                    <span className="flex size-4 items-center justify-center rounded-full border-[1.5px] border-border text-white transition-[background-color,border-color] duration-250 group-data-met/rule:border-transparent group-data-met/rule:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_25%)] dark:group-data-met/rule:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]">
                      <CheckIcon
                        className="size-2.5 scale-0 transition-[scale] duration-250 group-data-met/rule:scale-100 motion-reduce:transition-none"
                      />
                    </span>
                    {rule.label}
                    <span className="sr-only">
                      {met ? "(met)" : "(not met)"}
                    </span>
                  </li>
                )
              })}
            </ul>
          )}
        </form>
      </div>
      <div className="flex justify-center gap-4">
        {index > 0 && (
          <Button size="lg" variant="outline" onClick={() => go(-1)}>
            Back
          </Button>
        )}
        <Button
          size="lg"
          disabled={!ready}
          onClick={() => go(1)}
          className="min-w-35"
        >
          {index === steps.length - 1 ? "Create account" : "Continue"}
        </Button>
      </div>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { SignupSteps } from "@/components/blocks/signup-steps"
```

```tsx
<SignupSteps />
```

Edit `steps`; each has a key, title, description, placeholder, input type, autocomplete value and a `valid` function. Add matching keys to the `Values` type when you add steps. Replace the final step with a call to your registration API.

## Notes

The progress dots are an ordered list with the current step marked, and each input after the first receives focus automatically.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-signup-steps--default.light.webp)

```tsx title="uniwind/components/blocks/signup-steps.tsx"
import { cn } from "cn"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, type TextInputProps, View } from "react-native"
import {
  FadeInDown,
  Keyframe,
  ReduceMotion,
  ZoomIn,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const EMAIL = /.+@.+\..+/
const UPPERCASE = /[A-Z]/
const LOWERCASE = /[a-z]/
const DIGIT = /\d/

const RULES = [
  {
    label: "At least 8 characters",
    test: (value: string) => value.length >= 8,
  },
  {
    label: "Upper and lowercase letters",
    test: (value: string) => UPPERCASE.test(value) && LOWERCASE.test(value),
  },
  { label: "At least one number", test: (value: string) => DIGIT.test(value) },
]

const STEPS: {
  key: "name" | "email" | "password"
  title: string
  description: string
  placeholder: string
  input: TextInputProps
  valid: (value: string) => boolean
}[] = [
  {
    key: "name",
    title: "What should we call you?",
    description: "Your name appears across suiss products and services.",
    placeholder: "Full name",
    input: {
      autoComplete: "name",
      textContentType: "name",
      autoCapitalize: "words",
    },
    valid: (value) => value.trim().length > 1,
  },
  {
    key: "email",
    title: "What's your email address?",
    description: "This will be your suiss Account.",
    placeholder: "name@example.com",
    input: {
      autoComplete: "email",
      textContentType: "emailAddress",
      keyboardType: "email-address",
      autoCapitalize: "none",
      autoCorrect: false,
    },
    valid: (value) => EMAIL.test(value),
  },
  {
    key: "password",
    title: "Create a password.",
    description: "A strong password keeps your account safe.",
    placeholder: "Password",
    input: {
      autoComplete: "new-password",
      textContentType: "newPassword",
      secureTextEntry: true,
    },
    valid: (value) => RULES.filter((rule) => rule.test(value)).length >= 3,
  },
]

const EASING_DURATION = 550

function slideFrom(offset: number) {
  return new Keyframe({
    0: { opacity: 0, transform: [{ translateX: offset }] },
    100: { opacity: 1, transform: [{ translateX: 0 }] },
  })
    .duration(EASING_DURATION)
    .reduceMotion(ReduceMotion.System)
}

const FROM_END = slideFrom(40)

const FROM_START = slideFrom(-40)

type Values = Record<(typeof STEPS)[number]["key"], string>

function SignupSteps({
  onSubmit,
}: {
  onSubmit?: (values: { name: string; email: string; password: string }) => void
}) {
  const [index, setIndex] = React.useState(0)
  const [direction, setDirection] = React.useState(1)
  const [values, setValues] = React.useState<Values>({
    name: "",
    email: "",
    password: "",
  })
  const [done, setDone] = React.useState(false)
  const step = STEPS[index] ?? STEPS[0]
  const value = values[step.key]
  const ready = step.valid(value)

  const go = (delta: number) => {
    setDirection(delta)
    if (index + delta > STEPS.length - 1) {
      onSubmit?.(values)
      return setDone(true)
    }
    setIndex(Math.max(0, index + delta))
  }

  if (done) {
    return (
      <Screen>
        <View className="flex-1 items-center justify-center p-6">
          <AnimatedView
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
            className="items-center gap-3.5"
          >
            <View className="size-19 items-center justify-center rounded-full bg-green-500">
              <Icon
                as={CheckIcon}
                weight="bold"
                className="size-9.5 text-primary-foreground"
              />
            </View>
            <Text role="heading" className="text-center text-3xl font-semibold">
              Welcome, {values.name.split(" ")[0]}.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              Your account is ready. Verify {values.email} to finish.
            </Text>
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => {
                setDone(false)
                setIndex(0)
              }}
              className="active:opacity-60"
            >
              <Text className="text-lg text-primary">Start over ›</Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <View
        accessible
        accessibilityLabel="Progress"
        accessibilityValue={{ text: `Step ${index + 1} of ${STEPS.length}` }}
        className="flex-row justify-center gap-2 pt-6"
      >
        {STEPS.map((item, position) => (
          <View
            key={item.key}
            className={cn(
              "h-1.5 rounded-full",
              position === index ? "w-7" : "w-1.5",
              position <= index ? "bg-primary" : "bg-secondary"
            )}
          />
        ))}
      </View>
      <KeyboardScrollView contentContainerClassName="grow justify-center px-6 py-8">
        <AnimatedView
          key={step.key}
          entering={direction > 0 ? FROM_END : FROM_START}
          className="gap-3.5"
        >
          <Text className="text-center text-sm font-semibold text-muted-foreground">
            Step {index + 1} of {STEPS.length}
          </Text>
          <Text role="heading" className="text-center text-3xl font-semibold">
            {step.title}
          </Text>
          <Text className="text-center text-lg text-muted-foreground">
            {step.description}
          </Text>
          <View className="mt-4 overflow-hidden rounded-xl border border-input bg-background">
            <Input
              {...step.input}
              autoFocus={index > 0}
              value={value}
              onChangeText={(next) =>
                setValues((current) => ({ ...current, [step.key]: next }))
              }
              placeholder={step.placeholder}
              accessibilityLabel={step.placeholder}
              returnKeyType={index === STEPS.length - 1 ? "done" : "next"}
              submitBehavior="submit"
              onSubmitEditing={() => {
                if (ready) go(1)
              }}
              className="h-13 rounded-none border-transparent bg-transparent px-4 text-[18px]"
            />
          </View>
          {step.key === "password" && value ? (
            <View className="gap-1.5">
              {RULES.map((rule) => {
                const met = rule.test(value)
                return (
                  <View
                    key={rule.label}
                    accessible
                    accessibilityLabel={`${rule.label} ${met ? "(met)" : "(not met)"}`}
                    className="flex-row items-center gap-2"
                  >
                    <View
                      className={cn(
                        "size-4 items-center justify-center rounded-full border-[1.5px]",
                        met
                          ? "border-transparent bg-green-500"
                          : "border-border"
                      )}
                    >
                      {met ? (
                        <AnimatedView
                          entering={ZoomIn.duration(250).reduceMotion(
                            ReduceMotion.System
                          )}
                        >
                          <Icon
                            as={CheckIcon}
                            weight="bold"
                            className="size-2.5 text-primary-foreground"
                          />
                        </AnimatedView>
                      ) : null}
                    </View>
                    <Text
                      className={cn(
                        "text-sm",
                        met ? "text-foreground" : "text-muted-foreground"
                      )}
                    >
                      {rule.label}
                    </Text>
                  </View>
                )
              })}
            </View>
          ) : null}
        </AnimatedView>
      </KeyboardScrollView>
      <BottomBar>
        <BottomBarContent className="flex-row justify-center gap-4">
          {index > 0 ? (
            <Button
              size="lg"
              variant="outline"
              className="flex-1"
              onPress={() => go(-1)}
            >
              <Text>Back</Text>
            </Button>
          ) : null}
          <Button
            size="lg"
            disabled={!ready}
            className="flex-1"
            onPress={() => go(1)}
          >
            <Text>
              {index === STEPS.length - 1 ? "Create account" : "Continue"}
            </Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { SignupSteps }
```

```swift title="swift/Blocks/SignupSteps.swift"
import PhosphorSwift
import SwiftUI

public struct SignupSteps: View {
  struct Step {
    let title: LocalizedStringKey
    let description: LocalizedStringKey
    let placeholder: Text
    let content: UITextContentType
    let keyboard: UIKeyboardType
    let secure: Bool
    let valid: (String) -> Bool
  }

  static var steps: [Step] {
    [
      Step(
        title: "What should we call you?",
        description: "Your name appears across suiss products and services.",
        placeholder: Text("Full name"),
        content: .name,
        keyboard: .default,
        secure: false,
        valid: { $0.trimmingCharacters(in: .whitespaces).count > 1 }
      ),
      Step(
        title: "What's your email address?",
        description: "This will be your suiss Account.",
        placeholder: Text(verbatim: "name@example.com"),
        content: .emailAddress,
        keyboard: .emailAddress,
        secure: false,
        valid: { $0.wholeMatch(of: /.+@.+\..+/) != nil }
      ),
      Step(
        title: "Create a password.",
        description: "A strong password keeps your account safe.",
        placeholder: Text("Password"),
        content: .newPassword,
        keyboard: .default,
        secure: true,
        valid: { value in SignupStepsRules.rules.allSatisfy { $0.1(value) } }
      ),
    ]
  }

  private var onSubmit: ((String, String, String) -> Void)?
  @State private var index: Int
  @State private var forward = true
  @State private var values: [String]
  @State private var done: Bool
  @FocusState private var focused: Bool
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(onSubmit: ((_ name: String, _ email: String, _ password: String) -> Void)? = nil) {
    self.init(index: 0, values: ["", "", ""])
    self.onSubmit = onSubmit
  }

  init(index: Int, values: [String], done: Bool = false) {
    _index = State(initialValue: index)
    _values = State(initialValue: values)
    _done = State(initialValue: done)
  }

  private var step: Step { Self.steps[index] }
  private var ready: Bool { step.valid(values[index]) }

  public var body: some View {
    Screen {
      if done {
        welcome(name: values[0].split(separator: " ").first.map(String.init) ?? "", email: values[1])
          .transition(.opacity.combined(with: .offset(y: 18)))
      } else {
        progress
          .padding(.top, 16)
        KeyboardScrollView {
          form
            .id(index)
            .transition(reduceMotion ? .opacity : .asymmetric(
              insertion: .opacity.combined(with: .offset(x: forward ? 40 : -40)),
              removal: .opacity
            ))
        }
        BottomBar(separator: false) {
          BottomBarContent {
            HStack(spacing: 12) {
              if index > 0 {
                Button("Back") { go(-1) }
                  .buttonStyle(.variant(.outline, size: .lg, block: true))
              }
              Button(index == Self.steps.count - 1 ? "Create account" : "Continue") { go(1) }
                .buttonStyle(.variant(.default, size: .lg, block: true))
                .disabled(!ready)
            }
          }
        }
      }
    }
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.55), value: index)
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.6), value: done)
  }

  private var progress: some View {
    HStack(spacing: 8) {
      ForEach(0..<Self.steps.count, id: \.self) { position in
        Capsule()
          .fill(position <= index ? EsuissColor.primary : EsuissColor.border)
          .frame(width: position == index ? 28 : 6, height: 6)
      }
    }
    .frame(maxWidth: .infinity)
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(Text("Progress"))
    .accessibilityValue(Text("Step \(index + 1) of \(Self.steps.count)"))
  }

  private var form: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
    return VStack(spacing: 14) {
      Text("Step \(index + 1) of \(Self.steps.count)")
        .scaledFont(.sm, weight: .semibold)
        .foregroundStyle(EsuissColor.mutedForeground)
      Text(step.title)
        .scaledTextStyle(.xl2, weight: .bold)
      Text(step.description)
        .scaledTextStyle(.lg)
        .foregroundStyle(EsuissColor.mutedForeground)
      Group {
        if step.secure {
          SecureField(text: $values[index], prompt: step.placeholder) { step.placeholder }
        } else {
          TextField(text: $values[index], prompt: step.placeholder) { step.placeholder }
            .keyboardType(step.keyboard)
            .textInputAutocapitalization(step.keyboard == .emailAddress ? .never : .words)
            .autocorrectionDisabled()
        }
      }
      .textContentType(step.content)
      .submitLabel(index == Self.steps.count - 1 ? .done : .next)
      .focused($focused)
      .onSubmit { if ready { go(1) } }
      .scaledFont(.lg)
      .foregroundStyle(EsuissColor.foreground)
      .tint(EsuissColor.primary)
      .multilineTextAlignment(.leading)
      .padding(.horizontal, 16)
      .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading)
      .background(EsuissColor.background, in: shape)
      .overlay {
        shape.strokeBorder(focused ? EsuissColor.ring : EsuissColor.input, lineWidth: 1)
      }
      .padding(.top, 16)
      if step.secure && !values[index].isEmpty {
        SignupStepsRules(password: values[index])
          .frame(maxWidth: .infinity, alignment: .leading)
      }
    }
    .multilineTextAlignment(.center)
    .fixedSize(horizontal: false, vertical: true)
    .frame(maxWidth: .infinity)
    .padding(.horizontal, 24)
    .padding(.vertical, 40)
  }

  private func welcome(name: String, email: String) -> some View {
    VStack(spacing: 14) {
      Ph.check.bold
        .frame(width: 38, height: 38)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 76, height: 76)
        .background(Palette.green500, in: Circle())
        .accessibilityHidden(true)
      Text("Welcome, \(name).")
        .scaledTextStyle(.xl2, weight: .bold)
      Text("Your account is ready. Verify \(email) to finish.")
        .scaledTextStyle(.lg)
        .foregroundStyle(EsuissColor.mutedForeground)
      Button("Start over ›") {
        done = false
        index = 0
      }
      .buttonStyle(SignupStepsLinkStyle())
    }
    .multilineTextAlignment(.center)
    .fixedSize(horizontal: false, vertical: true)
    .padding(24)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
  }

  private func go(_ delta: Int) {
    forward = delta > 0
    if index + delta > Self.steps.count - 1 {
      focused = false
      onSubmit?(values[0], values[1], values[2])
      done = true
      return
    }
    index = max(0, index + delta)
  }
}

private struct SignupStepsRules: View {
  let password: String

  static var rules: [(LocalizedStringKey, (String) -> Bool)] {
    [
      ("At least 8 characters", { $0.count >= 8 }),
      ("Upper and lowercase letters", { $0.contains(where: \.isUppercase) && $0.contains(where: \.isLowercase) }),
      ("At least one number", { $0.contains(where: \.isNumber) }),
    ]
  }

  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    VStack(alignment: .leading, spacing: 6) {
      ForEach(Array(Self.rules.enumerated()), id: \.offset) { _, rule in
        let met = rule.1(password)
        HStack(spacing: 8) {
          ZStack {
            Circle()
              .strokeBorder(met ? Color.clear : EsuissColor.border, lineWidth: 1.5)
              .background(met ? Palette.green500 : .clear, in: Circle())
            Ph.check.bold
              .frame(width: 10, height: 10)
              .foregroundStyle(EsuissColor.primaryForeground)
              .scaleEffect(met ? 1 : 0)
          }
          .frame(width: 16, height: 16)
          Text(rule.0)
            .scaledFont(.sm)
            .foregroundStyle(met ? EsuissColor.foreground : EsuissColor.mutedForeground)
        }
        .animation(reduceMotion ? nil : .spring(response: 0.25, dampingFraction: 0.6), value: met)
        .accessibilityElement(children: .combine)
        .accessibilityValue(met ? Text("Met") : Text("Not met"))
      }
    }
  }
}

private struct SignupStepsLinkStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .scaledFont(.lg)
      .foregroundStyle(EsuissColor.primary)
      .opacity(configuration.isPressed ? 0.8 : 1)
      .frame(minHeight: 32)
      .contentShape(Rectangle())
  }
}
```

```kt title="kotlin/blocks/SignupSteps.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.bold.Check
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.BottomBarContent
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.KeyboardScrollView
import com.esuiss.ui.components.PageIndicator
import com.esuiss.ui.components.PageIndicatorVariant
import com.esuiss.ui.components.Screen
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay

private val EmailPattern = Regex(".+@.+\\..+")

private val RevealEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private val PopEasing = CubicBezierEasing(0.3f, 1.25f, 0.5f, 1f)

private class StepRule(val label: String, val test: (String) -> Boolean)

private val StepRules = listOf(
    StepRule("At least 8 characters") { it.length >= 8 },
    StepRule("Upper and lowercase letters") { value -> value.any { it.isUpperCase() } && value.any { it.isLowerCase() } },
    StepRule("At least one number") { value -> value.any { it.isDigit() } },
)

private class SignupStep(
    val title: String,
    val description: String,
    val placeholder: String,
    val keyboardType: KeyboardType,
    val secret: Boolean,
    val valid: (String) -> Boolean,
)

private val Steps = listOf(
    SignupStep(
        "What should we call you?",
        "Your name appears across suiss products and services.",
        "Full name",
        KeyboardType.Text,
        false,
    ) { it.trim().length > 1 },
    SignupStep(
        "What's your email address?",
        "This will be your suiss Account.",
        "name@example.com",
        KeyboardType.Email,
        false,
    ) { EmailPattern.matches(it) },
    SignupStep(
        "Create a password.",
        "A strong password keeps your account safe.",
        "Password",
        KeyboardType.Password,
        true,
    ) { value -> StepRules.count { it.test(value) } >= 3 },
)

@Composable
fun SignupSteps(
    modifier: Modifier = Modifier,
    onSubmit: (name: String, email: String, password: String) -> Unit = { _, _, _ -> },
) {
    var index by rememberSaveable { mutableIntStateOf(0) }
    var direction by remember { mutableIntStateOf(1) }
    val values = remember { mutableStateListOf("", "", "") }
    var done by rememberSaveable { mutableStateOf(false) }
    val step = Steps[index]
    val ready = step.valid(values[index])

    fun go(delta: Int) {
        direction = delta
        if (index + delta > Steps.lastIndex) {
            onSubmit(values[0], values[1], values[2])
            done = true
            return
        }
        index = (index + delta).coerceAtLeast(0)
    }

    Screen(modifier) {
        if (done) {
            Success(
                title = "Welcome, ${values[0].trim().substringBefore(' ')}.",
                description = "Your account is ready. Verify ${values[1]} to finish.",
            ) {
                done = false
                index = 0
            }
            return@Screen
        }
        Column(Modifier.fillMaxSize()) {
            PageIndicator(
                count = Steps.size,
                index = index,
                modifier = Modifier
                    .align(Alignment.CenterHorizontally)
                    .padding(top = 16.dp),
                variant = PageIndicatorVariant.Accent,
                accessibilityLabel = "Progress",
            )
            BoxWithConstraints(Modifier.weight(1f)) {
                val height = maxHeight
                KeyboardScrollView(Modifier.fillMaxSize()) {
                    AnimatedContent(
                        targetState = index,
                        transitionSpec = {
                            (
                                fadeIn(tween(450)) +
                                    slideInHorizontally(tween(550, easing = RevealEasing)) { direction * it / 10 }
                                ) togetherWith fadeOut(tween(150))
                        },
                        label = "step",
                    ) { position ->
                        StepPage(
                            position = position,
                            value = values[position],
                            onValueChange = { values[position] = it },
                            onSubmit = { if (Steps[position].valid(values[position])) go(1) },
                            modifier = Modifier
                                .fillMaxWidth()
                                .heightIn(min = height)
                                .padding(horizontal = 24.dp, vertical = 32.dp),
                        )
                    }
                }
            }
            BottomBar {
                BottomBarContent {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(16.dp),
                    ) {
                        if (index > 0) {
                            Button("Back", { go(-1) }, modifier = Modifier.weight(1f), variant = ButtonVariant.Outline, size = ButtonSize.Lg, block = true)
                        }
                        Button(
                            if (index == Steps.lastIndex) "Create account" else "Continue",
                            { go(1) },
                            modifier = Modifier.weight(1f),
                            size = ButtonSize.Lg,
                            block = true,
                            enabled = ready,
                        )
                    }
                }
            }
        }
    }
}

@Composable
private fun StepPage(
    position: Int,
    value: String,
    onValueChange: (String) -> Unit,
    onSubmit: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val colors = EsuissTheme.colors
    val step = Steps[position]
    val focus = remember { FocusRequester() }
    LaunchedEffect(position) {
        if (position == 0) return@LaunchedEffect
        delay(300)
        runCatching { focus.requestFocus() }
    }
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        BasicText(
            "Step ${position + 1} of ${Steps.size}",
            style = Typography.sm.copy(color = colors.mutedForeground, fontWeight = FontWeight.SemiBold),
        )
        BasicText(
            step.title,
            modifier = Modifier.semantics { heading() },
            style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
        )
        BasicText(step.description, style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center))
        val shape = RoundedCornerShape(Radius.xl)
        val style = Typography.lg.copy(color = colors.foreground)
        BasicTextField(
            value = value,
            onValueChange = onValueChange,
            modifier = Modifier
                .padding(top = 16.dp)
                .fillMaxWidth()
                .height(52.dp)
                .clip(shape)
                .background(colors.background, shape)
                .border(1.dp, colors.input, shape)
                .focusRequester(focus)
                .semantics { contentDescription = step.placeholder },
            textStyle = style,
            singleLine = true,
            cursorBrush = SolidColor(colors.primary),
            visualTransformation = if (step.secret) PasswordVisualTransformation() else VisualTransformation.None,
            keyboardOptions = KeyboardOptions(
                keyboardType = step.keyboardType,
                imeAction = if (position == Steps.lastIndex) ImeAction.Done else ImeAction.Next,
            ),
            keyboardActions = KeyboardActions(onNext = { onSubmit() }, onDone = { onSubmit() }),
            decorationBox = { inner ->
                Box(Modifier.padding(horizontal = 16.dp), contentAlignment = Alignment.CenterStart) {
                    if (value.isEmpty()) BasicText(step.placeholder, style = style.copy(color = colors.mutedForeground), maxLines = 1)
                    inner()
                }
            },
        )
        if (step.secret && value.isNotEmpty()) {
            Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
                StepRules.forEach { RuleRow(it.label, it.test(value)) }
            }
        }
    }
}

@Composable
private fun RuleRow(label: String, met: Boolean) {
    val colors = EsuissTheme.colors
    val fill by animateColorAsState(if (met) Palette.green500 else Color.Transparent, tween(250), label = "fill")
    val edge by animateColorAsState(if (met) Color.Transparent else colors.border, tween(250), label = "edge")
    val scale by animateFloatAsState(if (met) 1f else 0f, tween(250, easing = PopEasing), label = "scale")
    Row(
        modifier = Modifier.semantics(mergeDescendants = true) { stateDescription = if (met) "Met" else "Not met" },
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(
            Modifier
                .size(16.dp)
                .background(fill, CircleShape)
                .border(1.5.dp, edge, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(
                PhosphorIcons.Bold.Check,
                contentDescription = null,
                modifier = Modifier.graphicsLayer {
                    scaleX = scale
                    scaleY = scale
                },
                size = 10.dp,
                tint = colors.primaryForeground,
            )
        }
        BasicText(label, style = Typography.sm.copy(color = if (met) colors.foreground else colors.mutedForeground))
    }
}

@Composable
private fun Success(title: String, description: String, onStartOver: () -> Unit) {
    val colors = EsuissTheme.colors
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(1000, easing = RevealEasing)) }
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp)
            .graphicsLayer {
                alpha = appear.value
                translationY = (1f - appear.value) * 18.dp.toPx()
            },
        verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Box(
            Modifier
                .size(76.dp)
                .background(Palette.green500, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(PhosphorIcons.Bold.Check, contentDescription = null, size = 38.dp, tint = colors.primaryForeground)
        }
        BasicText(
            title,
            modifier = Modifier.semantics { heading() },
            style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
        )
        BasicText(description, style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center))
        BasicText(
            "Start over ›",
            modifier = Modifier
                .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
                .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onStartOver),
            style = Typography.lg.copy(color = colors.primary),
        )
    }
}
```

A progress bar tracks the step, and each step slides in with a title, description and a single `Input`. The email step checks the format and the password step shows a live rule checklist. Back and Continue `Button`s sit in a `BottomBar`, and the last step creates the account and shows a confirmation.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/signup-steps
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add signup-steps
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add signup-steps --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add signup-steps --platform kotlin
```

The CLI also adds what it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/signup-steps.tsx`:

- `uniwind/components/blocks/signup-steps.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/signup-steps.tsx`:

```tsx title="nativewind/components/blocks/signup-steps.tsx"
import { cn } from "cn"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, type TextInputProps, View } from "react-native"
import {
  FadeInDown,
  Keyframe,
  ReduceMotion,
  ZoomIn,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const EMAIL = /.+@.+\..+/
const UPPERCASE = /[A-Z]/
const LOWERCASE = /[a-z]/
const DIGIT = /\d/

const RULES = [
  {
    label: "At least 8 characters",
    test: (value: string) => value.length >= 8,
  },
  {
    label: "Upper and lowercase letters",
    test: (value: string) => UPPERCASE.test(value) && LOWERCASE.test(value),
  },
  { label: "At least one number", test: (value: string) => DIGIT.test(value) },
]

const STEPS: {
  key: "name" | "email" | "password"
  title: string
  description: string
  placeholder: string
  input: TextInputProps
  valid: (value: string) => boolean
}[] = [
  {
    key: "name",
    title: "What should we call you?",
    description: "Your name appears across suiss products and services.",
    placeholder: "Full name",
    input: {
      autoComplete: "name",
      textContentType: "name",
      autoCapitalize: "words",
    },
    valid: (value) => value.trim().length > 1,
  },
  {
    key: "email",
    title: "What's your email address?",
    description: "This will be your suiss Account.",
    placeholder: "name@example.com",
    input: {
      autoComplete: "email",
      textContentType: "emailAddress",
      keyboardType: "email-address",
      autoCapitalize: "none",
      autoCorrect: false,
    },
    valid: (value) => EMAIL.test(value),
  },
  {
    key: "password",
    title: "Create a password.",
    description: "A strong password keeps your account safe.",
    placeholder: "Password",
    input: {
      autoComplete: "new-password",
      textContentType: "newPassword",
      secureTextEntry: true,
    },
    valid: (value) => RULES.filter((rule) => rule.test(value)).length >= 3,
  },
]

const EASING_DURATION = 550

function slideFrom(offset: number) {
  return new Keyframe({
    0: { opacity: 0, transform: [{ translateX: offset }] },
    100: { opacity: 1, transform: [{ translateX: 0 }] },
  })
    .duration(EASING_DURATION)
    .reduceMotion(ReduceMotion.System)
}

const FROM_END = slideFrom(40)

const FROM_START = slideFrom(-40)

type Values = Record<(typeof STEPS)[number]["key"], string>

function SignupSteps({
  onSubmit,
}: {
  onSubmit?: (values: { name: string; email: string; password: string }) => void
}) {
  const [index, setIndex] = React.useState(0)
  const [direction, setDirection] = React.useState(1)
  const [values, setValues] = React.useState<Values>({
    name: "",
    email: "",
    password: "",
  })
  const [done, setDone] = React.useState(false)
  const step = STEPS[index] ?? STEPS[0]
  const value = values[step.key]
  const ready = step.valid(value)

  const go = (delta: number) => {
    setDirection(delta)
    if (index + delta > STEPS.length - 1) {
      onSubmit?.(values)
      return setDone(true)
    }
    setIndex(Math.max(0, index + delta))
  }

  if (done) {
    return (
      <Screen>
        <View className="flex-1 items-center justify-center p-6">
          <AnimatedView
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
            className="items-center gap-3.5"
          >
            <View className="size-[4.75rem] items-center justify-center rounded-full bg-green-500">
              <Icon
                as={CheckIcon}
                weight="bold"
                className="size-[2.375rem] text-primary-foreground"
              />
            </View>
            <Text role="heading" className="text-center text-3xl font-semibold">
              Welcome, {values.name.split(" ")[0]}.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              Your account is ready. Verify {values.email} to finish.
            </Text>
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => {
                setDone(false)
                setIndex(0)
              }}
              className="active:opacity-60"
            >
              <Text className="text-lg text-primary">Start over ›</Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <View
        accessible
        accessibilityLabel="Progress"
        accessibilityValue={{ text: `Step ${index + 1} of ${STEPS.length}` }}
        className="flex-row justify-center gap-2 pt-6"
      >
        {STEPS.map((item, position) => (
          <View
            key={item.key}
            className={cn(
              "h-1.5 rounded-full",
              position === index ? "w-7" : "w-1.5",
              position <= index ? "bg-primary" : "bg-secondary"
            )}
          />
        ))}
      </View>
      <KeyboardScrollView contentContainerClassName="grow justify-center px-6 py-8">
        <AnimatedView
          key={step.key}
          entering={direction > 0 ? FROM_END : FROM_START}
          className="gap-3.5"
        >
          <Text className="text-center text-sm font-semibold text-muted-foreground">
            Step {index + 1} of {STEPS.length}
          </Text>
          <Text role="heading" className="text-center text-3xl font-semibold">
            {step.title}
          </Text>
          <Text className="text-center text-lg text-muted-foreground">
            {step.description}
          </Text>
          <View className="mt-4 overflow-hidden rounded-xl border border-input bg-background">
            <Input
              {...step.input}
              autoFocus={index > 0}
              value={value}
              onChangeText={(next) =>
                setValues((current) => ({ ...current, [step.key]: next }))
              }
              placeholder={step.placeholder}
              accessibilityLabel={step.placeholder}
              returnKeyType={index === STEPS.length - 1 ? "done" : "next"}
              submitBehavior="submit"
              onSubmitEditing={() => {
                if (ready) go(1)
              }}
              className="h-[3.25rem] rounded-none border-transparent bg-transparent px-4 text-[18px]"
            />
          </View>
          {step.key === "password" && value ? (
            <View className="gap-1.5">
              {RULES.map((rule) => {
                const met = rule.test(value)
                return (
                  <View
                    key={rule.label}
                    accessible
                    accessibilityLabel={`${rule.label} ${met ? "(met)" : "(not met)"}`}
                    className="flex-row items-center gap-2"
                  >
                    <View
                      className={cn(
                        "size-4 items-center justify-center rounded-full border-[1.5px]",
                        met
                          ? "border-transparent bg-green-500"
                          : "border-border"
                      )}
                    >
                      {met ? (
                        <AnimatedView
                          entering={ZoomIn.duration(250).reduceMotion(
                            ReduceMotion.System
                          )}
                        >
                          <Icon
                            as={CheckIcon}
                            weight="bold"
                            className="size-2.5 text-primary-foreground"
                          />
                        </AnimatedView>
                      ) : null}
                    </View>
                    <Text
                      className={cn(
                        "text-sm",
                        met ? "text-foreground" : "text-muted-foreground"
                      )}
                    >
                      {rule.label}
                    </Text>
                  </View>
                )
              })}
            </View>
          ) : null}
        </AnimatedView>
      </KeyboardScrollView>
      <BottomBar>
        <BottomBarContent className="flex-row justify-center gap-4">
          {index > 0 ? (
            <Button
              size="lg"
              variant="outline"
              className="flex-1"
              onPress={() => go(-1)}
            >
              <Text>Back</Text>
            </Button>
          ) : null}
          <Button
            size="lg"
            disabled={!ready}
            className="flex-1"
            onPress={() => go(1)}
          >
            <Text>
              {index === STEPS.length - 1 ? "Create account" : "Continue"}
            </Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { SignupSteps }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/signup-steps.tsx`:

```tsx title="stylesheet/components/blocks/signup-steps.tsx"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, StyleSheet, type TextInputProps, View } from "react-native"
import {
  FadeInDown,
  Keyframe,
  ReduceMotion,
  ZoomIn,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, palette, radius, useTheme } from "@/components/ui/theme"

const EMAIL = /.+@.+\..+/
const UPPERCASE = /[A-Z]/
const LOWERCASE = /[a-z]/
const DIGIT = /\d/

const RULES = [
  {
    label: "At least 8 characters",
    test: (value: string) => value.length >= 8,
  },
  {
    label: "Upper and lowercase letters",
    test: (value: string) => UPPERCASE.test(value) && LOWERCASE.test(value),
  },
  { label: "At least one number", test: (value: string) => DIGIT.test(value) },
]

const STEPS: {
  key: "name" | "email" | "password"
  title: string
  description: string
  placeholder: string
  input: TextInputProps
  valid: (value: string) => boolean
}[] = [
  {
    key: "name",
    title: "What should we call you?",
    description: "Your name appears across suiss products and services.",
    placeholder: "Full name",
    input: {
      autoComplete: "name",
      textContentType: "name",
      autoCapitalize: "words",
    },
    valid: (value) => value.trim().length > 1,
  },
  {
    key: "email",
    title: "What's your email address?",
    description: "This will be your suiss Account.",
    placeholder: "name@example.com",
    input: {
      autoComplete: "email",
      textContentType: "emailAddress",
      keyboardType: "email-address",
      autoCapitalize: "none",
      autoCorrect: false,
    },
    valid: (value) => EMAIL.test(value),
  },
  {
    key: "password",
    title: "Create a password.",
    description: "A strong password keeps your account safe.",
    placeholder: "Password",
    input: {
      autoComplete: "new-password",
      textContentType: "newPassword",
      secureTextEntry: true,
    },
    valid: (value) => RULES.filter((rule) => rule.test(value)).length >= 3,
  },
]

const EASING_DURATION = 550

function slideFrom(offset: number) {
  return new Keyframe({
    0: { opacity: 0, transform: [{ translateX: offset }] },
    100: { opacity: 1, transform: [{ translateX: 0 }] },
  })
    .duration(EASING_DURATION)
    .reduceMotion(ReduceMotion.System)
}

const FROM_END = slideFrom(40)

const FROM_START = slideFrom(-40)

const styles = StyleSheet.create({
  center: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    padding: 24,
  },
  done: { alignItems: "center", gap: 14 },
  badge: {
    width: 76,
    height: 76,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  heading: { ...fontSize["3xl"], textAlign: "center", fontWeight: "600" },
  progress: {
    flexDirection: "row",
    justifyContent: "center",
    gap: 8,
    paddingTop: 24,
  },
  dot: { height: 6, borderRadius: radius.full },
  scroll: {
    flexGrow: 1,
    justifyContent: "center",
    paddingHorizontal: 24,
    paddingVertical: 32,
  },
  step: { gap: 14 },
  field: {
    marginTop: 16,
    overflow: "hidden",
    borderRadius: radius.xl,
    borderWidth: 1,
  },
  input: {
    fontSize: fontSize.lg.fontSize,
    height: 52,
    borderRadius: 0,
    borderColor: "transparent",
    backgroundColor: "transparent",
    paddingHorizontal: 16,
  },
  rules: { gap: 6 },
  rule: { flexDirection: "row", alignItems: "center", gap: 8 },
  ruleMark: {
    width: 16,
    height: 16,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
    borderWidth: 1.5,
  },
  bar: { flexDirection: "row", justifyContent: "center", gap: 16 },
})

type Values = Record<(typeof STEPS)[number]["key"], string>

function SignupSteps({
  onSubmit,
}: {
  onSubmit?: (values: { name: string; email: string; password: string }) => void
}) {
  const { colors } = useTheme()
  const [index, setIndex] = React.useState(0)
  const [direction, setDirection] = React.useState(1)
  const [values, setValues] = React.useState<Values>({
    name: "",
    email: "",
    password: "",
  })
  const [done, setDone] = React.useState(false)
  const step = STEPS[index] ?? STEPS[0]
  const value = values[step.key]
  const ready = step.valid(value)

  const go = (delta: number) => {
    setDirection(delta)
    if (index + delta > STEPS.length - 1) {
      onSubmit?.(values)
      return setDone(true)
    }
    setIndex(Math.max(0, index + delta))
  }

  const secondary = { ...fontSize.lg, textAlign: "center" as const }

  if (done) {
    return (
      <Screen>
        <View style={styles.center}>
          <AnimatedView
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
            style={styles.done}
          >
            <View style={[styles.badge, { backgroundColor: palette.green500 }]}>
              <Icon
                as={CheckIcon}
                weight="bold"
                size={38}
                color={colors.primaryForeground}
              />
            </View>
            <Text role="heading" style={styles.heading}>
              Welcome, {values.name.split(" ")[0]}.
            </Text>
            <Text style={[secondary, { color: colors.mutedForeground }]}>
              Your account is ready. Verify {values.email} to finish.
            </Text>
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => {
                setDone(false)
                setIndex(0)
              }}
              style={({ pressed }) => pressed && { opacity: 0.6 }}
            >
              <Text style={{ ...fontSize.lg, color: colors.primary }}>
                Start over ›
              </Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <View
        accessible
        accessibilityLabel="Progress"
        accessibilityValue={{ text: `Step ${index + 1} of ${STEPS.length}` }}
        style={styles.progress}
      >
        {STEPS.map((item, position) => (
          <View
            key={item.key}
            style={[
              styles.dot,
              {
                width: position === index ? 28 : 6,
                backgroundColor:
                  position <= index ? colors.primary : colors.secondary,
              },
            ]}
          />
        ))}
      </View>
      <KeyboardScrollView contentContainerStyle={styles.scroll}>
        <AnimatedView
          key={step.key}
          entering={direction > 0 ? FROM_END : FROM_START}
          style={styles.step}
        >
          <Text
            style={{
              ...fontSize.sm,
              textAlign: "center",
              fontWeight: "600",
              color: colors.mutedForeground,
            }}
          >
            Step {index + 1} of {STEPS.length}
          </Text>
          <Text role="heading" style={styles.heading}>
            {step.title}
          </Text>
          <Text style={[secondary, { color: colors.mutedForeground }]}>
            {step.description}
          </Text>
          <View
            style={[
              styles.field,
              {
                borderColor: colors.input,
                backgroundColor: colors.background,
              },
            ]}
          >
            <Input
              {...step.input}
              autoFocus={index > 0}
              value={value}
              onChangeText={(next) =>
                setValues((current) => ({ ...current, [step.key]: next }))
              }
              placeholder={step.placeholder}
              accessibilityLabel={step.placeholder}
              returnKeyType={index === STEPS.length - 1 ? "done" : "next"}
              submitBehavior="submit"
              onSubmitEditing={() => {
                if (ready) go(1)
              }}
              style={styles.input}
            />
          </View>
          {step.key === "password" && value ? (
            <View style={styles.rules}>
              {RULES.map((rule) => {
                const met = rule.test(value)
                return (
                  <View
                    key={rule.label}
                    accessible
                    accessibilityLabel={`${rule.label} ${met ? "(met)" : "(not met)"}`}
                    style={styles.rule}
                  >
                    <View
                      style={[
                        styles.ruleMark,
                        met
                          ? {
                              borderColor: "transparent",
                              backgroundColor: palette.green500,
                            }
                          : { borderColor: colors.border },
                      ]}
                    >
                      {met ? (
                        <AnimatedView
                          entering={ZoomIn.duration(250).reduceMotion(
                            ReduceMotion.System
                          )}
                        >
                          <Icon
                            as={CheckIcon}
                            weight="bold"
                            size={10}
                            color={colors.primaryForeground}
                          />
                        </AnimatedView>
                      ) : null}
                    </View>
                    <Text
                      style={{
                        ...fontSize.sm,
                        color: met ? colors.foreground : colors.mutedForeground,
                      }}
                    >
                      {rule.label}
                    </Text>
                  </View>
                )
              })}
            </View>
          ) : null}
        </AnimatedView>
      </KeyboardScrollView>
      <BottomBar>
        <BottomBarContent style={styles.bar}>
          {index > 0 ? (
            <Button
              size="lg"
              variant="outline"
              style={{ flex: 1 }}
              onPress={() => go(-1)}
            >
              <Text>Back</Text>
            </Button>
          ) : null}
          <Button
            size="lg"
            disabled={!ready}
            style={{ flex: 1 }}
            onPress={() => go(1)}
          >
            <Text>
              {index === STEPS.length - 1 ? "Create account" : "Continue"}
            </Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { SignupSteps }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/SignupSteps.swift`: shown above

- `swift/Components/BottomBar.swift`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/SignupSteps.kt`: shown above

- `kotlin/components/BottomBar.kt`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Carousel.kt`: see [Carousel](https://ui.suiss.app/docs/components/carousel.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/KeyboardScrollView.kt`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `kotlin/components/PageIndicator.kt`: see [Page Indicator](https://ui.suiss.app/docs/components/page-indicator.md)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { SignupSteps } from "@/components/blocks/signup-steps"
```

```tsx
<SignupSteps />
```

Edit the `STEPS` copy and `RULES` to fit your product. `onSubmit` receives the name, email and password when the last step is completed.

