Todos os componentes

Form

Form é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT forms

O que é

Form é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i lucide-react motion

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Form

index.tsx
"use client";

import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type React from "react";
import {
  cloneElement,
  createContext,
  useCallback,
  useContext,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import {
  DURATION_INSTANT,
  SPRING_DEFAULT,
  SPRING_SNAPPY,
} from "@/components/smoothui/lib/animation";

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const STAGGER_DELAY = 0.04;
const SHAKE_KEYFRAMES = [0, -6, 5, -4, 3, -1, 0];

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export type FormErrors = Record<string, string | undefined>;

export interface FormProps extends React.ComponentProps<"form"> {
  /** Form contents */
  children: React.ReactNode;
  /** Optional CSS class */
  className?: string;
  /** External errors object (e.g. from react-hook-form's `formState.errors`) */
  errors?: FormErrors;
  /** Callback invoked on native form submit with current errors map */
  onFormSubmit?: (e: React.FormEvent<HTMLFormElement>) => void;
}

export interface FormFieldProps {
  /** Field contents (label, input, message) */
  children: React.ReactNode;
  /** Optional CSS class for the field wrapper */
  className?: string;
  /** Unique field name — used to look up errors */
  name: string;
}

export interface FormLabelProps extends React.ComponentProps<"label"> {
  /** Label text */
  children: React.ReactNode;
  /** Optional CSS class */
  className?: string;
}

export interface FormMessageProps {
  /** Override the error message (otherwise pulled from FormField context) */
  children?: React.ReactNode;
  /** Optional CSS class */
  className?: string;
}

export interface FormDescriptionProps extends React.ComponentProps<"p"> {
  /** Description text */
  children: React.ReactNode;
  /** Optional CSS class */
  className?: string;
}

// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------

interface FormContextValue {
  errors: FormErrors;
  prevErrors: FormErrors;
  submitCount: number;
}

interface FormFieldContextValue {
  error: string | undefined;
  fieldIndex: number;
  formDescriptionId: string;
  formItemId: string;
  formMessageId: string;
  id: string;
  name: string;
  prevError: string | undefined;
  submitCount: number;
}

const FormContext = createContext<FormContextValue>({
  errors: {},
  prevErrors: {},
  submitCount: 0,
});
const FormFieldContext = createContext<FormFieldContextValue | null>(null);

const useFormCtx = () => useContext(FormContext);

const useFormFieldCtx = () => {
  const ctx = useContext(FormFieldContext);
  if (!ctx) {
    throw new Error("FormLabel / FormMessage must be used inside <FormField>");
  }
  return ctx;
};

// ---------------------------------------------------------------------------
// Form
// ---------------------------------------------------------------------------

export default function Form({
  errors = {},
  onFormSubmit,
  className,
  children,
  ...props
}: FormProps) {
  const [submitCount, setSubmitCount] = useState(0);
  const prevErrorsRef = useRef<FormErrors>({});
  const [prevErrors, setPrevErrors] = useState<FormErrors>({});

  const ctxValue = useMemo(
    () => ({ errors, prevErrors, submitCount }),
    [errors, submitCount, prevErrors]
  );

  const handleSubmit = useCallback(
    (e: React.FormEvent<HTMLFormElement>) => {
      setPrevErrors(prevErrorsRef.current);
      prevErrorsRef.current = errors;
      setSubmitCount((c) => c + 1);
      if (onFormSubmit) {
        onFormSubmit(e);
      }
    },
    [onFormSubmit, errors]
  );

  return (
    <FormContext.Provider value={ctxValue}>
      <form
        className={cn("grid gap-3", className)}
        noValidate
        onSubmit={handleSubmit}
        {...props}
      >
        {children}
      </form>
    </FormContext.Provider>
  );
}

// ---------------------------------------------------------------------------
// FormField — staggered entrance + validation shake
// ---------------------------------------------------------------------------

let fieldCounter = 0;

export function FormField({ name, className, children }: FormFieldProps) {
  const { errors, submitCount, prevErrors } = useFormCtx();
  const id = useId();
  const error = errors[name];
  const prevError = prevErrors[name];

  // Stable field index for stagger animation
  const fieldIndexRef = useRef<number | null>(null);
  if (fieldIndexRef.current === null) {
    fieldIndexRef.current = fieldCounter;
    fieldCounter += 1;
  }

  // Reset counter on unmount of the first field (index 0)
  useEffect(
    () => () => {
      if (fieldIndexRef.current === 0) {
        fieldCounter = 0;
      }
    },
    []
  );

  const ctxValue = useMemo(
    () => ({
      error,
      fieldIndex: fieldIndexRef.current ?? 0,
      formDescriptionId: `${id}-form-item-description`,
      formItemId: `${id}-form-item`,
      formMessageId: `${id}-form-item-message`,
      id,
      name,
      prevError,
      submitCount,
    }),
    [name, id, error, submitCount, prevError]
  );

  return (
    <FormFieldContext.Provider value={ctxValue}>
      <FormFieldInner className={className}>{children}</FormFieldInner>
    </FormFieldContext.Provider>
  );
}

/** Inner component that can consume FormFieldContext */
function FormFieldInner({
  className,
  children,
}: {
  className?: string;
  children: React.ReactNode;
}) {
  const shouldReduceMotion = useReducedMotion();
  const { error, fieldIndex, submitCount, prevError } = useFormFieldCtx();

  // Shake when a new error appears on submit
  const shouldShake = error && submitCount > 0;
  const [shakeKey, setShakeKey] = useState(0);

  useEffect(() => {
    if (shouldShake) {
      setShakeKey((k) => k + 1);
    }
  }, [shouldShake, submitCount]);

  return (
    <motion.div
      animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
      className={cn("grid gap-1.5", className)}
      data-slot="form-field"
      initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 8 }}
      transition={
        shouldReduceMotion
          ? DURATION_INSTANT
          : {
              ...SPRING_DEFAULT,
              delay: fieldIndex * STAGGER_DELAY,
            }
      }
    >
      <motion.div
        animate={
          shouldShake && !shouldReduceMotion ? { x: SHAKE_KEYFRAMES } : { x: 0 }
        }
        className="grid gap-1.5"
        key={shakeKey}
        transition={
          shouldReduceMotion
            ? DURATION_INSTANT
            : { duration: 0.4, ease: [0.36, 0.07, 0.19, 0.97] }
        }
      >
        {children}
      </motion.div>
    </motion.div>
  );
}

