Form

Structure fields, rows and sections, wiring labels, help text and errors to any input.

Shown in search results and the type editor.

Installation

  1. Install the package.
npm install @hashintel/ds-components
  1. 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 hideLabel to 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.

Assigned when the entity is created.

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.

Shown wherever the entity is referenced.
You can't change this later.

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.

Enter a URL that starts with https://

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.

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.

Entity visibilityWho can see entities created by this flow.

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.

Maximum must be at least the minimum

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, required and invalid on 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.Field renders 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.
  • hideLabel hides 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 — use hideLabel rather 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: invalid when passing errors, and disabled/required on both.
  • Give custom inputs the useFieldId id (see Custom inputs); without it the label is not associated.

API reference

Form.Field

PropTypeDescription
label
ReactNode
hideLabel
boolean
as
"label" |⁠ "legend"
default: "label"
labelDirection
"left" |⁠ "right"
default: "left"
description
ReactNode
descriptionBottom
ReactNode
labelTooltip
ReactNode
labelActions
ReactNode
errors
ReactNode[]
layout
"inline" |⁠ "block"
default: "block"
inputAlign
"end" |⁠ "start"
default: "start"
size
"xxs" |⁠ "xs" |⁠ "sm" |⁠ "md" |⁠ "lg"
default: "md"
disabled
boolean
required
boolean

Form.Field.Label

PropTypeDescription
as
"label" |⁠ "legend" |⁠ "span"
default: "label"
size
"xxs" |⁠ "xs" |⁠ "sm" |⁠ "md" |⁠ "lg"
default: "md"
direction
"left" |⁠ "right"
default: "left"
tooltip
ReactNode
actions
ReactNode
required
boolean
disabled
boolean
hide
boolean
htmlFor
string

Form.Field.Description

PropTypeDescription
size
"xxs" |⁠ "xs" |⁠ "sm" |⁠ "md" |⁠ "lg"
default: "md"
direction
"left" |⁠ "right"
default: "left"
disabled
boolean

Form.Field.Errors

PropTypeDescription
errors
ReactNode[]
size
"xxs" |⁠ "xs" |⁠ "sm" |⁠ "md" |⁠ "lg"
direction
"left" |⁠ "right"
disabled
boolean

Form.Row

PropTypeDescription
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...
gap
"md" |⁠ "lg" |⁠ "none" |⁠ "xl" |⁠ "spaceBetween"
default: "md"
align
"bottom" |⁠ "top" |⁠ "center"
default: "bottom"
noWrap
boolean
default: false

Form.Section

PropTypeDescription
children
ReactNode

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?

On this page