Submit values, validate fields, and compose accessible React forms with the Liquid Glass controls.
The form controls are designed to work with native <form> behavior first.
Form renders a native <form> and adds consolidated errors and form-level
validation. Input renders a native <input>, while Checkbox, CheckboxGroup, Switch,
Select, Slider, and RadioGroup render an internal native input for
submission. That means FormData, server actions, and progressive-enhancement
patterns keep working without a custom state container.
Form renders a native <form> with the library's vertical layout. Use
errors to send server errors back to named Field controls, and
validationMode to set the default validation timing for the fields inside it.
Form does not replace FormData: native onSubmit still receives the
browser's values, while onFormSubmit gives you an object for controls
registered through Field.
<Form
action={savePreferences}
errors={serverErrors}
validationMode="onBlur"
>
<Field label="Email" name="email">
<Input type="email" required />
</Field>
<Button type="submit" label="Save" />
</Form>| Component | Submitted as | Empty state |
|---|---|---|
Input | Native input named by name | Empty string |
Checkbox | Hidden checkbox with name and value | null when unchecked |
CheckboxGroup | One hidden checkbox per checked value, all sharing name | No entries when nothing is checked |
Switch | Hidden checkbox with name and value | null when off |
Select | Hidden native input named by name with the selected value | null when nothing is selected |
Slider | Hidden input[type="range"] named by name | Defaults to the current value |
RadioGroup | Hidden radio named by name with the selected item value | null when nothing is selected |
RadioGroup submits the selected item's value; set required when a choice
is mandatory. Select behaves the same way: name enables submission,
required participates in native validation, and form links the hidden input
to a form elsewhere. A controlled value of null represents no selection.
CheckboxGroup submits one entry per checked value under the same name, so
read the field with FormData.getAll(name). Groups have no native required;
validate that at least one value is present in your submit handler.
Button forwards type to its root button element. Set type="submit" when it should
submit the surrounding form.
<form action={savePreferences}>
<Field label="Email">
<Input name="email" type="email" required />
</Field>
<Select
label="Plan"
name="plan"
required
items={[
{ value: "starter", label: "Starter" },
{ value: "team", label: "Team" },
]}
/>
<CheckboxGroup
label="Channels"
name="channels"
items={[
{ value: "comments", label: "Comments" },
{ value: "releases", label: "Releases" },
]}
/>
<Switch label="Notifications" name="notifications" value="enabled" />
<Button type="submit" label="Save" />
</form>When handling submission in the client, read the values from FormData:
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const email = String(data.get("email") ?? "");
const channels = data.getAll("channels").map(String);
const notifications = data.get("notifications") !== null;
// Send the values to your API or update local state.
}Use Field for text inputs, Fieldset for related groups, and the built-in
label props for the other controls:
Field renders a real <label>, description, and validation message around
Input.Fieldset groups related fields under a shared legend; each Field keeps
its own label.Checkbox and Switch use their visible label as the accessible name.CheckboxGroup names the group with label and each option with its own
label.Select uses label to name its trigger; without a visible label, use
aria-label or aria-labelledby.RadioGroup uses its label prop to name the group.Slider does not render a visible label, so point aria-labelledby at one.Descriptions are linked automatically through aria-describedby. If you pass
your own aria-describedby, it is merged with the generated one.
<div className="flex flex-col gap-2">
<span id="volume-label" className="text-sm text-ui-ink">
Volume
</span>
<Slider aria-labelledby="volume-label" name="volume" defaultValue={40} />
</div>For a group of related controls, use Fieldset with a legend:
<Fieldset legend="Notifications">
<Switch label="Email" name="email-notifications" value="on" />
<Switch label="Mentions" name="mention-notifications" value="on" />
</Fieldset>Every stateful control supports both modes:
// Uncontrolled: the component owns the state.
<Field label="Name">
<Input name="name" defaultValue="Ada" />
</Field>
<Switch label="Public profile" name="public" defaultChecked />
// Controlled: you own the state.
const [email, setEmail] = useState("");
<Field label="Email">
<Input
name="email"
value={email}
onValueChange={setEmail}
/>
</Field>Do not pass value without a corresponding change handler. For Slider,
onValueChange receives the current number or array; use onValueCommitted
when you only need the settled value. Select uses value with
onValueChange; set value to null to clear the selection.
CheckboxGroup uses value with onValueChange, and the callback receives the
next array of checked values.
Native constraints such as required, minLength, pattern, and type are
forwarded to the input. For custom validation, keep the message next to the
field and connect it with aria-describedby or aria-errormessage:
import { useState } from "react";
import { Field, Input } from "@xingxingmofashu/ui";
function EmailField() {
const [email, setEmail] = useState("");
const invalid = email.length > 0 && !email.includes("@");
return (
<Field
label="Email"
error={invalid ? "Enter a valid email address." : undefined}
>
<Input
name="email"
type="email"
value={email}
onValueChange={setEmail}
/>
</Field>
);
}Slider forwards aria-invalid and aria-errormessage to its underlying
range input, so validation state reaches the focusable control. Checkbox and
Switch forward their props and hidden inputs, so the same pattern applies.
disabled blocks interaction and removes the control from submission.readOnly keeps the value focusable and selectable but prevents changes.focusableWhenDisabled on Button keeps a disabled action in the Tab order
while exposing aria-disabled and blocking activation.Use read-only for values the user should be able to copy, and disabled for controls that are not relevant in the current state.
Because the controls submit through native inputs, a React 19 server action can
receive a regular FormData object:
"use server";
export async function savePreferences(formData: FormData) {
const email = String(formData.get("email") ?? "");
const notifications = formData.get("notifications") !== null;
// Persist the values.
}The action prop belongs to Form or the surrounding native <form>; the components only
supply the fields.
value without onValueChange freezes a controlled field.FormData before the submit event sees stale or empty values.Switch that is off submits null, not "false". Normalize it explicitly.Select with no selection submits no value; treat a missing FormData
entry as null before using it as a string.CheckboxGroup submits repeated fields, so use getAll instead of get.CheckboxGroup has no native required; validate the array length.Slider needs an accessible name; aria-labelledby is usually the
cleanest option when the label is rendered outside the component.<label> yourself. Use Field for Input,
Fieldset for related groups, and the built-in label props for the other
controls.