Form

free

Form keeps create and edit flows readable from input to validation.

motion
Preview
Dark
import { useState } from 'react';
import Button from '@/components/ui/Button';
import Form from '@/components/ui/Form';
import Input from '@/components/ui/Input';

export default function UsageDemo() {
  const [saved, setSaved] = useState(false);

  return (
    <Form
      className="mx-auto w-full max-w-md"
      onSubmit={(event) => {
        event.preventDefault();
        setSaved(true);
      }}
    >
      <Form.Field label="Workspace" htmlFor="usage-workspace" asterisk>
        <Input
          id="usage-workspace"
          name="workspace"
          defaultValue="Northwind Sales"
          placeholder="Workspace name"
        />
      </Form.Field>

      <Form.Field label="Owner email" htmlFor="usage-email" asterisk>
        <Input
          id="usage-email"
          name="email"
          type="email"
          defaultValue="owner@example.com"
          placeholder="owner@example.com"
        />
      </Form.Field>

      {saved ? (
        <div className="mb-4 rounded-control border border-success/30 bg-success-soft p-3 text-success">
          Settings saved for this workspace.
        </div>
      ) : null}

      <div className="flex justify-end gap-2">
        <Button type="reset" onClick={() => setSaved(false)}>
          Reset
        </Button>
        <Button type="submit" variant="solid">
          Save changes
        </Button>
      </div>
    </Form>
  );
}

Installation

Add this component with the NateUI CLI.

npx nateui@latest add Form

Examples

Basic

Preview
Dark
import Button from '@/components/ui/Button';
import Form from '@/components/ui/Form';
import Input from '@/components/ui/Input';

export default function BasicDemo() {
  return (
    <Form
      className="mx-auto w-full max-w-md"
      onSubmit={(event) => event.preventDefault()}
    >
      <Form.Field label="Full name" htmlFor="basic-name">
        <Input id="basic-name" name="name" placeholder="Jordan Blake" />
      </Form.Field>

      <Form.Field label="Email" htmlFor="basic-email">
        <Input
          id="basic-email"
          name="email"
          type="email"
          placeholder="jordan@example.com"
        />
      </Form.Field>

      <Form.Field label="Notes" htmlFor="basic-notes">
        <Input
          id="basic-notes"
          name="notes"
          textArea
          placeholder="Add a short note"
        />
      </Form.Field>

      <div className="flex justify-end gap-2">
        <Button type="reset">Reset</Button>
        <Button type="submit" variant="solid">
          Submit
        </Button>
      </div>
    </Form>
  );
}

Form Layout

Preview
Dark
import { useState } from 'react';
import Button from '@/components/ui/Button';
import Form from '@/components/ui/Form';
import Input from '@/components/ui/Input';
import Segment from '@/components/ui/Segment';

type FormLayout = 'vertical' | 'horizontal' | 'inline';

const layouts: { label: string; value: FormLayout }[] = [
  { label: 'Vertical', value: 'vertical' },
  { label: 'Horizontal', value: 'horizontal' },
  { label: 'Inline', value: 'inline' },
];

export default function FormLayoutDemo() {
  const [layout, setLayout] = useState<FormLayout>('vertical');

  return (
    <div className="mx-auto w-full max-w-lg space-y-4">
      <div className="flex justify-center">
        <Segment
          value={layout}
          onChange={(value) => setLayout(value as FormLayout)}
        >
          {layouts.map((item) => (
            <Segment.Item key={item.value} value={item.value}>
              {item.label}
            </Segment.Item>
          ))}
        </Segment>
      </div>

      <Form
        layout={layout}
        labelWidth={120}
        onSubmit={(event) => event.preventDefault()}
      >
        <Form.Field label="Name" htmlFor="layout-name">
          <Input id="layout-name" name="name" placeholder="Jordan Blake" />
        </Form.Field>

        <Form.Field label="Email" htmlFor="layout-email">
          <Input
            id="layout-email"
            name="email"
            type="email"
            placeholder="jordan@example.com"
          />
        </Form.Field>

        <Form.Field>
          <Button type="submit" variant="solid">
            Submit
          </Button>
        </Form.Field>
      </Form>
    </div>
  );
}

Form Size

Preview
Dark
import { useState } from 'react';
import Button from '@/components/ui/Button';
import Form from '@/components/ui/Form';
import Input from '@/components/ui/Input';
import Segment from '@/components/ui/Segment';

type ControlSize = 'sm' | 'md' | 'lg';

const sizes: { label: string; value: ControlSize }[] = [
  { label: 'Small', value: 'sm' },
  { label: 'Medium', value: 'md' },
  { label: 'Large', value: 'lg' },
];

