Guides

Forms

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.

Used for account notices and receipts.

Preferences
Weekly digestProduct notes and release highlights, once a week.
ChannelsChoose where release notes are sent.
CommentsReleases
NotificationsSend a summary when a teammate publishes changes.
Volume

The slider submits its current value through a hidden range input.

Not submitted yet.

Form 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>

Submitted values

ComponentSubmitted asEmpty state
InputNative input named by nameEmpty string
CheckboxHidden checkbox with name and valuenull when unchecked
CheckboxGroupOne hidden checkbox per checked value, all sharing nameNo entries when nothing is checked
SwitchHidden checkbox with name and valuenull when off
SelectHidden native input named by name with the selected valuenull when nothing is selected
SliderHidden input[type="range"] named by nameDefaults to the current value
RadioGroupHidden radio named by name with the selected item valuenull 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.
}

Labels and descriptions

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>

Controlled and uncontrolled

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.

Validation

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 and read-only

  • 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.

Server actions

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.

Common pitfalls

  • Using value without onValueChange freezes a controlled field.
  • Reading FormData before the submit event sees stale or empty values.
  • A Switch that is off submits null, not "false". Normalize it explicitly.
  • A Select with no selection submits no value; treat a missing FormData entry as null before using it as a string.
  • A CheckboxGroup submits repeated fields, so use getAll instead of get.
  • A CheckboxGroup has no native required; validate the array length.
  • A Slider needs an accessible name; aria-labelledby is usually the cleanest option when the label is rendered outside the component.
  • Do not wrap controls in a <label> yourself. Use Field for Input, Fieldset for related groups, and the built-in label props for the other controls.