// ---------------------------------------------------------------------------
// FormLabel
// ---------------------------------------------------------------------------

export function FormLabel({ className, children, ...props }: FormLabelProps) {
  const { formItemId, error } = useFormFieldCtx();

  return (
    <label
      className={cn(
        "font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
        error && "text-destructive",
        className
      )}
      data-slot="form-label"
      htmlFor={formItemId}
      {...props}
    >
      {children}
    </label>
  );
}

// ---------------------------------------------------------------------------
// FormControl — renders a wrapper with animated focus ring
// ---------------------------------------------------------------------------

export function FormControl({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) {
  const shouldReduceMotion = useReducedMotion();
  const { formItemId, formDescriptionId, formMessageId, error } =
    useFormFieldCtx();
  const [isFocused, setIsFocused] = useState(false);

  return (
    <motion.div
      animate={
        shouldReduceMotion
          ? {}
          : {
              boxShadow: isFocused
                ? "0 0 0 3px hsl(var(--ring) / 0.3)"
                : "0 0 0 0px hsl(var(--ring) / 0)",
            }
      }
      className={cn("rounded-md", className)}
      data-slot="form-control"
      onBlur={() => setIsFocused(false)}
      onFocus={() => setIsFocused(true)}
      transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_SNAPPY}
    >
      {cloneChildWithA11y(children, {
        "aria-describedby": error
          ? `${formDescriptionId} ${formMessageId}`
          : formDescriptionId,
        "aria-invalid": error ? true : undefined,
        id: formItemId,
      })}
    </motion.div>
  );
}

function cloneChildWithA11y(
  children: React.ReactNode,
  a11yProps: Record<string, unknown>
): React.ReactNode {
  const child = Array.isArray(children) ? children[0] : children;
  if (child && typeof child === "object" && "type" in child) {
    const element = child as React.ReactElement<Record<string, unknown>>;
    // biome-ignore lint/suspicious/noExplicitAny: cloneElement requires flexible typing
    return cloneElement(element as any, a11yProps);
  }
  return children;
}

// ---------------------------------------------------------------------------
// FormDescription
// ---------------------------------------------------------------------------

export function FormDescription({
  className,
  children,
  ...props
}: FormDescriptionProps) {
  const { formDescriptionId } = useFormFieldCtx();

  return (
    <p
      className={cn("text-muted-foreground text-sm", className)}
      data-slot="form-description"
      id={formDescriptionId}
      {...props}
    >
      {children}
    </p>
  );
}

// ---------------------------------------------------------------------------
// FormMessage — animated error message with success state
// ---------------------------------------------------------------------------

export function FormMessage({ className, children }: FormMessageProps) {
  const shouldReduceMotion = useReducedMotion();
  const { error, formMessageId, submitCount, prevError } = useFormFieldCtx();

  const body = children ?? error;

  // Show success checkmark when error was just cleared after a submit
  const wasError = prevError && !error && submitCount > 0;

  return (
    <div>
      <AnimatePresence mode="wait">
        {body ? (
          <motion.p
            animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
            className={cn("text-destructive text-sm", className)}
            data-slot="form-message"
            exit={
              shouldReduceMotion
                ? { opacity: 0, transition: { duration: 0 } }
                : { opacity: 0, y: -4 }
            }
            id={formMessageId}
            initial={
              shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: -4 }
            }
            key={typeof body === "string" ? body : "message"}
            role="alert"
            transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_DEFAULT}
          >
            {body}
          </motion.p>
        ) : wasError ? (
          <motion.div
            animate={
              shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }
            }
            className="flex items-center gap-1 text-sm"
            exit={
              shouldReduceMotion
                ? { opacity: 0, transition: { duration: 0 } }
                : { opacity: 0, scale: 0.9 }
            }
            initial={
              shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }
            }
            key="success"
            transition={
              shouldReduceMotion
                ? DURATION_INSTANT
                : {
                    damping: 20,
                    duration: 0.25,
                    stiffness: 300,
                    type: "spring" as const,
                  }
            }
          >
            <motion.span
              animate={shouldReduceMotion ? {} : { scale: 1 }}
              initial={shouldReduceMotion ? {} : { scale: 0 }}
              transition={
                shouldReduceMotion
                  ? DURATION_INSTANT
                  : {
                      damping: 15,
                      delay: 0.05,
                      duration: 0.2,
                      stiffness: 400,
                      type: "spring" as const,
                    }
              }
            >
              <Check className="size-3.5 text-emerald-500" />
            </motion.span>
            <span className="text-emerald-500">Looks good</span>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  );
}

Componentes parecidos