Skip to content
Tier

Field

Pro

One form control with its label, hint and error, wired together: the label points at the control, the control is described by the hint and the error and is aria-invalid while there is one, and required marks the label and sets aria-required (not the native required, so the browser's own bubbles don't speak over yours).

Wrap one control: an Input, Textarea, Combobox, NumberInput, a native <select>, anything that takes an id and aria-*. Field passes them in. For a control that needs them somewhere else, pass a function as children: it gets the props to spread and whether to ring. For a set of radios or checkboxes, set group: the field becomes a fieldset whose legend is the label, described by the hint and the error. Each radio and checkbox in it is aria-invalid while there is an error, and a required group's legend says "(required)" to screen readers. A function child of a group gets the same props, with its own id and no aria-describedby (the fieldset carries both).

When the value isn't simply the person's own, pass trust: under the label, before the hint, an icon and words say it is "Not verified", "Filled by Intake agent; check it" (the app names the agent), or "Source unavailable" with the reason, and the control's edge is dashed (a group's start edge, beside its radios or checkboxes), so the state isn't shown by colour alone. The line is read with the control.

Errors are the app's. Field shows the one you pass and never makes one up, in a polite live region, so one that appears or changes while the person works is read out without moving focus. To point at the first problem after a submit, pass the form's submit count as attempt (react-hook-form's formState.submitCount, or a counter your server action returns with its errors). When it goes up and this is the first field in its <form> with an error, focus moves to the control and an Input or Textarea rings once. Change attempt in the same render as the errors it found; a field that only gains an error later, as the person types, is left alone. Errors that arrive with a submit are not put in the live region, since the focus move and a FormErrorSummary read them; without attempt, Field can't tell a submit's errors apart, and each is read out as it appears.

When the app checks the value somewhere else (a username is free, an address is real), set validating while the check runs: the field says "Checking…" with a live dot, and holds back any error and aria-invalid until the answer. Then it shows the error the app passes, or, when the app sets valid, "Looks good" (read out once). Field never infers a pass: a check that stops without an answer, or an error the app clears, shows nothing until the app sets valid.

Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1 and 2.6.2: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/field

Brings in: bidi, label, locale, motion, tokens, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"use client"

import * as React from "react"
import { Field } from "@/components/candor/ui/field"
import { Input } from "@/components/candor/ui/input"
import { Textarea } from "@/components/candor/ui/textarea"
import { Button } from "@/components/candor/ui/button"

type Errors = { name?: string | undefined; email?: string | undefined }

// The app checks the values and says what is wrong; Field shows it, wires it to the control, and on a failed submit
// moves focus to the first field with a problem.
export default function Example() {
  const [errors, setErrors] = React.useState<Errors>({})
  const [attempt, setAttempt] = React.useState(0)
  return (
    <form
      noValidate
      className="flex w-full max-w-sm flex-col gap-4"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        const next: Errors = {}
        if (!String(data.get("name")).trim()) next.name = "Enter a name for the agent"
        if (!/^\S+@\S+$/.test(String(data.get("email")))) next.email = "Enter an email address, like name@company.com"
        setErrors(next)
        setAttempt((n) => n + 1)
      }}
    >
      <Field label="Agent name" required error={errors.name} attempt={attempt}>
        <Input name="name" />
      </Field>
      <Field label="Escalation email" hint="Where the agent sends what it can't decide." required error={errors.email} attempt={attempt}>
        <Input name="email" type="email" autoComplete="email" />
      </Field>
      <Field label="Team" hint="Who the agent works for." trust={{ state: "provisional", by: "Intake agent" }}>
        <Input name="team" defaultValue="Customer success" />
      </Field>
      <HandleField />
      <Field label="Notes" optional>
        <Textarea name="notes" rows={3} />
      </Field>
      <Button type="submit" className="self-start">
        Save
      </Button>
    </form>
  )
}

const taken = ["admin", "support", "billing"]

// The app's own check, somewhere else (here a stand-in that takes half a second): while it runs the field says
// "Checking…"; then it shows the app's error, or "Looks good" when the app says the value passed. Any edit clears the
// last answer, so an empty or unchecked value claims nothing.
function HandleField() {
  const [value, setValue] = React.useState("")
  const [checking, setChecking] = React.useState(false)
  const [error, setError] = React.useState<string | undefined>()
  const [valid, setValid] = React.useState(false)
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const check = (next: string) => {
    setValue(next)
    clearTimeout(timer.current)
    setError(undefined)
    setValid(false)
    if (!next) return setChecking(false)
    setChecking(true)
    timer.current = setTimeout(() => {
      const isTaken = taken.includes(next.toLowerCase())
      setError(isTaken ? "That handle is taken. Try another." : undefined)
      setValid(!isTaken)
      setChecking(false)
    }, 500)
  }
  React.useEffect(() => () => clearTimeout(timer.current), [])
  return (
    <Field label="Handle" hint="Try admin to see a taken one." validating={checking} valid={valid} error={error}>
      <Input name="handle" value={value} onChange={(e) => check(e.target.value)} />
    </Field>
  )
}

