Form
Structure fields, rows and sections, wiring labels, help text and errors to any input.
Installation
- Install the package.
npm install @hashintel/ds-components- Import the component.
import { useState } from "react";
import { Form, TextInput } from "@hashintel/ds-components";
export const EntityNameField = () => {
const [name, setName] = useState("");
return (
<Form.Field label="Entity name" required>
<TextInput name="entityName" value={name} onChange={setName} required />
</Form.Field>
);
};When to use
- Do: Wrap every input in a Form.Field — it supplies the label, description and error display, and wires the label to the input automatically.
- Do: Use
layout="inline"fields inside a Form.Section for settings panels, so labels and inputs align across fields. - Do: Use Form.Row for values entered as a pair, such as minimum/maximum limits or first/last name.
- Do: Use
as="legend"when the field wraps a group of inputs such as RadioGroup or CheckboxGroup.
When not to use
- Don't: Don't put two inputs in one Form.Field — it labels exactly one control. Use Form.Row for side-by-side fields, or
as="legend"for groups. - Don't: Don't use a placeholder instead of a label — it disappears as soon as a value is entered. Use
hideLabelto hide the label visually. - Don't: Don't wrap controls that already label themselves, such as a Checkbox or Toggle with its own label text.
- Don't: Don't build multi-column forms out of Form.Row — a single column reads faster. Reserve rows for naturally paired values.
Anatomy
Form.Field renders its label, description and error parts from props; you only compose the structural parts.
<Form.Section>
<Form.Field
label="Name" // renders Form.Field.Label
description="Helper text" // renders Form.Field.Description
errors={["Error message"]} // renders Form.Field.Errors
>
{/* exactly one input */}
</Form.Field>
<Form.Row>
<Form.Field label="Minimum">{/* input */}</Form.Field>
<Form.Field label="Maximum">{/* input */}</Form.Field>
</Form.Row>
</Form.Section>Examples
Basic
A field needs only a label and a single input child — the label is associated with the input automatically, no ids required.
Required
required adds the mark to the label only — it never reaches the input. Set required on the input too so forms and assistive technology enforce it.
Disabled
disabled greys the field's label, description and errors; disable the input separately — the prop doesn't cascade.
Descriptions
description renders between the label and the input; descriptionBottom renders after it. Use them for format hints and consequences, not to restate the label.
Errors
Pass errors as an array — one message renders inline, several render as a list, and they're announced when they appear. Set invalid on the input yourself; the field never modifies its children.
Hidden label
Every field needs a label. When the context makes a visible one redundant — a lone search input, a table cell — set hideLabel so it stays announced to screen readers.
Label tooltip and actions
labelTooltip adds a help icon after the label; labelActions puts controls at the far end of the label row (next to the input in inline layout). Both sit outside the <label>, so clicking them doesn't focus the input.
Sizes
size scales the label, description and error text — set the same size on the field and its input. md is the default.
Inline layout
layout="inline" places the label and input on one line — the settings-panel arrangement. inputAlign="end" pushes the input to the far edge of the row, and labelDirection="right" puts the label after the input instead — the trailing-label pattern.
Grouping related inputs
A <label> can name only one control. When the field wraps a group — RadioGroup, CheckboxGroup — set as="legend" so the whole group is named with fieldset semantics.
Rows
Form.Row places fields side by side, sharing the row's width and bottom-aligned so inputs line up; gap controls the spacing. For a message that belongs to the pair rather than one field, pass a Form.Field.Errors element to errors — it renders below the whole row.
Sections
Form.Section aligns inline fields onto a shared grid — labels and inputs each share a column across fields — and spaces adjacent fields automatically.
Custom inputs
Every input picks up the field's generated id automatically. A hand-rolled input must read useFieldId and set it as its id.
import { useState } from "react";
import { Form, useFieldId } from "@hashintel/ds-components";
const ColorInput = ({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) => {
const id = useFieldId();
return (
<input
type="color"
id={id ?? undefined}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
};
export const AccentColorField = () => {
const [color, setColor] = useState("#0775e3");
return (
<Form.Field label="Accent color">
<ColorInput value={color} onChange={setColor} />
</Form.Field>
);
};Form libraries
Form.Field is driven entirely by props, so it drops into any form library. With TanStack Form, bind the label and errors once in a shared field component; in a real app the contexts and field components live in one module that every form imports.
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { Form, TextInput } from "@hashintel/ds-components";
const { fieldContext, formContext, useFieldContext } = createFormHookContexts();
const TextField = ({
label,
required,
}: {
label: string;
required?: boolean;
}) => {
const field = useFieldContext<string>();
return (
<Form.Field
label={label}
required={required}
errors={field.state.meta.errors}
>
<TextInput
name={field.name}
value={field.state.value}
onChange={(value) => field.handleChange(value)}
onBlur={field.handleBlur}
required={required}
invalid={field.state.meta.errors.length > 0}
/>
</Form.Field>
);
};
const { useAppForm } = createFormHook({
fieldComponents: { TextField },
formComponents: {},
fieldContext,
formContext,
});
export const InviteTeammateForm = () => {
const form = useAppForm({
defaultValues: { email: "" },
onSubmit: ({ value }) => console.log(value),
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<form.AppField
name="email"
validators={{
onChange: ({ value }) =>
/^\S+@\S+\.\S+$/.test(value)
? undefined
: "Enter a valid email address",
}}
>
{(field) => <field.TextField label="Teammate email" required />}
</form.AppField>
</form>
);
};Behavior
- Nothing is injected into children: the field talks to its input only through the generated label id. Set
size,disabled,requiredandinvalidon the input yourself. - With
as="legend"the generated id is not provided to children — group inputs label their own options.
Content
- Labels name the data, 1–3 words, sentence case, no trailing punctuation: "Entity type name", "Teammate email".
- Write errors as instructions that reuse the label's words: "Enter an entity type name", "Maximum must be at least the minimum". Avoid "please", "sorry" and the word "invalid".
- Mark required fields consistently: if one required field carries the mark, they all should.
Accessibility
Form fields build on native <fieldset>, <label> and <legend> semantics — see the WAI forms tutorial for the underlying model.
What Form provides
Form.Fieldrenders a real<fieldset>and wires its<label>to the input through a generated id every ds-components input adopts automatically.as="legend"names the whole group, keeping the required mark and tooltip out of the group's accessible name.hideLabelhides the label visually but keeps it announced.- Error messages render with
role="alert", so they're announced when they appear.
What you must do
- Always pass a
label— usehideLabelrather than leaving it empty or relying on a placeholder. - Set
as="legend"when the field wraps more than one input (RadioGroup, CheckboxGroup) — a label can name only one control. - Mirror field state on the input:
invalidwhen passingerrors, anddisabled/requiredon both. - Give custom inputs the
useFieldIdid (see Custom inputs); without it the label is not associated.
API reference
Form.Field
| Prop | Type | Description |
|---|---|---|
| label | ReactNode | |
| hideLabel | boolean | Visually hides the label while keeping it announced to screen readers |
| as | "label" | "legend" default: "label" | |
| labelDirection | "left" | "right" default: "left" | right puts the label after the input in inline layout; in block layout it right-aligns the label, description and errors instead |
| description | ReactNode | Helper text rendered between the label and the input |
| descriptionBottom | ReactNode | Helper text rendered below the input, in addition to any description above it. |
| labelTooltip | ReactNode | A HelpTooltip beside the label — a string for plain text, or custom tooltip content |
| labelActions | ReactNode | Content at the label row's far edge, such as a small action Button |
| errors | ReactNode[] | |
| layout | "inline" | "block" default: "block" | inline places the label and input on one line. block stacks the label above the input inline places the label and input on one line — the settings-panel arrangement; block (the default) stacks the label above the input |
| inputAlign | "end" | "start" default: "start" | end pushes the input to the far edge of the inline row |
| size | "xxs" | "xs" | "sm" | "md" | "lg" default: "md" | The size (height) of the element |
| disabled | boolean | Set to show the input as disabled |
| required | boolean | Shows the required mark next to the field's label. Visual only — set required on the input itself for validation and announcement. |
Form.Field.Label
| Prop | Type | Description |
|---|---|---|
| as | "label" | "legend" | "span" default: "label" | |
| size | "xxs" | "xs" | "sm" | "md" | "lg" default: "md" | |
| direction | "left" | "right" default: "left" | right right-aligns the label row and text |
| tooltip | ReactNode | A HelpTooltip beside the label text |
| actions | ReactNode | Content at the row's far edge |
| required | boolean | |
| disabled | boolean | |
| hide | boolean | Visually hides the label while keeping it announced to screen readers |
| htmlFor | string | The id of the input this label points at. Supplied automatically inside Form.Field |
Form.Field.Description
| Prop | Type | Description |
|---|---|---|
| size | "xxs" | "xs" | "sm" | "md" | "lg" default: "md" | |
| direction | "left" | "right" default: "left" | right right-aligns the text, matching a right-labelDirection field |
| disabled | boolean |
Form.Field.Errors
| Prop | Type | Description |
|---|---|---|
| errors | ReactNode[] | An array of error messages to show. One renders inline, several render as a list, nothing renders while empty |
| size | "xxs" | "xs" | "sm" | "md" | "lg" | |
| direction | "left" | "right" | right right-aligns the messages, matching a right-labelDirection field |
| disabled | boolean |
Form.Row
| Prop | Type | Description |
|---|---|---|
| errors | ReactElement<{ ({ className, errors, size, direction, disabled, "data-part": dataPart, }: { className?: string; errors?: ReactNode[]; size?: "xxs" | "xs" | "sm" | "md" | "lg" | undefined; direction?: "left" | "right" | undefined; disabled?: boolean | undefined; "data-part"?: string | undefine... | A Form.Field.Errors element rendered under the whole row, for messages that span its fields |
| gap | "md" | "lg" | "none" | "xl" | "spaceBetween" default: "md" | Horizontal spacing between the row's fields; spaceBetween spreads them to the row's edges |
| align | "bottom" | "top" | "center" default: "bottom" | |
| noWrap | boolean default: false |
Form.Section
| Prop | Type | Description |
|---|---|---|
| children | ReactNode | The fields and Form.Rows the section groups. |
useFieldId
Returns the id the nearest Form.Field provides for its input, or null when the component isn't rendered inside one.
const fieldId: string | null = useFieldId();Was this page helpful?