Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsCore inputs & forms

Form

A form system without react-hook-form: useForm with sync and async validators, native constraint fallback, server error mapping, a focusable error summary, pending submit and a multi-step variant.

Preview

Create your account

Free for teams up to five.

@

Shown on your profile and mentions.

Fair
  • 8+ characters (met)
  • Upper and lower (not met)
  • A number (met)
  • A symbol (not met)

Set up your workspace

  1. 1Workspace (current step)
  2. 2Team
  3. 3Region

1/3Workspace

.team.example

Install

Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.

npx shadcn@latest add https://pebbleui.com/r/form.json

Props

PropTypeDefaultDescription
useForm({ initialValues })V—Initial values; their keys are the field names.
useForm({ validators }){ [name]: Validator | Validator[] }—(value, values) => message | undefined, sync or async; run in order. Native constraints are the fallback.
useForm({ onSubmit })(values) => void | { errors?, formError? }—Runs after every field passes; may be async. Returned errors map onto fields or the summary.
useForm({ labels }){ [name]: string }field nameHuman names used in the error summary links.
Form formFormApi—The object returned by useForm. Renders a noValidate <form>.
Field namestring—Connects the label, control, description and error to one value.
FieldControlsingle child—Injects id, name, value/checked, onChange, onBlur, aria-invalid, aria-describedby and aria-busy.
FieldError childrenReactNodefield errorOverrides the message; reveals with a short slide.
FormErrorSummary titlestring"Fix N fields to continue"Shown after a failed submit; focuses itself and links to each field.
FormSubmitButtonProps—Submit button that shows a spinner while submitting.
FormSteps steps{ title, fields, content }[]—Multi-step layout; Next validates only that step's fields.
FormSteps submitLabel / step / onStepChangeReactNode · number · (step) => void"Submit"Last-step button label and optional controlled step.
Build notes
- Validators run in sequence; each async run increments a per-field counter
  in a ref, and a result is dropped if a newer run started (no stale
  "taken" after the user changed the name).
- The form element is held in state through a callback ref (not a ref
  object) so the API object can be read during render under the React
  Compiler lint rules; native validation reads
  `form.elements.namedItem(name).checkValidity()` in handlers only.
- `FieldControl` is a Radix `Slot`: boolean values map to `checked`,
  others to `value`; Radix Checkbox can be wired by passing
  `onCheckedChange` that calls `form.setValue`.
- `FieldDescription` registers itself with its `Field` in an effect so
  `aria-describedby` only names ids that exist.
- `FormSteps` uses `AnimatePresence mode="popLayout"` with direction
  variants (x ±24 px, house spring, 160 ms fade); headings carry
  `data-step-heading={index}` so focus goes to the entering copy, not the
  exiting one.

<!-- PEBBLE-LICENSE -->
View source

registry/input/form/form.tsx

"use client";

/* PEBBLE-LICENSE
Built by Pebble. Component: form.

First-party license: MIT

The following first-party terms apply only to original Pebble material.

MIT License

Copyright (c) 2026 Praxys Solutions LLC (Pebble)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.

Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License

Copyright (c) 2023 shadcn

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */

import * as React from "react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { Slot } from "radix-ui";

import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "@/registry/button/button/button";
import { Label, type LabelProps } from "@/registry/input/label/label";

/* -------------------------------------------------------------------------- */
/* useForm: values, touched, sync + async validation, server errors           */
/* -------------------------------------------------------------------------- */

export type FormValues = Record<string, unknown>;
type MaybePromise<T> = T | Promise<T>;

/** Returns an error message, or nothing when the value is fine. */
export type Validator<V extends FormValues> = (
  value: V[keyof V],
  values: V,
) => MaybePromise<string | undefined | null | false>;

export type FormErrors<V extends FormValues> = Partial<Record<keyof V & string, string>>;

/** What `onSubmit` may return to map server-side problems back onto fields. */
export type SubmitResult<V extends FormValues> = void | {
  errors?: FormErrors<V>;
  formError?: string;
};

