Advanced conceptsJump to steps

Advanced concepts

Jump to steps

Learn how to jump to specific steps in a multi-step form.


Initial steps

We'll show you how to jump to specific steps in a multi-step form. Specifically, we'll recreate the multi-step form you can see here.

We'll start by cloning the following GitHub repository so we don't have to start from scratch.

git clone https://github.com/martiserra99/formity-jump-to-steps

Make sure you run the following command to install all the dependencies.

npm install

The project includes multiple form steps and a review step. We'll implement the logic to track completed steps, highlight the current step in the sidebar, and jump between steps.

First, we'll create the logic to determine the current step and how many steps are completed. This data will be shown in the sidebar and used to control which steps can be jumped to.

We'll get the current step by making each form element return its position, and the number of completed steps by checking which steps pass validation.

To implement this logic we'll need to update the following files.

app/index.tsx:

// app/index.tsx
import type { OnReturn } from "@formity/react";

import { useFormity } from "@formity/react";
import { useState, useCallback, useMemo } from "react";

import type { Status, FormStatus } from "@/types/status";
import type { FormStep } from "@/types/steps";

import { Sidebar } from "../components/sidebar";
import { Submitted } from "../components/submitted";

import { steps, flow, inputs, type Schema } from "./flow";

export default function App() {
  const [status, setStatus] = useState<Status>({
    type: "form",
    submitting: false,
  });

  const onReturn = useCallback<OnReturn<Schema>>(async (output) => {
    setStatus({ type: "form", submitting: true });

    // Show output in the console
    console.log(output);

    // Simulate a network request
    await new Promise((resolve) => setTimeout(resolve, 2000));

    setStatus({ type: "submitted" });
  }, []);

  if (status.type === "submitted") {
    return (
      <Submitted
        onStart={() => setStatus({ type: "form", submitting: false })}
      />
    );
  }

  return <Formity status={status} onReturn={onReturn} />;
}

interface FormityProps {
  status: FormStatus;
  onReturn: OnReturn<Schema>;
}

function Formity({ status, onReturn }: FormityProps) {
  const [values, setValues] = useState(inputs);

  const { step: currentStep, form } = useFormity({
    flow,
    inputs,
    params: { status, setValues },
    history: false,
    onReturn,
  });

  const completedSteps = useMemo(() => {
    for (let i = 0; i < steps.length - 1; i++) {
      const step = steps[i] as FormStep;
      if (!step.zod.safeParse(values).success) {
        return i;
      }
    }
    return steps.length - 1;
  }, [values]);

  return (
    <div className="color-scheme-dark flex min-h-svh">
      <Sidebar
        steps={steps}
        currentStep={currentStep}
        completedSteps={completedSteps}
        onJump={() => {}}
      />
      <main className="flex flex-1 items-start justify-center overflow-y-auto px-16 py-20">
        {form}
      </main>
    </div>
  );
}

app/flow.tsx:

// app/flow.tsx
import type { UnionToIntersection } from "type-fest";
import type { s, Flow } from "@formity/react";

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

import type { Steps, FormStep, ReviewStep } from "@/types/steps";
import type { FormStatus } from "@/types/status";

import { Form } from "@/components/form";
import { Review } from "@/components/review";

import * as constants from "@/constants";
import * as format from "@/utils/format";

type Values = UnionToIntersection<Fields[keyof Fields]>;

type Fields = {
  personal: {
    name: string;
    surname: string;
    gender: string;
    bio: string;
  };
  location: {
    streetAddress: string;
    apartment: string;
    city: string;
    state: string;
    postalCode: string;
    country: string;
  };
  security: {
    username: string;
    password: string;
    confirmPassword: string;
  };
  preferences: {
    language: string;
    timezone: string;
    emailNotifications: boolean;
    marketingEmails: boolean;
    weeklyNewsletter: boolean;
  };
};

const personal: FormStep<Fields["personal"]> = {
  id: "personal",
  label: "Personal",
  subtitle: "Basic information",
  zod: z.object({
    name: z.string().nonempty("Required"),
    surname: z.string().nonempty("Required"),
    gender: z.string().nonempty("Required"),
    bio: z.string(),
  }),
};

const location: FormStep<Fields["location"]> = {
  id: "location",
  label: "Location",
  subtitle: "Where you are",
  zod: z.object({
    streetAddress: z.string().nonempty("Required"),
    apartment: z.string(),
    city: z.string().nonempty("Required"),
    state: z.string().nonempty("Required"),
    postalCode: z.string().nonempty("Required"),
    country: z.string().nonempty("Required"),
  }),
};