export default function FormSizeDemo() {
  const [size, setSize] = useState<ControlSize>('md');

  return (
    <div className="mx-auto w-full max-w-md space-y-4">
      <div className="flex justify-center">
        <Segment
          value={size}
          onChange={(value) => setSize(value as ControlSize)}
        >
          {sizes.map((item) => (
            <Segment.Item key={item.value} value={item.value}>
              {item.label}
            </Segment.Item>
          ))}
        </Segment>
      </div>

      <Form size={size} onSubmit={(event) => event.preventDefault()}>
        <Form.Field label="Project" htmlFor="size-project">
          <Input id="size-project" name="project" placeholder="Launch plan" />
        </Form.Field>

        <Form.Field label="Owner" htmlFor="size-owner">
          <Input id="size-owner" name="owner" placeholder="Jordan Blake" />
        </Form.Field>

        <div className="flex justify-end gap-2">
          <Button type="reset" size={size}>
            Reset
          </Button>
          <Button type="submit" variant="solid" size={size}>
            Save
          </Button>
        </div>
      </Form>
    </div>
  );
}

Label Extra

Preview
Dark
import Button from '@/components/ui/Button';
import Form from '@/components/ui/Form';
import Input from '@/components/ui/Input';

export default function LabelExtraDemo() {
  return (
    <Form
      className="mx-auto w-full max-w-md"
      onSubmit={(event) => event.preventDefault()}
    >
      <Form.Field
        label="Workspace"
        htmlFor="extra-workspace"
        asterisk
        extra={<span className="text-xs text-muted-foreground">Required</span>}
      >
        <Input
          id="extra-workspace"
          name="workspace"
          placeholder="Northwind Sales"
        />
      </Form.Field>

      <Form.Field
        label="Reference"
        htmlFor="extra-reference"
        extra={<span className="text-xs text-muted-foreground">Optional</span>}
      >
        <Input
          id="extra-reference"
          name="reference"
          placeholder="Internal project code"
        />
      </Form.Field>

      <Form.Field
        label="Summary"
        htmlFor="extra-summary"
        extra={<span className="text-xs text-muted-foreground">0/120</span>}
      >
        <Input
          id="extra-summary"
          name="summary"
          textArea
          placeholder="Short summary"
        />
      </Form.Field>

      <div className="flex justify-end gap-2">
        <Button type="submit" variant="solid">
          Continue
        </Button>
      </div>
    </Form>
  );
}

Message

Preview
Dark
Use lowercase letters, numbers, and dashes.
import { PiQuestion } from 'react-icons/pi'
import Button from '@/components/ui/Button';
import Form from '@/components/ui/Form';
import Input from '@/components/ui/Input';
import Tooltip from '@/components/ui/Tooltip';

export default function MessageDemo() {
  return (
    <Form
      className="mx-auto w-full max-w-md"
      onSubmit={(event) => event.preventDefault()}
    >
      <Form.Field
        label="Workspace slug"
        htmlFor="message-slug"
        hint="Use lowercase letters, numbers, and dashes."
      >
        <Input
          id="message-slug"
          name="slug"
          placeholder="northwind-sales"
        />
      </Form.Field>

      <Form.Field
        label="Billing contact"
        htmlFor="message-billing"
        extra={
          <Tooltip 
            title="This person receives receipts and renewal notices."
            wrapperClass="flex items-center"
          >
            <span
              tabIndex={0}
              aria-label="Billing contact help"
              className="inline-flex cursor-help items-center text-muted-foreground"
            >
              <PiQuestion className="text-base" />
            </span>
          </Tooltip>
        }
      >
        <Input
          id="message-billing"
          name="billing"
          type="email"
          placeholder="billing@example.com"
        />
      </Form.Field>

      <div className="flex justify-end gap-2">
        <Button type="submit" variant="solid">
          Save
        </Button>
      </div>
    </Form>
  );
}

Validation

Preview
Dark
import { useState, type FormEvent } from 'react';
import Button from '@/components/ui/Button';
import Form from '@/components/ui/Form';
import Input from '@/components/ui/Input';

type FormValues = {
  name: string;
  email: string;
  handle: string;
};

type FormErrors = Partial<Record<keyof FormValues, string>>;

const initialValues: FormValues = {
  name: '',
  email: '',
  handle: '',
};

function validate(values: FormValues) {
  const nextErrors: FormErrors = {};

  if (!values.name.trim()) {
    nextErrors.name = 'Enter a name.';
  }

  if (!values.email.trim()) {
    nextErrors.email = 'Enter an email address.';
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
    nextErrors.email = 'Enter a valid email address.';
  }

  if (!values.handle.trim()) {
    nextErrors.handle = 'Enter a handle.';
  } else if (!/^[a-z0-9-]+$/.test(values.handle)) {
    nextErrors.handle = 'Use lowercase letters, numbers, and dashes.';
  }

  return nextErrors;
}