export type UseFormOptions<V extends FormValues> = {
  initialValues: V;
  validators?: Partial<Record<keyof V & string, Validator<V> | Validator<V>[]>>;
  onSubmit: (values: V) => MaybePromise<SubmitResult<V>>;
  /** Field labels used in the error summary. Defaults to the field name. */
  labels?: Partial<Record<keyof V & string, string>>;
};

export type FormApi<V extends FormValues> = ReturnType<typeof useForm<V>>;

export function useForm<V extends FormValues>({
  initialValues,
  validators = {},
  onSubmit,
  labels = {},
}: UseFormOptions<V>) {
  type Name = keyof V & string;
  const [values, setValues] = React.useState<V>(initialValues);
  const [errors, setErrorsState] = React.useState<FormErrors<V>>({});
  const [touched, setTouched] = React.useState<Partial<Record<Name, boolean>>>({});
  const [validating, setValidating] = React.useState<Partial<Record<Name, boolean>>>({});
  const [submitting, setSubmitting] = React.useState(false);
  const [submitCount, setSubmitCount] = React.useState(0);
  const [formError, setFormError] = React.useState<string | undefined>();
  // Bumped on each submit that fails validation, so the summary can take focus.
  const [failedAttempt, setFailedAttempt] = React.useState(0);
  // Kept in state (set by <Form>'s callback ref) so the API object holds no refs.
  const [formElement, attach] = React.useState<HTMLFormElement | null>(null);
  // Per-field run counters: a slow async result is dropped if a newer run started.
  const runs = React.useRef<Record<string, number>>({});

  const setError = (name: Name, message: string | undefined) =>
    setErrorsState((prev) => {
      if (prev[name] === message) return prev;
      const next = { ...prev };
      if (message) next[name] = message;
      else delete next[name];
      return next;
    });

  /** Native constraint validation fallback (required, type=email, pattern, min…). */
  const nativeMessage = (name: Name) => {
    const el = formElement?.elements.namedItem(name);
    if (
      el instanceof HTMLInputElement ||
      el instanceof HTMLSelectElement ||
      el instanceof HTMLTextAreaElement
    ) {
      const control = el;
      return control.checkValidity() ? undefined : control.validationMessage || "Check this field.";
    }
    return undefined;
  };

  const validateField = async (name: Name, all: V = values): Promise<string | undefined> => {
    const run = (runs.current[name] = (runs.current[name] ?? 0) + 1);
    const list = validators[name];
    const fns = list ? (Array.isArray(list) ? list : [list]) : [];
    let message: string | undefined;
    for (const fn of fns) {
      const out = fn(all[name], all);
      if (out instanceof Promise) {
        setValidating((p) => ({ ...p, [name]: true }));
        const resolved = await out;
        if (runs.current[name] !== run) return undefined;
        setValidating((p) => ({ ...p, [name]: false }));
        if (resolved) message = resolved;
      } else if (out) {
        message = out;
      }
      if (message) break;
    }
    if (!message) message = nativeMessage(name);
    if (runs.current[name] === run) setError(name, message);
    return message;
  };

  /** Validates the named fields (all when omitted); true when they all pass. */
  const validateFields = async (names?: Name[]) => {
    const list = names ?? (Object.keys(values) as Name[]);
    setTouched((p) => ({ ...p, ...Object.fromEntries(list.map((n) => [n, true])) }));
    const results = await Promise.all(list.map((n) => validateField(n)));
    return results.every((r) => !r);
  };

  const setValue = <K extends Name>(name: K, value: V[K]) => {
    const next = { ...values, [name]: value };
    setValues(next);
    // Reward early: once a field was left or has an error, re-check as you type.
    if (touched[name] || errors[name]) void validateField(name, next);
  };

  const blur = (name: Name) => {
    setTouched((p) => ({ ...p, [name]: true }));
    void validateField(name);
  };

  const handleSubmit = async (event?: React.FormEvent) => {
    event?.preventDefault();
    if (submitting) return;
    setSubmitCount((c) => c + 1);
    setFormError(undefined);
    const ok = await validateFields();
    if (!ok) {
      setFailedAttempt((n) => n + 1);
      return;
    }
    setSubmitting(true);
    try {
      const result = await onSubmit(values);
      if (result?.errors) {
        setErrorsState((p) => ({ ...p, ...result.errors }));
        setFailedAttempt((n) => n + 1);
      }
      if (result?.formError) setFormError(result.formError);
    } finally {
      setSubmitting(false);
    }
  };

  const reset = (next: V = initialValues) => {
    setValues(next);
    setErrorsState({});
    setTouched({});
    setSubmitCount(0);
    setFormError(undefined);
  };

  return {
    values,
    errors,
    touched,
    validating,
    submitting,
    submitCount,
    formError,
    failedAttempt,
    labels: labels as Partial<Record<string, string>>,
    formElement,
    attach,
    setValue,
    setError,
    setErrors: (next: FormErrors<V>) => setErrorsState(next),
    blur,
    validateField,
    validateFields,
    handleSubmit,
    reset,
  };
}

