Fluent UI Forms — Knowledge Base
Overview
Fluent UI React v9 provides a comprehensive set of form components that follow the Microsoft design language. The key differentiator from raw HTML forms is the Field component — a wrapper that unifies label, validation message, hint text, and required indicator for any input.
Key packages:
@fluentui/react-components— All form components (Field, Input, Textarea, Select, etc.)@fluentui/react-datepicker-compat— DatePicker component (compat layer)@fluentui/react-timepicker-compat— TimePicker component (compat layer)
Form architecture in Fluent v9:
FluentProvider (theme context)
└── <form> (native HTML)
└── Field (label + validation + hint wrapper)
└── Input / Textarea / Select / Combobox / etc. (actual control)
Fluent form components are controlled by default — they accept value and onChange props.
They integrate with any state management or form library because they follow standard React
controlled component conventions.
External resources:
- Field component: https://react.fluentui.dev/?path=/docs/components-field--docs
- Input component: https://react.fluentui.dev/?path=/docs/components-input--docs
- Form patterns: https://fluent2.microsoft.design/components/web/react/field
Form Components
Field — The Universal Wrapper
The Field component is the foundation of every Fluent form. It wraps any input component and
provides consistent label placement, validation messages, hint text, and required indicators.
import { Field, Input } from "@fluentui/react-components";
<Field
label="Email address"
validationMessage="Please enter a valid email"
validationState="error"
hint="We'll never share your email"
required
>
<Input type="email" value={email} />
</Field>
Field props:
| Prop | Type | Description |
|---|---|---|
label |
string | JSX |
Label text above or beside the input |
validationMessage |
string | JSX |
Message shown below input during validation |
validationState |
"error" | "warning" | "success" | "none" |
Controls icon and color of validation message |
hint |
string | JSX |
Persistent help text below the input |
required |
boolean | string |
Shows required indicator; string overrides default text |
orientation |
"vertical" | "horizontal" |
Layout direction for label relative to input |
size |
"small" | "medium" | "large" |
Adjusts label and message sizing |
Important: Field does NOT render an input itself. It always wraps a child input component. This composable design means Field works with Input, Textarea, Combobox, Dropdown, DatePicker, or any custom input component.
Input
Standard text input for single-line text entry.
import { Input } from "@fluentui/react-components";
<Input
type="text" // "text" | "email" | "password" | "number" | "tel" | "url"
value={value}
data) => setValue(data.value)}
placeholder="Enter text..."
contentBefore={<SearchRegular />} // Icon or text before input
contentAfter={<DismissRegular />} // Icon or button after input
appearance="outline" // "outline" | "underline" | "filled-darker" | "filled-lighter"
size="medium" // "small" | "medium" | "large"
disabled={false}
/>
Key point: The onChange callback receives (event, data) where data.value is the new
string value. This is different from native React where you use event.target.value.
Textarea
Multi-line text input with auto-resize support.
import { Textarea } from "@fluentui/react-components";
<Textarea
value={value}
data) => setValue(data.value)}
placeholder="Enter description..."
resize="vertical" // "none" | "horizontal" | "vertical" | "both"
rows={4}
appearance="outline"
/>
Select
Native HTML select wrapper styled with Fluent tokens.
import { Select } from "@fluentui/react-components";
<Field label="Country">
<Select value={country} data) => setCountry(data.value)}>
<option value="">Select a country...</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="de">Germany</option>
</Select>
</Field>
Combobox and Dropdown
For richer selection experiences with filtering, multi-select, and custom rendering.
import { Combobox, Option } from "@fluentui/react-components";
<Field label="Assign to">
<Combobox
value={selectedUser}
data) => setSelectedUser(data.optionText ?? "")}
placeholder="Search users..."
freeform // Allows typed text not in options
multiselect // Multiple selection mode
>
<Option value="user1">Alice Johnson</Option>
<Option value="user2">Bob Smith</Option>
<Option value="user3">Carol Williams</Option>
</Combobox>
</Field>
Dropdown is similar but without the text input — it's a button that opens a listbox:
import { Dropdown, Option } from "@fluentui/react-components";
<Field label="Priority">
<Dropdown
value={priority}
data) => setPriority(data.optionText ?? "")}
placeholder="Select priority"
>
<Option>Low</Option>
<Option>Medium</Option>
<Option>High</Option>
<Option>Critical</Option>
</Dropdown>
</Field>
Checkbox, RadioGroup, Switch
import { Checkbox, RadioGroup, Radio, Switch } from "@fluentui/react-components";
// Checkbox
<Checkbox
label="Accept terms and conditions"
checked={accepted}
data) => setAccepted(data.checked)}
/>
// RadioGroup
<Field label="Notification preference">
<RadioGroup value={pref} data) => setPref(data.value)}>
<Radio value="email" label="Email" />
<Radio value="sms" label="SMS" />
<Radio value="push" label="Push notification" />
</RadioGroup>
</Field>
// Switch
<Switch
label="Enable dark mode"
checked={darkMode}
data) => setDarkMode(data.checked)}
/>
SpinButton, Slider, Rating
import { SpinButton, Slider } from "@fluentui/react-components";
import { Rating } from "@fluentui/react-components";
// SpinButton — numeric input with increment/decrement
<Field label="Quantity">
<SpinButton
value={qty}
data) => setQty(data.value ?? 0)}
min={1}
max={100}
step={1}
/>
</Field>
// Slider — continuous or discrete range
<Field label="Volume">
<Slider
value={volume}
data) => setVolume(data.value)}
min={0}
max={100}
step={5}
/>
</Field>
// Rating — star rating input
<Rating
value={rating}
data) => setRating(data.value)}
max={5}
/>
DatePicker and TimePicker
These are compat components that wrap the v8 implementations with v9 styling:
import { DatePicker } from "@fluentui/react-datepicker-compat";
import { TimePicker } from "@fluentui/react-timepicker-compat";
// DatePicker
<Field label="Start date">
<DatePicker
value={startDate}
=> setStartDate(date)}
placeholder="Select a date..."
formatDate={(date) => date?.toLocaleDateString() ?? ""}
/>
</Field>
// TimePicker
<Field label="Meeting time">
<TimePicker
selectedTime={meetingTime}
data) => setMeetingTime(data.selectedTime)}
startHour={8}
endHour={18}
increment={30} // 30-minute intervals
/>
</Field>
Install separately:
npm install @fluentui/react-datepicker-compat @fluentui/react-timepicker-compat
Field Component Deep-Dive
Validation States
Field supports four validation states, each rendering a different icon and color:
// Error — red icon, red text
<Field validationState="error" validationMessage="This field is required">
<Input />
</Field>
// Warning — yellow icon, yellow text
<Field validationState="warning" validationMessage="Password is weak">
<Input type="password" />
</Field>
// Success — green icon, green text
<Field validationState="success" validationMessage="Username is available">
<Input />
</Field>
// None — no icon, neutral text (informational)
<Field validationState="none" validationMessage="Must be 8+ characters">
<Input type="password" />
</Field>
Label and Hint
// Label with required indicator
<Field label="Full name" required>
<Input />
</Field>
// Custom required text
<Field label="Email" required="(mandatory)">
<Input type="email" />
</Field>
// Hint text — persistent help below the input
<Field label="Password" hint="Use at least 8 characters with a mix of letters and numbers">
<Input type="password" />
</Field>
Orientation
// Vertical (default) — label above input
<Field label="Name" orientation="vertical">
<Input />
</Field>
// Horizontal — label beside input (useful for settings forms)
<Field label="Display name" orientation="horizontal">
<Input />
</Field>
Horizontal orientation renders the label in a fixed-width column (typically 33%) with the input occupying the remaining space.
Formik + Yup Integration
Reference: Paul Gildea's guide — https://dev.to/paulgildea/fluent-ui-react-v9-with-formik-and-yup-523g
Setup
npm install formik yup
npm install @types/yup # TypeScript users — Yup ships types since v1
Creating Fluent-Wrapped Formik Fields
The key pattern is creating adapter components that bridge Formik's field state with Fluent's Field + Input components:
import { Field, Input } from "@fluentui/react-components";
import { useField } from "formik";
interface FluentFieldProps {
name: string;
label: string;
type?: string;
required?: boolean;
hint?: string;
placeholder?: string;
}
export const FluentTextField: React.FC<FluentFieldProps> = ({
name,
label,
type = "text",
required = false,
hint,
placeholder,
}) => {
const [field, meta] = useField(name);
const hasError = meta.touched && !!meta.error;
return (
<Field
label={label}
required={required}
hint={hint}
validationState={hasError ? "error" : "none"}
validationMessage={hasError ? meta.error : undefined}
>
<Input
type={type}
name={field.name}
value={field.value}
data) => {
field.onChange({ target: { name: field.name, value: data.value } });
}}
placeholder={placeholder}
/>
</Field>
);
};
Important: Formik's field.onChange expects a React synthetic event with target.name and
target.value. Since Fluent's Input onChange provides (event, data), you must construct the
expected shape manually.
Validation Schema with Yup
import * as Yup from "yup";
const signUpSchema = Yup.object({
firstName: Yup.string()
.min(2, "Must be at least 2 characters")
.required("First name is required"),
lastName: Yup.string()
.min(2, "Must be at least 2 characters")
.required("Last name is required"),
email: Yup.string()
.email("Invalid email address")
.required("Email is required"),
password: Yup.string()
.min(8, "Must be at least 8 characters")
.matches(/[A-Z]/, "Must contain an uppercase letter")
.matches(/[0-9]/, "Must contain a number")
.required("Password is required"),
confirmPassword: Yup.string()
.oneOf([Yup.ref("password")], "Passwords must match")
.required("Please confirm your password"),
});
Complete Form Example
import { Formik, Form } from "formik";
import { Button, makeStyles, tokens } from "@fluentui/react-components";
const useStyles = makeStyles({
form: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalM,
maxWidth: "480px",
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: tokens.spacingHorizontalS,
marginTop: tokens.spacingVerticalL,
},
});
export const SignUpForm: React.FC = () => {
const classes = useStyles();
return (
<Formik
initialValues={{
firstName: "",
lastName: "",
email: "",
password: "",
confirmPassword: "",
}}
validationSchema={signUpSchema}
{ setSubmitting }) => {
console.log("Form submitted:", values);
setSubmitting(false);
}}
>
{({ isSubmitting }) => (
<Form className={classes.form}>
<FluentTextField name="firstName" label="First name" required />
<FluentTextField name="lastName" label="Last name" required />
<FluentTextField name="email" label="Email" type="email" required />
<FluentTextField name="password" label="Password" type="password" required />
<FluentTextField
name="confirmPassword"
label="Confirm password"
type="password"
required
/>
<div className={classes.actions}>
<Button appearance="secondary" type="reset">
Clear
</Button>
<Button appearance="primary" type="submit" disabled={isSubmitting}>
Sign Up
</Button>
</div>
</Form>
)}
</Formik>
);
};
React Hook Form + Zod Integration
Setup
npm install react-hook-form @hookform/resolvers zod
Controller Pattern for Fluent Components
React Hook Form's Controller component bridges RHF with controlled Fluent components:
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Field, Input } from "@fluentui/react-components";
import { z } from "zod";
const schema = z.object({
displayName: z.string().min(2, "Must be at least 2 characters"),
email: z.string().email("Invalid email"),
age: z.number().min(18, "Must be at least 18").max(120, "Invalid age"),
});
type FormData = z.infer<typeof schema>;
export const ProfileForm: React.FC = () => {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { displayName: "", email: "", age: 18 },
});
const FormData) => {
console.log("Submitted:", data);
};
return (
<form
<Controller
name="displayName"
control={control}
render={({ field }) => (
<Field
label="Display name"
required
validationState={errors.displayName ? "error" : "none"}
validationMessage={errors.displayName?.message}
>
<Input
value={field.value}
data) => field.onChange(data.value)}
/>
</Field>
)}
/>
<Controller
name="email"
control={control}
render={({ field }) => (
<Field
label="Email"
required
validationState={errors.email ? "error" : "none"}
validationMessage={errors.email?.message}
>
<Input
type="email"
value={field.value}
data) => field.onChange(data.value)}
/>
</Field>
)}
/>
<Button appearance="primary" type="submit">
Save Profile
</Button>
</form>
);
};
Reusable Controller Wrapper
To reduce boilerplate, create a generic wrapper:
import { Controller, useFormContext, FieldPath, FieldValues } from "react-hook-form";
import { Field, Input, FieldProps } from "@fluentui/react-components";
interface FluentControlledFieldProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
type?: string;
required?: boolean;
hint?: string;
placeholder?: string;
}
export function FluentControlledField<T extends FieldValues>({
name,
label,
type = "text",
required = false,
hint,
placeholder,
}: FluentControlledFieldProps<T>) {
const { control, formState: { errors } } = useFormContext<T>();
const error = errors[name];
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<Field
label={label}
required={required}
hint={hint}
validationState={error ? "error" : "none"}
validationMessage={error?.message as string | undefined}
>
<Input
type={type}
value={field.value ?? ""}
data) => field.onChange(data.value)}
placeholder={placeholder}
/>
</Field>
)}
/>
);
}
Multi-Step Wizard Pattern
Step State Management
import React, { useState, useCallback } from "react";
import {
Button,
ProgressBar,
makeStyles,
tokens,
Text,
} from "@fluentui/react-components";
interface WizardStep {
title: string;
component: React.ComponentType<WizardStepProps>;
validate?: () => boolean | Promise<boolean>;
}
interface WizardStepProps {
data: Record<string, unknown>;
onUpdate: (updates: Record<string, unknown>) => void;
}
const useStyles = makeStyles({
wizard: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalL,
maxWidth: "600px",
},
header: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
},
navigation: {
display: "flex",
justifyContent: "space-between",
marginTop: tokens.spacingVerticalXL,
},
});
export const FormWizard: React.FC<{ steps: WizardStep[]; onComplete: (data: Record<string, unknown>) => void }> = ({
steps,
onComplete,
}) => {
const classes = useStyles();
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const handleUpdate = useCallback((updates: Record<string, unknown>) => {
setFormData((prev) => ({ ...prev, ...updates }));
}, []);
const handleNext = async () => {
const step = steps[currentStep];
if (step.validate) {
const isValid = await step.validate();
if (!isValid) return;
}
if (currentStep < steps.length - 1) {
setCurrentStep((s) => s + 1);
} else {
onComplete(formData);
}
};
const handleBack = () => {
setCurrentStep((s) => Math.max(0, s - 1));
};
const CurrentComponent = steps[currentStep].component;
const progress = (currentStep + 1) / steps.length;
return (
<div className={classes.wizard}>
<div className={classes.header}>
<Text weight="semibold" size={500}>
Step {currentStep + 1} of {steps.length}: {steps[currentStep].title}
</Text>
<ProgressBar value={progress} />
</div>
<CurrentComponent data={formData} />
<div className={classes.navigation}>
<Button
appearance="secondary"
disabled={currentStep === 0}
>
Back
</Button>
<Button appearance="primary"
{currentStep === steps.length - 1 ? "Submit" : "Next"}
</Button>
</div>
</div>
);
};
Per-Step Validation
Each step can use its own validation schema. With React Hook Form + Zod:
const step1Schema = z.object({
firstName: z.string().min(1, "Required"),
lastName: z.string().min(1, "Required"),
});
const step2Schema = z.object({
email: z.string().email("Invalid email"),
phone: z.string().regex(/^\+?[\d\s-]{10,}$/, "Invalid phone number"),
});
const step3Schema = z.object({
plan: z.enum(["basic", "pro", "enterprise"]),
acceptTerms: z.literal(true, { errorMap: () => ({ message: "You must accept the terms" }) }),
});
Form Accessibility
Error Announcements with aria-live
When validation errors appear dynamically, they must be announced to screen readers:
// Field handles this automatically — validationMessage is wrapped in an aria-live region.
// For custom error summaries, use aria-live explicitly:
<div role="alert" aria-live="assertive">
{Object.keys(errors).length > 0 && (
<Text>
Please fix {Object.keys(errors).length} error(s) before submitting.
</Text>
)}
</div>
Required Field Indicators
Field's required prop automatically:
- Adds a visual asterisk (*) after the label
- Sets
aria-required="true"on the input - Optionally renders custom required text
<Field label="Email" required> {/* Shows "*" */}
<Input type="email" />
</Field>
<Field label="Email" required="(required)"> {/* Shows "(required)" */}
<Input type="email" />
</Field>
Field Grouping
For related fields, use semantic grouping:
<fieldset style={{ border: "none", padding: 0, margin: 0 }}>
<legend>
<Text weight="semibold" size={400}>Contact Information</Text>
</legend>
<Field label="Phone" required>
<Input type="tel" />
</Field>
<Field label="Email" required>
<Input type="email" />
</Field>
</fieldset>
Focus Management on Validation Errors
After form submission with errors, move focus to the first invalid field:
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const isValid = await validate();
if (!isValid) {
// Focus the first field with an error
const firstError = document.querySelector('[aria-invalid="true"]');
if (firstError instanceof HTMLElement) {
firstError.focus();
}
}
};
Common Form Patterns
Login Form
export const LoginForm: React.FC = () => {
const classes = useFormStyles();
return (
<Formik
initialValues={{ email: "", password: "", rememberMe: false }}
validationSchema={Yup.object({
email: Yup.string().email("Invalid email").required("Required"),
password: Yup.string().required("Required"),
})}
>
{({ isSubmitting }) => (
<Form className={classes.form}>
<FluentTextField name="email" label="Email" type="email" required />
<FluentTextField name="password" label="Password" type="password" required />
<Checkbox name="rememberMe" label="Remember me" />
<Button appearance="primary" type="submit" disabled={isSubmitting}>
Sign in
</Button>
<Link href="/forgot-password">Forgot password?</Link>
</Form>
)}
</Formik>
);
};
Settings Form with Sections
const useStyles = makeStyles({
section: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalM,
paddingBottom: tokens.spacingVerticalXL,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
marginBottom: tokens.spacingVerticalXL,
},
});
export const SettingsForm: React.FC = () => {
const classes = useStyles();
return (
<form>
<div className={classes.section}>
<Text as="h2" size={500} weight="semibold">Profile</Text>
<Field label="Display name" orientation="horizontal">
<Input />
</Field>
<Field label="Bio" orientation="horizontal">
<Textarea resize="vertical" />
</Field>
</div>
<div className={classes.section}>
<Text as="h2" size={500} weight="semibold">Notifications</Text>
<Switch label="Email notifications" />
<Switch label="Push notifications" />
<Field label="Digest frequency" orientation="horizontal">
<Dropdown>
<Option>Daily</Option>
<Option>Weekly</Option>
<Option>Monthly</Option>
</Dropdown>
</Field>
</div>
<Button appearance="primary" type="submit">Save changes</Button>
</form>
);
};
Inline Editing
export const InlineEditField: React.FC<{ value: string; onSave: (v: string) => void }> = ({
value,
onSave,
}) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
if (!editing) {
return (
<Text => setEditing(true)} style={{ cursor: "pointer" }}>
{value} <EditRegular />
</Text>
);
}
return (
<div style={{ display: "flex", gap: tokens.spacingHorizontalS }}>
<Input
value={draft}
data) => setDraft(data.value)}
autoFocus
=> {
if (e.key === "Enter") { onSave(draft); setEditing(false); }
if (e.key === "Escape") { setDraft(value); setEditing(false); }
}}
/>
<Button icon={<CheckmarkRegular />} => { onSave(draft); setEditing(false); }} />
<Button icon={<DismissRegular />} => { setDraft(value); setEditing(false); }} />
</div>
);
};
Filter Form
export const FilterBar: React.FC = () => {
const classes = useFilterStyles();
return (
<div className={classes.filterBar} role="search" aria-label="Filter results">
<Input
contentBefore={<SearchRegular />}
placeholder="Search..."
data) => onSearch(data.value)}
/>
<Dropdown placeholder="Status"
<Option>Active</Option>
<Option>Inactive</Option>
<Option>Pending</Option>
</Dropdown>
<Combobox placeholder="Tags" multiselect
{tags.map((tag) => <Option key={tag}>{tag}</Option>)}
</Combobox>
<Button appearance="subtle" icon={<FilterDismissRegular />}
Clear
</Button>
</div>
);
};
Cross-References
- Core components and tokens — See
fluent-design-systemskill for token reference, color system, and component catalog - Custom input components — See
fluent-extensibilityskill for building custom inputs that work with Field - Styling form components — See
fluent-griffelskill formakeStylesand token usage - Next.js forms — See
fluent-nextjsskill for Server Actions integration with Fluent forms - Charts in forms — See
fluent-chartingskill if embedding visualizations alongside form data
Reference files:
${CLAUDE_PLUGIN_ROOT}/skills/fluent-forms/references/form-patterns.md— Complete API reference, validation patterns, and wizard implementation