const security: FormStep<Fields["security"]> = {
  id: "security",
  label: "Security",
  subtitle: "Login credentials",
  zod: z
    .object({
      username: z
        .string()
        .nonempty("Required")
        .regex(
          /^[a-zA-Z0-9_.]+$/,
          "Letters, numbers, underscores and dots only",
        ),
      password: z.string().nonempty("Required").min(8, "Min. 8 characters"),
      confirmPassword: z.string().nonempty("Required"),
    })
    .superRefine(({ password, confirmPassword }, ctx) => {
      if (confirmPassword !== password) {
        ctx.addIssue({
          code: "custom",
          message: "Passwords do not match",
          path: ["confirmPassword"],
        });
      }
    }),
};

const preferences: FormStep<Fields["preferences"]> = {
  id: "preferences",
  label: "Preferences",
  subtitle: "Customize your setup",
  zod: z.object({
    language: z.string().nonempty("Required"),
    timezone: z.string().nonempty("Required"),
    emailNotifications: z.boolean(),
    marketingEmails: z.boolean(),
    weeklyNewsletter: z.boolean(),
  }),
};

const review: ReviewStep = {
  id: "review",
  label: "Review",
  subtitle: "Confirm & submit",
};

export const steps: Steps = [personal, location, security, preferences, review];

export type Schema = {
  render: { step: number; form: React.ReactNode };
  struct: [
    s.Form<Fields["personal"]>,
    s.Form<Fields["location"]>,
    s.Form<Fields["security"]>,
    s.Form<Fields["preferences"]>,
    s.Form<Record<never, never>>,
    s.Return<Values>,
  ];
  inputs: Values;
  params: {
    status: FormStatus;
    setValues: (values: Values) => void;
  };
};