/* -------------------------------------------------------------------------- */
/* Context                                                                    */
/* -------------------------------------------------------------------------- */

// The context is untyped on purpose: fields address values by string name.
type AnyForm = FormApi<FormValues>;
const FormContext = React.createContext<AnyForm | null>(null);

export function useFormContext() {
  const form = React.useContext(FormContext);
  if (!form) throw new Error("Form parts must be used inside <Form>.");
  return form;
}

type FieldContextValue = {
  name: string;
  controlId: string;
  descriptionId: string;
  errorId: string;
  error: string | undefined;
  validating: boolean;
  hasDescription: boolean;
  registerDescription: (on: boolean) => void;
};
const FieldContext = React.createContext<FieldContextValue | null>(null);

export function useField() {
  const field = React.useContext(FieldContext);
  if (!field) throw new Error("Field parts must be used inside <Field>.");
  return field;
}

/* -------------------------------------------------------------------------- */
/* Form                                                                       */
/* -------------------------------------------------------------------------- */

export type FormProps<V extends FormValues> = Omit<React.ComponentProps<"form">, "onSubmit"> & {
  form: FormApi<V>;
};

export function Form<V extends FormValues>({ form, className, children, ...props }: FormProps<V>) {
  return (
    <FormContext.Provider value={form as unknown as AnyForm}>
      <MotionConfig reducedMotion="user">
        <form
          ref={(el) => form.attach(el)}
          data-slot="form"
          noValidate
          aria-busy={form.submitting || undefined}
          onSubmit={form.handleSubmit}
          className={cn("flex flex-col gap-5", className)}
          {...props}
        >
          {children}
        </form>
      </MotionConfig>
    </FormContext.Provider>
  );
}

/* -------------------------------------------------------------------------- */
/* Field parts                                                                */
/* -------------------------------------------------------------------------- */

export function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div data-slot="field-group" className={cn("flex flex-col gap-4", className)} {...props} />
  );
}

export type FieldProps = React.ComponentProps<"div"> & { name: string };

export function Field({ name, className, ...props }: FieldProps) {
  const form = useFormContext();
  const id = React.useId();
  const [hasDescription, setHasDescription] = React.useState(false);
  const error = form.errors[name];
  const value: FieldContextValue = {
    name,
    controlId: `${id}-control`,
    descriptionId: `${id}-description`,
    errorId: `${id}-error`,
    error,
    validating: !!form.validating[name],
    hasDescription,
    registerDescription: setHasDescription,
  };
  return (
    <FieldContext.Provider value={value}>
      <div
        data-slot="field"
        data-invalid={error ? "true" : undefined}
        data-validating={value.validating || undefined}
        className={cn("group/field flex flex-col gap-2", className)}
        {...props}
      />
    </FieldContext.Provider>
  );
}