<Field>

PropTypeDefaultDescription
labelrequiredReact.ReactNode—What the control is for. Shown above it; for a `group`, the legend.
hintReact.ReactNode | undefined—Help that stays: the format wanted, what the value is used for. Read out with the control.
errorstring | null | undefined—What is wrong with the value, in words that say how to fix it ("Enter an email address, like name@company.com"). Nothing, or an empty string, is no error.
requiredboolean | undefinedfalseThe field must be filled. Marks the label (the mark is hidden from screen readers, which hear `aria-required`).
optionalboolean | undefinedfalseSays "(optional)" after the label. Use it when most of the form is required, in place of marking every required field.
attemptnumber | undefined—How many times the form has been submitted. When it goes up and this is the form's first field with an error, focus moves here and the control rings once. The errors that arrive with it are shown but not announced, so they are read once, by the summary and the focus move.
groupboolean | undefinedfalseThe control is a set of radios or checkboxes: render a `fieldset` with a `legend` in place of a `label`.
trustFieldTrust | undefined—How far the value can be trusted: `unverified`, `provisional` (an agent filled it; `by` names the agent) or `degraded` (its source failed; `reason` says why). Shown under the label as an icon and words, read with the control, and the control's edge is dashed (a group draws a dashed line down its start side). Left out, the value is the person's own and nothing is shown.
validatingboolean | undefinedfalseThe app's check of the value is running (a server lookup): the field says "Checking…", and holds back `error` and `aria-invalid` until it turns false. Then the error shows, or "Looks good" when the app sets `valid`.
validboolean | undefinedfalseThe app's check answered and found nothing wrong with the value as it is now: the field says "Looks good", read out once. Clear it as soon as the value changes. Ignored while `validating` is set or there is an `error`.
idstring | undefined—The control's id, for a `form-error-summary` link to point at. Defaults to a generated one, or the control's own `id`.
classNamestring | undefined—Classes for the field's outer box.
childrenrequiredControlElement | React.ReactNode | ((control: FieldControlProps, state: { attend: boolean }) => React.ReactNode)—The control, or a function that renders it from the props to spread and whether it should ring. A `group` takes any content: its radios or checkboxes with their labels.

Strings

The fixed words, in English by default. Change any of them under field in <LocaleProvider strings={{ … }}>; the table is exported as fieldStrings.

KeyEnglish
requiredMark*
optional(optional)
errorError:
required(required)
trustUnverifiedNot verified
trustProvisionalFilled by {by}; check it
trustProvisionalUnnamedFilled by an agent; check it
trustDegradedSource unavailable
trustDegradedReasonSource unavailable: {reason}
checkingChecking…
looksGoodLooks good

Motion

MomentFamilyWhat it does
checkingliveA dot pulses beside "Checking…" while `validating` is set.
descriptionarrive, riseThe hint arrives when it appears or its words change after the field was on screen; one there at the first draw is still.
errorarrive, fadeThe error message fades in under its control when it appears after the field was on screen, and again when its text changes. An error already showing when the field first draws is still, and the ring on the control is the control's own (`Input`'s attention:invalid).
resultarrive, fade"Looks good" arrives when the app sets `valid`.

Set with --cd-motion-field-<moment>-* in your brand file; the families are in the design reference.

Types

FieldTrust
How far a field's value can be trusted, when the app knows it isn't simply the person's own.
{ /** * `unverified`: no one has checked the value. `provisional`: an agent filled it, and the person should check it. * `degraded`: the value's source failed, so it may be stale or missing. */ state: "unverified" | "provisional" | "degraded"; /** Who filled a provisional value, as the app names the agent: "Intake agent". Left out, it says "an agent". */ by?: string | undefined; /** Why a degraded value's source is unavailable, from the app or the tool that failed: "The CRM timed out". */ reason?: string | undefined; }
FieldControlProps
What a field hands its control: the wiring that ties the label, hint and error to it. Spread it on the control.
{ /** The control's id, which the label's `htmlFor` (and a `form-error-summary` link) points at. */ id: string; /** The hint's and the error's ids, after any the control already had. */ "aria-describedby": string | undefined; /** True while the field has an error. */ "aria-invalid": true | undefined; /** True when the field is `required`. */ "aria-required": true | undefined; /** The value's trust state, while it has one: the control draws a dashed edge on it. */ "data-trust"?: FieldTrust["state"] | undefined; /** Clears the ring once it has played. */ onAnimationEnd: React.AnimationEventHandler; }