export const flow: Flow<Schema> = [
  {
    form: {
      fields: (values) => ({
        name: [values.name, []],
        surname: [values.surname, []],
        gender: [values.gender, []],
        bio: [values.bio, []],
      }),
      render: ({ fields, values, params, onNext }) => ({
        step: 0,
        form: (
          <Form
            key={personal.id}
            defaultValues={fields}
            resolver={zodResolver(personal.zod)}
            position="01"
            heading="Personal Info"
            message="Let's start with the basics. Your information is kept private."
            content={[
              {
                type: "columns",
                columns: [
                  {
                    type: "input",
                    name: "name",
                    label: "Name",
                    placeholder: "Jane",
                  },
                  {
                    type: "input",
                    name: "surname",
                    label: "Surname",
                    placeholder: "Smith",
                  },
                ],
              },
              {
                type: "select",
                name: "gender",
                label: "Gender",
                placeholder: "Select your gender",
                options: constants.genders,
              },
              {
                type: "textarea",
                name: "bio",
                label: "Short Bio",
                placeholder: "Tell us a little about yourself...",
                optional: true,
              },
            ]}
            buttons={{ next: "Continue", back: null }}
            onNext={onNext}
            values={values}
            setValues={params.setValues}
          />
        ),
      }),
    },
  },
  {
    form: {
      fields: (values) => ({
        streetAddress: [values.streetAddress, []],
        apartment: [values.apartment, []],
        city: [values.city, []],
        state: [values.state, []],
        postalCode: [values.postalCode, []],
        country: [values.country, []],
      }),
      render: ({ fields, values, params, onNext }) => ({
        step: 1,
        form: (
          <Form
            key={location.id}
            defaultValues={fields}
            resolver={zodResolver(location.zod)}
            position="02"
            heading="Location"
            message="Where are you based? Used to personalise your experience."
            content={[
              {
                type: "input",
                name: "streetAddress",
                label: "Street Address",
                placeholder: "123 Main Street",
              },
              {
                type: "input",
                name: "apartment",
                label: "Apartment / Suite",
                placeholder: "Apt 4B",
                optional: true,
              },
              {
                type: "columns",
                columns: [
                  {
                    type: "input",
                    name: "city",
                    label: "City",
                    placeholder: "New York",
                  },
                  {
                    type: "input",
                    name: "state",
                    label: "State / Province",
                    placeholder: "NY",
                  },
                ],
              },
              {
                type: "columns",
                columns: [
                  {
                    type: "input",
                    name: "postalCode",
                    label: "Postal Code",
                    placeholder: "10001",
                  },
                  {
                    type: "select",
                    name: "country",
                    label: "Country",
                    placeholder: "Select a country",
                    options: constants.countries,
                  },
                ],
              },
            ]}
            buttons={{ next: "Continue", back: "Back" }}
            onNext={onNext}
            values={values}
            setValues={params.setValues}
          />
        ),
      }),
    },
  },
  {
    form: {
      fields: (values) => ({
        username: [values.username, []],
        password: [values.password, []],
        confirmPassword: [values.confirmPassword, []],
      }),
      render: ({ fields, values, params, onNext }) => ({
        step: 2,
        form: (
          <Form
            key={security.id}
            defaultValues={fields}
            resolver={zodResolver(security.zod)}
            position="03"
            heading="Security"
            message="Create your login credentials. Use a strong and unique password."
            content={[
              {
                type: "input",
                name: "username",
                label: "Username",
                placeholder: "jane_smith",
              },
              {
                type: "divider",
                text: "Credentials",
              },
              {
                type: "password",
                name: "password",
                label: "Password",
                placeholder: "Min. 8 characters",
              },
              {
                type: "password",
                name: "confirmPassword",
                label: "Confirm Password",
                placeholder: "Repeat your password",
              },
            ]}
            buttons={{ next: "Continue", back: "Back" }}
            onNext={onNext}
            values={values}
            setValues={params.setValues}
          />
        ),
      }),
    },
  },
  {
    form: {
      fields: (values) => ({
        language: [values.language, []],
        timezone: [values.timezone, []],
        emailNotifications: [values.emailNotifications, []],
        marketingEmails: [values.marketingEmails, []],
        weeklyNewsletter: [values.weeklyNewsletter, []],
      }),
      render: ({ fields, values, params, onNext }) => ({
        step: 3,
        form: (
          <Form
            key={preferences.id}
            defaultValues={fields}
            resolver={zodResolver(preferences.zod)}
            position="04"
            heading="Preferences"
            message="Customise your experience. All of these can be changed later."
            content={[
              {
                type: "select",
                name: "language",
                label: "Language",
                placeholder: "Select language",
                options: constants.languages,
              },
              {
                type: "select",
                name: "timezone",
                label: "Timezone",
                placeholder: "Select timezone",
                options: constants.timezones,
              },
              {
                type: "switch",
                name: "emailNotifications",
                label: "Email Notifications",
                description: "Security alerts and account updates",
              },
              {
                type: "switch",
                name: "marketingEmails",
                label: "Marketing Emails",
                description: "Product announcements and special offers",
              },
              {
                type: "switch",
                name: "weeklyNewsletter",
                label: "Weekly Newsletter",
                description: "Tips, guides and community highlights",
              },
            ]}
            buttons={{ next: "Continue", back: "Back" }}
            onNext={onNext}
            values={values}
            setValues={params.setValues}
          />
        ),
      }),
    },
  },
  {
    form: {
      fields: () => ({}),
      render: ({ values, params, onNext }) => ({
        step: 4,
        form: (
          <Review
            key={review.id}
            position="05"
            heading="Review"
            message="Almost there. Double-check your details before submitting."
            content={[
              {
                text: "Personal",
                rows: [
                  {
                    label: "Full Name",
                    value: format.fullName(values.name, values.surname),
                  },
                  {
                    label: "Gender",
                    value: format.gender(values.gender),
                  },
                  {
                    label: "Bio",
                    value: format.bio(values.bio),
                  },
                ],
              },
              {
                text: "Location",
                rows: [
                  {
                    label: "Street",
                    value: format.street(
                      values.streetAddress,
                      values.apartment,
                    ),
                  },
                  {
                    label: "City",
                    value: format.city(
                      values.city,
                      values.state,
                      values.postalCode,
                    ),
                  },
                  {
                    label: "Country",
                    value: format.country(values.country),
                  },
                ],
              },
              {
                text: "Security",
                rows: [
                  {
                    label: "Username",
                    value: format.username(values.username),
                  },
                  {
                    label: "Password",
                    value: format.password(values.password),
                  },
                ],
              },
              {
                text: "Preferences",
                rows: [
                  {
                    label: "Language",
                    value: format.language(values.language),
                  },
                  {
                    label: "Timezone",
                    value: format.timezone(values.timezone),
                  },
                  {
                    label: "Email Notifications",
                    value: format.toggle(values.emailNotifications),
                  },
                  {
                    label: "Marketing Emails",
                    value: format.toggle(values.marketingEmails),
                  },
                  {
                    label: "Newsletter",
                    value: format.toggle(values.weeklyNewsletter),
                  },
                ],
              },
            ]}
            buttons={{
              next: "Submit",
              back: "Back",
            }}
            onNext={onNext}
            status={params.status}
          />
        ),
      }),
    },
  },
  {
    return: (values) => values,
  },
];

