Form
freeForm 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 FormExamples
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
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
| Prop | Description | Type | Default |
|---|---|---|---|
children | Fields, scopes, and form content rendered inside the native form. | ReactNode | - |
className | Class names applied to the native form element. | string | - |
containerClassName | Class names applied to the internal Form.Scope wrapper. | string | - |
labelWidth | Label width shared by child fields when the layout is horizontal. | string | number | 100 |
layout | Layout shared by child fields. | FormLayout | 'vertical' |
ref | Ref forwarded to the native form element. | Ref<HTMLFormElement> | - |
size | Control size shared by descendant form-aware controls. | ControlSize | 'md' |
...nativeFormProps | Native props applied to the form element. | NativeFormProps | - |
Form.Field
| Prop | Description | Type | Default |
|---|---|---|---|
asterisk | Shows a destructive required marker before the label. | boolean | - |
children | The control or content rendered under the label. | ReactNode | - |
className | Class names applied to the field wrapper. | string | - |
errorMessage | Message rendered below the control when invalid is true, replacing hint while the field is invalid. | string | - |
extra | Extra label-side content, such as optional text or a compact count. | string | ReactNode | - |
hint | Helper text rendered below the control when there is no invalid error message. | string | ReactNode | - |
htmlFor | Associates the label with a control id. | string | - |
invalid | Marks the field invalid and provides invalid context to child controls. | boolean | - |
label | Field label content. | string | ReactNode | - |
labelClass | Class names applied to the label element. | string | - |
labelId | Id applied to the label element. | string | - |
labelWidth | Overrides the scoped label width for this field. | string | number | - |
layout | Overrides the scoped layout for this field. | FormLayout | - |
ref | Ref forwarded to the field wrapper. | Ref<HTMLDivElement> | - |
size | Overrides the scoped control size for this field label. | ControlSize | - |
...nativeDivProps | Native props applied to the field wrapper. | NativeFieldProps | - |
Form.Scope
| Prop | Description | Type | Default |
|---|---|---|---|
children | Form fields that should share the scoped layout and size settings. | ReactNode | - |
className | Class names applied to the scope wrapper. | string | - |
labelWidth | Label width shared by child fields when horizontal. | string | number | 100 |
layout | Layout shared by child fields. | FormLayout | 'vertical' |
size | Control size shared by descendant form-aware controls. | ControlSize | 'md' |
...nativeDivProps | Native 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'>;