export default function ValidationDemo() {
  const [values, setValues] = useState<FormValues>(initialValues);
  const [errors, setErrors] = useState<FormErrors>({});
  const [submitted, setSubmitted] = useState(false);

  const updateField = (field: keyof FormValues, value: string) => {
    setValues((current) => ({ ...current, [field]: value }));
    setErrors((current) => {
      const { [field]: _removed, ...rest } = current;
      return rest;
    });
    setSubmitted(false);
  };

  const handleSubmit = (event: FormEvent) => {
    event.preventDefault();

    const nextErrors = validate(values);
    setErrors(nextErrors);
    setSubmitted(Object.keys(nextErrors).length === 0);
  };

  return (
    <Form className="mx-auto w-full max-w-md" noValidate onSubmit={handleSubmit}>
      <Form.Field
        label="Name"
        htmlFor="validation-name"
        asterisk
        invalid={Boolean(errors.name)}
        errorMessage={errors.name}
      >
        <Input
          id="validation-name"
          name="name"
          value={values.name}
          placeholder="Jordan Blake"
          onChange={(event) => updateField('name', event.target.value)}
        />
      </Form.Field>

      <Form.Field
        label="Email"
        htmlFor="validation-email"
        asterisk
        invalid={Boolean(errors.email)}
        errorMessage={errors.email}
      >
        <Input
          id="validation-email"
          name="email"
          type="email"
          value={values.email}
          placeholder="jordan@example.com"
          onChange={(event) => updateField('email', event.target.value)}
        />
      </Form.Field>

      <Form.Field
        label="Handle"
        htmlFor="validation-handle"
        invalid={Boolean(errors.handle)}
        errorMessage={errors.handle}
      >
        <Input
          id="validation-handle"
          name="handle"
          value={values.handle}
          placeholder="jordan-blake"
          onChange={(event) => updateField('handle', event.target.value)}
        />
      </Form.Field>

      {submitted ? (
        <div className="mb-4 rounded-control border border-success/30 bg-success-soft p-3 text-success">
          Validation passed. The form is ready to submit.
        </div>
      ) : null}

      <div className="flex justify-end gap-2">
        <Button
          type="button"
          onClick={() => {
            setValues(initialValues);
            setErrors({});
            setSubmitted(false);
          }}
        >
          Reset
        </Button>
        <Button type="submit" variant="solid">
          Validate
        </Button>
      </div>
    </Form>
  );
}

API

Form is a compound component: the root Form, Form.Field for label/control/error composition, and Form.Scope for nested layout or sizing context.

Form

PropDescriptionTypeDefault
childrenFields, scopes, and form content rendered inside the native form.ReactNode-
classNameClass names applied to the native form element.string-
containerClassNameClass names applied to the internal Form.Scope wrapper.string-
labelWidthLabel width shared by child fields when the layout is horizontal.string | number100
layoutLayout shared by child fields.FormLayout'vertical'
refRef forwarded to the native form element.Ref<HTMLFormElement>-
sizeControl size shared by descendant form-aware controls.ControlSize'md'
...nativeFormPropsNative props applied to the form element.NativeFormProps-

Form.Field

PropDescriptionTypeDefault
asteriskShows a destructive required marker before the label.boolean-
childrenThe control or content rendered under the label.ReactNode-
classNameClass names applied to the field wrapper.string-
errorMessageMessage rendered below the control when invalid is true, replacing hint while the field is invalid.string-
extraExtra label-side content, such as optional text or a compact count.string | ReactNode-
hintHelper text rendered below the control when there is no invalid error message.string | ReactNode-
htmlForAssociates the label with a control id.string-
invalidMarks the field invalid and provides invalid context to child controls.boolean-
labelField label content.string | ReactNode-
labelClassClass names applied to the label element.string-
labelIdId applied to the label element.string-
labelWidthOverrides the scoped label width for this field.string | number-
layoutOverrides the scoped layout for this field.FormLayout-
refRef forwarded to the field wrapper.Ref<HTMLDivElement>-
sizeOverrides the scoped control size for this field label.ControlSize-
...nativeDivPropsNative props applied to the field wrapper.NativeFieldProps-

Form.Scope

PropDescriptionTypeDefault
childrenForm fields that should share the scoped layout and size settings.ReactNode-
classNameClass names applied to the scope wrapper.string-
labelWidthLabel width shared by child fields when horizontal.string | number100
layoutLayout shared by child fields.FormLayout'vertical'
sizeControl size shared by descendant form-aware controls.ControlSize'md'
...nativeDivPropsNative props applied to the scope wrapper.NativeScopeProps-

Types

type FormLayout = 'horizontal' | 'vertical' | 'inline';
type ControlSize = 'sm' | 'md' | 'lg';
type NativeFormProps = ComponentPropsWithoutRef<'form'>;
type NativeFieldProps = ComponentProps<'div'>;
type NativeScopeProps = ComponentProps<'div'>;