export const inputs: Values = {
  name: "",
  surname: "",
  gender: "",
  bio: "",
  streetAddress: "",
  apartment: "",
  city: "",
  state: "",
  postalCode: "",
  country: "",
  username: "",
  password: "",
  confirmPassword: "",
  language: "",
  timezone: "",
  emailNotifications: false,
  marketingEmails: false,
  weeklyNewsletter: false,
};

components/form/index.tsx:

// components/form/index.tsx
import type { DefaultValues, Resolver } from "react-hook-form";
import type { OnNext } from "@formity/react";

import { useEffect, useEffectEvent } from "react";
import { useForm, FormProvider } from "react-hook-form";

import { ItemView, type Item } from "./item";

interface FormProps<T extends Record<string, unknown>, U extends T> {
  defaultValues: DefaultValues<T>;
  resolver: Resolver<T>;
  position: string;
  heading: string;
  message: string;
  content: Item[];
  buttons: {
    next: string;
    back: string | null;
  };
  onNext: OnNext<T>;
  values: U;
  setValues: (values: U) => void;
}

export function Form<T extends Record<string, unknown>, U extends T>({
  defaultValues,
  resolver,
  position,
  heading,
  message,
  content,
  buttons,
  onNext,
  values,
  setValues,
}: FormProps<T, U>) {
  const form = useForm({ defaultValues, resolver });

  const onValuesChange = useEffectEvent(({ values: fields }: { values: T }) => {
    setValues({ ...values, ...fields });
  });

  useEffect(() => {
    return form.subscribe({
      formState: { values: true },
      callback: onValuesChange,
    });
  }, [form]);

  return (
    <form
      autoComplete="off"
      onSubmit={form.handleSubmit(onNext)}
      className="block w-full max-w-lg"
    >
      <FormProvider {...form}>
        <div className="mb-10">
          <div className="mb-2.5 inline-flex select-none items-center rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 font-sans text-xs font-bold text-blue-500">
            {position}
          </div>
          <h2 className="mb-2.5 font-sans text-3xl font-bold leading-tight text-white">
            {heading}
          </h2>
          <p className="mb-6 text-base leading-normal text-neutral-400">
            {message}
          </p>
          <div className="mb-8 flex flex-col gap-5">
            {content.map((item, i) => (
              <ItemView key={i} {...item} />
            ))}
          </div>
          <div className="flex items-center justify-between gap-3">
            {buttons.back && (
              <button
                type="button"
                onClick={() => {}}
                className="not-disabled:hover:bg-neutral-500/30 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-neutral-500/20 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
              >
                {buttons.back}
              </button>
            )}
            <button
              type="submit"
              className="not-disabled:hover:bg-blue-600 ml-auto inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-500 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
            >
              {buttons.next}
            </button>
          </div>
        </div>
      </FormProvider>
    </form>
  );
}

Jump to steps

We'll implement jump functionality using the jump element. For this, we'll update these files.

app/index.tsx:

// app/index.tsx
import type { OnReturn } from "@formity/react";

import { useFormity } from "@formity/react";
import { useState, useCallback, useMemo, useRef } from "react";

import type { Status, FormStatus } from "@/types/status";
import type { FormStep } from "@/types/steps";

import { Sidebar } from "../components/sidebar";
import { Submitted } from "../components/submitted";

import { steps, flow, inputs, type Schema } from "./flow";

export default function App() {
  const [status, setStatus] = useState<Status>({
    type: "form",
    submitting: false,
  });

  const onReturn = useCallback<OnReturn<Schema>>(async (output) => {
    setStatus({ type: "form", submitting: true });

    // Show output in the console
    console.log(output);

    // Simulate a network request
    await new Promise((resolve) => setTimeout(resolve, 2000));

    setStatus({ type: "submitted" });
  }, []);

  if (status.type === "submitted") {
    return (
      <Submitted
        onStart={() => setStatus({ type: "form", submitting: false })}
      />
    );
  }

  return <Formity status={status} onReturn={onReturn} />;
}

interface FormityProps {
  status: FormStatus;
  onReturn: OnReturn<Schema>;
}