export function FieldLabel({ className, ...props }: LabelProps) {
  const field = useField();
  return (
    <Label
      data-slot="field-label"
      htmlFor={field.controlId}
      className={cn("group-data-[invalid=true]/field:text-danger-strong", className)}
      {...props}
    />
  );
}

export function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
  const field = useField();
  const { registerDescription } = field;
  React.useEffect(() => {
    registerDescription(true);
    return () => registerDescription(false);
  }, [registerDescription]);
  return (
    <p
      id={field.descriptionId}
      data-slot="field-description"
      className={cn("text-xs leading-normal text-muted-foreground", className)}
      {...props}
    />
  );
}

/**
 * Wires its single child to the field: id, name, value, change and blur,
 * plus `aria-invalid` and `aria-describedby`. Works with native inputs,
 * TextField, Textarea and anything forwarding those props.
 */
export function FieldControl({ children, ...props }: React.ComponentProps<typeof Slot.Root>) {
  const form = useFormContext();
  const field = useField();
  const value = form.values[field.name];
  const describedBy =
    [field.hasDescription && field.descriptionId, field.error && field.errorId]
      .filter(Boolean)
      .join(" ") || undefined;
  const isBoolean = typeof value === "boolean";
  // Slot is typed as a generic HTMLElement; these are input props.
  const controlProps: Record<string, unknown> = {
    name: field.name,
    ...(isBoolean ? { checked: value } : { value: (value as string | number | undefined) ?? "" }),
  };
  return (
    <Slot.Root
      id={field.controlId}
      {...controlProps}
      onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
        form.setValue(field.name, isBoolean ? e.target.checked : e.target.value)
      }
      onBlur={() => form.blur(field.name)}
      aria-invalid={field.error ? true : undefined}
      aria-describedby={describedBy}
      aria-busy={field.validating || undefined}
      {...props}
    >
      {children}
    </Slot.Root>
  );
}

function AlertIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      aria-hidden
      className="mt-px size-3.5 shrink-0"
    >
      <circle cx="8" cy="8" r="6" />
      <path d="M8 5v3.5M8 11h.01" />
    </svg>
  );
}

/** The field's error, revealed with a short height + fade when it appears. */
export function FieldError({
  className,
  children,
}: {
  className?: string;
  children?: React.ReactNode;
}) {
  const field = useField();
  const message = children ?? field.error;
  return (
    <AnimatePresence initial={false}>
      {message && (
        <motion.p
          key="error"
          id={field.errorId}
          data-slot="field-error"
          initial={{ opacity: 0, height: 0, y: -4 }}
          animate={{ opacity: 1, height: "auto", y: 0 }}
          exit={{ opacity: 0, height: 0 }}
          transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
          className={cn(
            "-mt-0.5 flex gap-1.5 overflow-hidden text-xs text-danger-strong",
            className,
          )}
        >
          <AlertIcon />
          <span>{message}</span>
        </motion.p>
      )}
    </AnimatePresence>
  );
}

/* -------------------------------------------------------------------------- */
/* Error summary                                                              */
/* -------------------------------------------------------------------------- */

/**
 * After a failed submit, lists every error as a link that focuses its field.
 * Takes focus itself so screen readers hear the count first.
 */
