Design system

Text input

A labelled single-line field for short values such as names, addresses and IDs.

import { TextInput } from "@/ui";

Usage

TextInput is controlled: pass value and update it in onChange. onChange receives the native change event, so read e.target.value.

const [name, setName] = useState("");

<TextInput
  label="Campaign name"
  placeholder="Q4 pipeline webinar"
  value={name}
  onChange={(e) => setName(e.target.value)}
/>

The field fills the width of its container. Set the width on the parent, not on the input.

TextInput matches the Figma Text Input (Single-Line) and Form label components.

Always pass a placeholder. It defaults to the literal text "Placeholder". label has no default: leave it out and no label renders.

Sizes, icons and clearing

size="sm" is 38px tall with 12px gaps; the default md is 42px with 16px gaps. prefixIcon and suffixIcon take a Phosphor icon, drawn at 20px. clearable shows a 20px × once the field has a value.

<TextInput size="sm" placeholder="Search pages" prefixIcon={MagnifyingGlass} />
<TextInput placeholder="Search pages" prefixIcon={MagnifyingGlass} clearable value={q} onChange={(e) => setQ(e.target.value)} />
<TextInput label="Website" placeholder="yourcompany.com" suffixIcon={Globe} />

Input types

type is passed straight to the <input>. Use email, url or password when the value is one of those, so browsers offer the right keyboard and autofill.

<TextInput type="email" label="Send the weekly report to" placeholder="revops@yourcompany.com" … />
<TextInput type="password" label="Page password" placeholder="Shared with the people who should see it" … />
<TextInput type="url" label="Page canonical URL" placeholder="https://" … />

Validation

Set error to turn the border red, and errorMessage to say what to fix. The message only shows while error is true. Check the value on blur or on submit, not on every keystroke, so people are not told off while they are still typing.

const [gtm, setGtm] = useState("GTM-");
const [touched, setTouched] = useState(false);
const invalid = touched && !/^GTM-[A-Z0-9]{4,}$/.test(gtm);

<TextInput
  label="Google Tag Manager ID"
  placeholder="GTM-XXXXXXX"
  value={gtm}
  onChange={(e) => setGtm(e.target.value.toUpperCase())}
  onBlur={() => setTouched(true)}
  error={invalid}
  errorMessage="Use the ID from your container, for example GTM-5KX2P7Q."
/>

Write the message as the fix, not the fault: "Give the page an address", not "Invalid input".

States

<TextInput label="Default" placeholder="Landing page name" value="" />
<TextInput label="Filled" value="Demo request, EMEA" />
<TextInput label="Error" placeholder="demo-request" error errorMessage="Give the page an address." />
<TextInput label="Disabled" value="Forecast revenue you can defend" disabled />
Figma stateBorderBackgroundText
Default1px --color-grey-200--color-whitePlaceholder in --color-text-disabled
Selected (focus)1px --color-primary-500--color-white
Filled (has a value)1px --color-primary-500--color-white14px --color-text-primary
Error1px --color-red--color-whiteMessage 12px medium --color-red, 4px below
Disablednone--color-grey-50Label and value in --color-text-disabled

The border stays blue once the field has a value, as Figma's Filled state shows, so people can tell filled fields from empty ones at a glance. The field is 10px by 16px padding (8px by 16px for sm), a 22px line, a 1px border and --radius-md (8px) corners. The label is 14px medium, 6px above the field.

There is no hint or helper text prop. For a hint, put a 12px --color-text-secondary paragraph under the field, as the page settings dialog does.

Props

PropTypeDefaultNotes
labelReactNodeRendered in a <label> tied to the input. Left out, there is no label.
placeholderstring"Placeholder"Pass your own, or "" for none.
valuestringControlled value.
onChange(event) => voidReceives the native change event.
typestring"text"Any HTML input type.
errorbooleanfalseRed border and aria-invalid.
errorMessagestring""Shown under the field only when error is true.
disabledbooleanfalse
size"sm" | "md""md"38px or 42px tall.
prefixIcon, suffixIconPhosphor icon20px, --color-grey-900.
clearablebooleanfalse× once filled.
onClear() => voidDefaults to calling onChange with an empty value.
classNamestringAdded to the wrapper <div>, not the input. Use for layout only.

Any other prop (onBlur, onKeyDown, name, autoComplete, maxLength, aria-*) is passed to the <input>. In React 19 ref is an ordinary prop, so it reaches the <input> the same way; use it to focus the field, for example when a dialog opens.

Accessibility

  • The <label> is linked to the input with a generated id, so clicking the label focuses the field and screen readers read it as the field's name.
  • With error, the input gets aria-invalid, and the message is linked with aria-describedby and announced through role="alert".
  • Focus turns the border Primary 500, as Figma's Selected state shows. There is no extra ring.
  • If the design has no visible label, leave out label and pass an aria-label.
  • The clear button is labelled "Clear" and puts focus back in the field.
  • A required marker is not built in. If a field is required, say so in the label text ("Table name (required)") rather than with a red asterisk alone.

Do and don't

  • Do write labels as a noun that names the value: "Page address", "Campaign name".
  • Do use the placeholder for an example of the format, such as GTM-XXXXXXX or yourcompany.com, not for instructions that disappear once someone types.
  • Don't use a TextInput for text longer than one line. Use Text area.
  • Don't use one to pick from a known list. Use Dropdown or Radio.
  • Don't restyle the field with className; it lands on the wrapper and the input keeps its own styles.

On this page