function Formity({ status, onReturn }: FormityProps) {
  const [values, setValues] = useState(inputs);

  const ref = useRef<{ jump: (id: string) => void }>(null);

  const { step: currentStep, form } = useFormity({
    flow,
    inputs,
    params: { status, setValues, ref },
    history: false,
    onReturn,
  });

  const completedSteps = useMemo(() => {
    for (let i = 0; i < steps.length - 1; i++) {
      const step = steps[i] as FormStep;
      if (!step.zod.safeParse(values).success) {
        return i;
      }
    }
    return steps.length - 1;
  }, [values]);

  return (
    <div className="color-scheme-dark flex min-h-svh">
      <Sidebar
        steps={steps}
        currentStep={currentStep}
        completedSteps={completedSteps}
        onJump={(index) => ref.current?.jump(steps[index].id)}
      />
      <main className="flex flex-1 items-start justify-center overflow-y-auto px-16 py-20">
        {form}
      </main>
    </div>
  );
}

app/flow.tsx:

// app/flow.tsx
import type { UnionToIntersection } from "type-fest";
import type { s, Flow } from "@formity/react";

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

import type { Steps, FormStep, ReviewStep } from "@/types/steps";
import type { FormStatus } from "@/types/status";

import { Form } from "@/components/form";
import { Review } from "@/components/review";

import * as constants from "@/constants";
import * as format from "@/utils/format";

type Values = UnionToIntersection<Fields[keyof Fields]>;

type Fields = {
  personal: {
    name: string;
    surname: string;
    gender: string;
    bio: string;
  };
  location: {
    streetAddress: string;
    apartment: string;
    city: string;
    state: string;
    postalCode: string;
    country: string;
  };
  security: {
    username: string;
    password: string;
    confirmPassword: string;
  };
  preferences: {
    language: string;
    timezone: string;
    emailNotifications: boolean;
    marketingEmails: boolean;
    weeklyNewsletter: boolean;
  };
};

const personal: FormStep<Fields["personal"]> = {
  id: "personal",
  label: "Personal",
  subtitle: "Basic information",
  zod: z.object({
    name: z.string().nonempty("Required"),
    surname: z.string().nonempty("Required"),
    gender: z.string().nonempty("Required"),
    bio: z.string(),
  }),
};

const location: FormStep<Fields["location"]> = {
  id: "location",
  label: "Location",
  subtitle: "Where you are",
  zod: z.object({
    streetAddress: z.string().nonempty("Required"),
    apartment: z.string(),
    city: z.string().nonempty("Required"),
    state: z.string().nonempty("Required"),
    postalCode: z.string().nonempty("Required"),
    country: z.string().nonempty("Required"),
  }),
};

const security: FormStep<Fields["security"]> = {
  id: "security",
  label: "Security",
  subtitle: "Login credentials",
  zod: z
    .object({
      username: z
        .string()
        .nonempty("Required")
        .regex(
          /^[a-zA-Z0-9_.]+$/,
          "Letters, numbers, underscores and dots only",
        ),
      password: z.string().nonempty("Required").min(8, "Min. 8 characters"),
      confirmPassword: z.string().nonempty("Required"),
    })
    .superRefine(({ password, confirmPassword }, ctx) => {
      if (confirmPassword !== password) {
        ctx.addIssue({
          code: "custom",
          message: "Passwords do not match",
          path: ["confirmPassword"],
        });
      }
    }),
};

const preferences: FormStep<Fields["preferences"]> = {
  id: "preferences",
  label: "Preferences",
  subtitle: "Customize your setup",
  zod: z.object({
    language: z.string().nonempty("Required"),
    timezone: z.string().nonempty("Required"),
    emailNotifications: z.boolean(),
    marketingEmails: z.boolean(),
    weeklyNewsletter: z.boolean(),
  }),
};

const review: ReviewStep = {
  id: "review",
  label: "Review",
  subtitle: "Confirm & submit",
};

export const steps: Steps = [personal, location, security, preferences, review];

export type Schema = {
  render: { step: number; form: React.ReactNode };
  struct: [
    s.Jump<s.Form<Fields["personal"]>>,
    s.Jump<s.Form<Fields["location"]>>,
    s.Jump<s.Form<Fields["security"]>>,
    s.Jump<s.Form<Fields["preferences"]>>,
    s.Jump<s.Form<Record<never, never>>>,
    s.Return<Values>,
  ];
  inputs: Values;
  params: {
    status: FormStatus;
    setValues: (values: Values) => void;
    ref: React.Ref<{ jump: (id: string) => void }>;
  };
};