export function FormErrorSummary({ className, title }: { className?: string; title?: string }) {
  const form = useFormContext();
  const ref = React.useRef<HTMLDivElement>(null);
  const entries = Object.entries(form.errors).filter((e): e is [string, string] => !!e[1]);
  const show = (form.submitCount > 0 && entries.length > 0) || !!form.formError;

  React.useEffect(() => {
    if (form.failedAttempt > 0) ref.current?.focus();
  }, [form.failedAttempt]);

  const focusField = (name: string) => {
    const el = form.formElement?.elements.namedItem(name);
    if (el instanceof HTMLElement) {
      el.focus();
      el.scrollIntoView?.({ block: "center", behavior: "smooth" });
    }
  };

  return (
    <AnimatePresence initial={false}>
      {show && (
        <motion.div
          ref={ref}
          key="summary"
          tabIndex={-1}
          role="alert"
          data-slot="form-error-summary"
          initial={{ opacity: 0, y: -6 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
          className={cn(
            "rounded-lg bg-danger/6 p-3 text-[13px] ring-1 ring-danger/25 outline-none",
            "focus-visible:ring-2 focus-visible:ring-ring",
            className,
          )}
        >
          <p className="flex items-center gap-1.5 font-medium text-danger-strong">
            <AlertIcon />
            {form.formError ??
              title ??
              `Fix ${entries.length} ${entries.length === 1 ? "field" : "fields"} to continue`}
          </p>
          {entries.length > 0 && (
            <ul className="mt-1.5 flex flex-col gap-0.5 ps-5">
              {entries.map(([name, message]) => (
                <li key={name}>
                  <a
                    href={`#${name}`}
                    onClick={(e) => {
                      e.preventDefault();
                      focusField(name);
                    }}
                    className="text-foreground underline decoration-danger/40 underline-offset-2 hover:decoration-danger"
                  >
                    {form.labels[name] ?? name}
                  </a>
                  <span className="text-muted-foreground">: {message}</span>
                </li>
              ))}
            </ul>
          )}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

/* -------------------------------------------------------------------------- */
/* Submit                                                                     */
/* -------------------------------------------------------------------------- */

export function FormSubmit({ children, ...props }: ButtonProps) {
  const form = useFormContext();
  return (
    <Button type="submit" loading={form.submitting} data-slot="form-submit" {...props}>
      {children}
    </Button>
  );
}

/* -------------------------------------------------------------------------- */
/* Multi-step                                                                 */
/* -------------------------------------------------------------------------- */

export type FormStep = {
  title: string;
  /** Fields validated before moving past this step. */
  fields: string[];
  content: React.ReactNode;
};

export type FormStepsProps = {
  steps: FormStep[];
  submitLabel?: React.ReactNode;
  className?: string;
  /** Controlled step index. */
  step?: number;
  onStepChange?: (step: number) => void;
};

function CheckGlyph() {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      className="size-3"
    >
      <path d="M3.5 8.5 6.5 11.5l6-7" />
    </svg>
  );
}

/**
 * Step header, one visible step at a time (slides with the direction of
 * travel), and Back / Next / Submit. Next validates only that step's fields.
 */
export function FormSteps({
  steps,
  submitLabel = "Submit",
  className,
  step: stepProp,
  onStepChange,
}: FormStepsProps) {
  const form = useFormContext();
  const [stepState, setStepState] = React.useState(0);
  const [direction, setDirection] = React.useState(1);
  const [checking, setChecking] = React.useState(false);
  const step = Math.min(stepProp ?? stepState, steps.length - 1);
  const last = step === steps.length - 1;
  const headingId = React.useId();
  const panelRef = React.useRef<HTMLDivElement>(null);
  const moved = React.useRef(false);

  const go = (next: number) => {
    setDirection(next > step ? 1 : -1);
    moved.current = true;
    setStepState(next);
    onStepChange?.(next);
  };

  React.useEffect(() => {
    // After a step change, move focus to the new step's heading.
    if (!moved.current) return;
    moved.current = false;
    panelRef.current?.querySelector<HTMLElement>(`[data-step-heading="${step}"]`)?.focus();
  }, [step]);

  const next = async () => {
    setChecking(true);
    const ok = await form.validateFields(steps[step].fields);
    setChecking(false);
    if (ok) go(step + 1);
  };

  return (
    <div data-slot="form-steps" className={cn("flex flex-col gap-5", className)}>
      <ol data-slot="form-steps-header" aria-label="Progress" className="flex items-center gap-2">
        {steps.map((s, i) => {
          const done = i < step;
          const current = i === step;
          return (
            <li
              key={s.title}
              aria-current={current ? "step" : undefined}
              className="flex min-w-0 flex-1 flex-col gap-2"
            >
              {/* A track per step that fills ember as you reach it. */}
              <span
                aria-hidden
                className="relative h-1 overflow-hidden rounded-full bg-muted inset-shadow-xs"
              >
                <motion.span
                  className="absolute inset-y-0 start-0 rounded-full bg-accent"
                  initial={false}
                  animate={{ width: done ? "100%" : current ? "50%" : "0%" }}
                  transition={{ type: "spring", stiffness: 420, damping: 32, mass: 0.8 }}
                />
              </span>
              <span className="flex min-w-0 items-center gap-1.5 text-xs">
                <span
                  aria-hidden
                  className={cn(
                    "grid size-4 shrink-0 place-items-center rounded-full font-mono text-[10px] tabular-nums",
                    done &&
                      "bg-linear-to-b from-key-top to-key-bottom text-primary-foreground ring-1 ring-key-edge",
                    current && "text-accent-strong ring-1 ring-accent",
                    !done && !current && "text-muted-foreground ring-1 ring-border",
                  )}
                >
                  {done ? <CheckGlyph /> : i + 1}
                </span>
                <span
                  className={cn(
                    "truncate",
                    current ? "font-medium text-foreground" : "text-muted-foreground",
                  )}
                >
                  {s.title}
                </span>
                <span className="sr-only">
                  {done ? " (done)" : current ? " (current step)" : ""}
                </span>
              </span>
            </li>
          );
        })}
      </ol>

      <div ref={panelRef} className="relative -m-1 overflow-hidden p-1">
        <AnimatePresence initial={false} mode="popLayout" custom={direction}>
          <motion.section
            key={step}
            custom={direction}
            aria-labelledby={headingId}
            variants={{
              enter: (d: number) => ({ opacity: 0, x: d * 24 }),
              center: { opacity: 1, x: 0 },
              exit: (d: number) => ({ opacity: 0, x: d * -24 }),
            }}
            initial="enter"
            animate="center"
            exit="exit"
            transition={{
              x: { type: "spring", stiffness: 420, damping: 32, mass: 0.8 },
              opacity: { duration: 0.16 },
            }}
            className="flex flex-col gap-4"
            onKeyDown={(e) => {
              // Enter in a field moves to the next step (validated) instead
              // of submitting the whole form early.
              const target = e.target as HTMLElement;
              if (e.key === "Enter" && !last && target.tagName === "INPUT") {
                e.preventDefault();
                void next();
              }
            }}
          >
            <h3
              id={headingId}
              data-step-heading={step}
              tabIndex={-1}
              className="text-sm font-medium tracking-[-0.01em] outline-none"
            >
              <span className="me-1.5 font-mono text-xs text-muted-foreground tabular-nums">
                {step + 1}/{steps.length}
              </span>
              {steps[step].title}
            </h3>
            {steps[step].content}
          </motion.section>
        </AnimatePresence>
      </div>

      <div data-slot="form-steps-footer" className="flex items-center justify-between gap-2">
        <Button
          type="button"
          variant="ghost"
          onClick={() => go(step - 1)}
          disabled={step === 0}
          className={cn(step === 0 && "invisible")}
        >
          Back
        </Button>
        {last ? (
          <FormSubmit>{submitLabel}</FormSubmit>
        ) : (
          <Button type="button" variant="primary" loading={checking} onClick={next}>
            Next
          </Button>
        )}
      </div>
    </div>
  );
}
Pebble

Small details.
Better interfaces.

React components to explore, make your own, and build on.

Library

  • Components
  • Installation
  • Pricing

Resources

  • For agents
  • About
  • Support

Legal

  • License
  • Terms
  • Privacy
  • Final sale

Built by Pebble

A product of Praxys Solutions LLC ↗

Pebble Pro is live

$49 one time, lifetime updates ↗