export const flow: Flow<Schema> = [
  {
    jump: {
      id: personal.id,
      at: {
        form: {
          fields: (values) => ({
            name: [values.name, []],
            surname: [values.surname, []],
            gender: [values.gender, []],
            bio: [values.bio, []],
          }),
          render: ({ fields, values, params, onNext, onJump }) => ({
            step: 0,
            form: (
              <Form
                key={personal.id}
                defaultValues={fields}
                resolver={zodResolver(personal.zod)}
                position="01"
                heading="Personal Info"
                message="Let's start with the basics. Your information is kept private."
                content={[
                  {
                    type: "columns",
                    columns: [
                      {
                        type: "input",
                        name: "name",
                        label: "Name",
                        placeholder: "Jane",
                      },
                      {
                        type: "input",
                        name: "surname",
                        label: "Surname",
                        placeholder: "Smith",
                      },
                    ],
                  },
                  {
                    type: "select",
                    name: "gender",
                    label: "Gender",
                    placeholder: "Select your gender",
                    options: constants.genders,
                  },
                  {
                    type: "textarea",
                    name: "bio",
                    label: "Short Bio",
                    placeholder: "Tell us a little about yourself...",
                    optional: true,
                  },
                ]}
                buttons={{ next: "Continue", back: null }}
                onNext={onNext}
                onJump={onJump}
                prevId={null}
                values={values}
                setValues={params.setValues}
                ref={params.ref}
              />
            ),
          }),
        },
      },
    },
  },
  {
    jump: {
      id: location.id,
      at: {
        form: {
          fields: (values) => ({
            streetAddress: [values.streetAddress, []],
            apartment: [values.apartment, []],
            city: [values.city, []],
            state: [values.state, []],
            postalCode: [values.postalCode, []],
            country: [values.country, []],
          }),
          render: ({ fields, values, params, onNext, onJump }) => ({
            step: 1,
            form: (
              <Form
                key={location.id}
                defaultValues={fields}
                resolver={zodResolver(location.zod)}
                position="02"
                heading="Location"
                message="Where are you based? Used to personalise your experience."
                content={[
                  {
                    type: "input",
                    name: "streetAddress",
                    label: "Street Address",
                    placeholder: "123 Main Street",
                  },
                  {
                    type: "input",
                    name: "apartment",
                    label: "Apartment / Suite",
                    placeholder: "Apt 4B",
                    optional: true,
                  },
                  {
                    type: "columns",
                    columns: [
                      {
                        type: "input",
                        name: "city",
                        label: "City",
                        placeholder: "New York",
                      },
                      {
                        type: "input",
                        name: "state",
                        label: "State / Province",
                        placeholder: "NY",
                      },
                    ],
                  },
                  {
                    type: "columns",
                    columns: [
                      {
                        type: "input",
                        name: "postalCode",
                        label: "Postal Code",
                        placeholder: "10001",
                      },
                      {
                        type: "select",
                        name: "country",
                        label: "Country",
                        placeholder: "Select a country",
                        options: constants.countries,
                      },
                    ],
                  },
                ]}
                buttons={{ next: "Continue", back: "Back" }}
                onNext={onNext}
                onJump={onJump}
                prevId={personal.id}
                values={values}
                setValues={params.setValues}
                ref={params.ref}
              />
            ),
          }),
        },
      },
    },
  },
  {
    jump: {
      id: security.id,
      at: {
        form: {
          fields: (values) => ({
            username: [values.username, []],
            password: [values.password, []],
            confirmPassword: [values.confirmPassword, []],
          }),
          render: ({ fields, values, params, onNext, onJump }) => ({
            step: 2,
            form: (
              <Form
                key={security.id}
                defaultValues={fields}
                resolver={zodResolver(security.zod)}
                position="03"
                heading="Security"
                message="Create your login credentials. Use a strong and unique password."
                content={[
                  {
                    type: "input",
                    name: "username",
                    label: "Username",
                    placeholder: "jane_smith",
                  },
                  {
                    type: "divider",
                    text: "Credentials",
                  },
                  {
                    type: "password",
                    name: "password",
                    label: "Password",
                    placeholder: "Min. 8 characters",
                  },
                  {
                    type: "password",
                    name: "confirmPassword",
                    label: "Confirm Password",
                    placeholder: "Repeat your password",
                  },
                ]}
                buttons={{ next: "Continue", back: "Back" }}
                onNext={onNext}
                onJump={onJump}
                prevId={location.id}
                values={values}
                setValues={params.setValues}
                ref={params.ref}
              />
            ),
          }),
        },
      },
    },
  },
  {
    jump: {
      id: preferences.id,
      at: {
        form: {
          fields: (values) => ({
            language: [values.language, []],
            timezone: [values.timezone, []],
            emailNotifications: [values.emailNotifications, []],
            marketingEmails: [values.marketingEmails, []],
            weeklyNewsletter: [values.weeklyNewsletter, []],
          }),
          render: ({ fields, values, params, onNext, onJump }) => ({
            step: 3,
            form: (
              <Form
                key={preferences.id}
                defaultValues={fields}
                resolver={zodResolver(preferences.zod)}
                position="04"
                heading="Preferences"
                message="Customise your experience. All of these can be changed later."
                content={[
                  {
                    type: "select",
                    name: "language",
                    label: "Language",
                    placeholder: "Select language",
                    options: constants.languages,
                  },
                  {
                    type: "select",
                    name: "timezone",
                    label: "Timezone",
                    placeholder: "Select timezone",
                    options: constants.timezones,
                  },
                  {
                    type: "switch",
                    name: "emailNotifications",
                    label: "Email Notifications",
                    description: "Security alerts and account updates",
                  },
                  {
                    type: "switch",
                    name: "marketingEmails",
                    label: "Marketing Emails",
                    description: "Product announcements and special offers",
                  },
                  {
                    type: "switch",
                    name: "weeklyNewsletter",
                    label: "Weekly Newsletter",
                    description: "Tips, guides and community highlights",
                  },
                ]}
                buttons={{ next: "Continue", back: "Back" }}
                onNext={onNext}
                onJump={onJump}
                prevId={security.id}
                values={values}
                setValues={params.setValues}
                ref={params.ref}
              />
            ),
          }),
        },
      },
    },
  },
  {
    jump: {
      id: review.id,
      at: {
        form: {
          fields: () => ({}),
          render: ({ values, params, onNext, onJump }) => ({
            step: 4,
            form: (
              <Review
                key={review.id}
                position="05"
                heading="Review"
                message="Almost there. Double-check your details before submitting."
                content={[
                  {
                    text: "Personal",
                    rows: [
                      {
                        label: "Full Name",
                        value: format.fullName(values.name, values.surname),
                      },
                      {
                        label: "Gender",
                        value: format.gender(values.gender),
                      },
                      {
                        label: "Bio",
                        value: format.bio(values.bio),
                      },
                    ],
                  },
                  {
                    text: "Location",
                    rows: [
                      {
                        label: "Street",
                        value: format.street(
                          values.streetAddress,
                          values.apartment,
                        ),
                      },
                      {
                        label: "City",
                        value: format.city(
                          values.city,
                          values.state,
                          values.postalCode,
                        ),
                      },
                      {
                        label: "Country",
                        value: format.country(values.country),
                      },
                    ],
                  },
                  {
                    text: "Security",
                    rows: [
                      {
                        label: "Username",
                        value: format.username(values.username),
                      },
                      {
                        label: "Password",
                        value: format.password(values.password),
                      },
                    ],
                  },
                  {
                    text: "Preferences",
                    rows: [
                      {
                        label: "Language",
                        value: format.language(values.language),
                      },
                      {
                        label: "Timezone",
                        value: format.timezone(values.timezone),
                      },
                      {
                        label: "Email Notifications",
                        value: format.toggle(values.emailNotifications),
                      },
                      {
                        label: "Marketing Emails",
                        value: format.toggle(values.marketingEmails),
                      },
                      {
                        label: "Newsletter",
                        value: format.toggle(values.weeklyNewsletter),
                      },
                    ],
                  },
                ]}
                buttons={{
                  next: "Submit",
                  back: "Back",
                }}
                onNext={onNext}
                onJump={onJump}
                prevId={preferences.id}
                status={params.status}
                ref={params.ref}
              />
            ),
          }),
        },
      },
    },
  },
  {
    return: (values) => values,
  },
];

export const inputs: Values = {
  name: "",
  surname: "",
  gender: "",
  bio: "",
  streetAddress: "",
  apartment: "",
  city: "",
  state: "",
  postalCode: "",
  country: "",
  username: "",
  password: "",
  confirmPassword: "",
  language: "",
  timezone: "",
  emailNotifications: false,
  marketingEmails: false,
  weeklyNewsletter: false,
};

components/form/index.tsx:

// components/form/index.tsx
import type { DefaultValues, Resolver } from "react-hook-form";
import type { OnNext, OnJump } from "@formity/react";

import { useEffect, useEffectEvent, useImperativeHandle } from "react";
import { useForm, FormProvider } from "react-hook-form";

import { ItemView, type Item } from "./item";

interface FormProps<T extends Record<string, unknown>, U extends T> {
  defaultValues: DefaultValues<T>;
  resolver: Resolver<T>;
  position: string;
  heading: string;
  message: string;
  content: Item[];
  buttons: {
    next: string;
    back: string | null;
  };
  onNext: OnNext<T>;
  onJump: OnJump<T>;
  prevId: string | null;
  values: U;
  setValues: (values: U) => void;
  ref: React.Ref<{ jump: (id: string) => void }>;
}

export function Form<T extends Record<string, unknown>, U extends T>({
  defaultValues,
  resolver,
  position,
  heading,
  message,
  content,
  buttons,
  onNext,
  onJump,
  prevId,
  values,
  setValues,
  ref,
}: FormProps<T, U>) {
  const form = useForm({ defaultValues, resolver });

  const onValuesChange = useEffectEvent(({ values: fields }: { values: T }) => {
    setValues({ ...values, ...fields });
  });

  useEffect(() => {
    return form.subscribe({
      formState: { values: true },
      callback: onValuesChange,
    });
  }, [form]);

  useImperativeHandle(
    ref,
    () => ({
      jump: (id) => onJump(id, form.getValues()),
    }),
    [form, onJump],
  );

  return (
    <form
      autoComplete="off"
      onSubmit={form.handleSubmit(onNext)}
      className="block w-full max-w-lg"
    >
      <FormProvider {...form}>
        <div className="mb-10">
          <div className="mb-2.5 inline-flex select-none items-center rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 font-sans text-xs font-bold text-blue-500">
            {position}
          </div>
          <h2 className="mb-2.5 font-sans text-3xl font-bold leading-tight text-white">
            {heading}
          </h2>
          <p className="mb-6 text-base leading-normal text-neutral-400">
            {message}
          </p>
          <div className="mb-8 flex flex-col gap-5">
            {content.map((item, i) => (
              <ItemView key={i} {...item} />
            ))}
          </div>
          <div className="flex items-center justify-between gap-3">
            {buttons.back && (
              <button
                type="button"
                onClick={() => onJump(prevId, form.getValues())}
                className="not-disabled:hover:bg-neutral-500/30 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-neutral-500/20 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
              >
                {buttons.back}
              </button>
            )}
            <button
              type="submit"
              className="not-disabled:hover:bg-blue-600 ml-auto inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-500 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
            >
              {buttons.next}
            </button>
          </div>
        </div>
      </FormProvider>
    </form>
  );
}

components/review/index.tsx:

// components/review/index.tsx
import type { OnNext, OnJump } from "@formity/react";

import { useImperativeHandle } from "react";

import type { FormStatus } from "@/types/status";

import { ItemView, type Item } from "./item";

interface ReviewProps {
  position: string;
  heading: string;
  message: string;
  content: Item[];
  buttons: {
    next: string;
    back: string;
  };
  onNext: OnNext<Record<never, never>>;
  onJump: OnJump<Record<never, never>>;
  prevId: string;
  status: FormStatus;
  ref: React.Ref<{ jump: (id: string) => void }>;
}

export function Review({
  position,
  heading,
  message,
  content,
  buttons,
  onNext,
  onJump,
  prevId,
  status,
  ref,
}: ReviewProps) {
  useImperativeHandle(
    ref,
    () => ({
      jump: (id) => onJump(id, {}),
    }),
    [onJump],
  );
  return (
    <div className="w-full max-w-lg">
      <div className="mb-10">
        <div className="mb-2.5 inline-flex select-none items-center rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 font-sans text-xs font-bold text-blue-500">
          {position}
        </div>
        <h2 className="mb-2.5 font-sans text-3xl font-bold leading-tight text-white">
          {heading}
        </h2>
        <p className="mb-6 text-base leading-normal text-neutral-400">
          {message}
        </p>
        <div className="mb-8 flex flex-col gap-5">
          {content.map((item, i) => (
            <ItemView key={i} {...item} />
          ))}
        </div>
        <div className="flex items-center justify-between gap-3">
          <button
            type="button"
            onClick={() => onJump(prevId, {})}
            disabled={status.submitting}
            className="not-disabled:hover:bg-neutral-500/30 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-neutral-500/20 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
          >
            {buttons.back}
          </button>
          <button
            type="submit"
            onClick={() => onNext({})}
            disabled={status.submitting}
            className="not-disabled:hover:bg-blue-600 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-500 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
          >
            {status.submitting ? "Submitting..." : buttons.next}
          </button>
        </div>
      </div>
    </div>
  );
}