Composites

Form Layout

FormSection groups related fields under a heading and lays them out on a twelve-column grid; FormField puts one control on that grid and delegates its label, helper text and error message down to the Tier 1 Field. Neither of them draws an input.

Basic usage

A section carries the heading and the grid; each field declares how many of the twelve columns it wants. The function form of children hands the control everything it needs to be announced correctly — spread it and move on.

Workspace details

Everyone in the workspace sees this.

The twelve-column grid

The body of a FormSection is a plain grid-cols-12, which is the whole reason a field only has to say colSpan. Every form in the ecosystem then lands on the same column rhythm instead of each page inventing its own flex widths. Drag the preview narrower to watch the spans collapse.

Column spans

Twelve columns, one rhythm.

The span you ask for is the desktop span. Below that, the class map collapses in two steps rather than letting a 3-column field become an unusable sliver on a phone:

  • Every span is col-span-full — full width — on the smallest screens. Not col-span-12, which is a relative count: inside a grid that declares no grid-cols-* — a DialogContent, say — the browser has to invent eleven implicit tracks and the field breaks out of the box.
  • 3, 4, 5 and 6 become half width at sm, then take their real span at md. colSpan={6} stops at half and stays there.
  • 1, 2 and 711 jump straight from full width to their real span at md.
  • Because the spans are a fixed lookup rather than an interpolated class name, Tailwind can see all twelve at build time. A computed col-span- string would be purged away.

Handing the control its accessibility

When children is a function, FormField calls it with a FormFieldControlProps object. Spreading that object is the whole contract: the control gets an id that matches the label, an invalid flag that matches the error, a required flag, and a pointer to whichever piece of text is currently below it. Nothing here has to be repeated per page, and nothing can drift out of sync with the message you passed to error.

Three kinds of control

Spread it, spread it onto the trigger, or don't spread it at all.

Tasks wait in review until an approver agrees.

  • Do not double up the labelling. Input has its own label, error and hint props, but inside a FormField they are already drawn one level up. Using both paints the same string twice.
  • Node children opt out. Pass a plain element instead of a function and none of the four attributes are applied — that is the escape hatch for controls that carry their own label, such as a Switch or a group of checkboxes. The field then only reserves a slot on the grid.
  • Radix triggers need w-full. Spread control onto SelectTrigger rather than onto Select, and add className="w-full" — the trigger is w-fit by default and will otherwise shrink to its content in the middle of a form column.
  • A Select that allows “no choice” needs a sentinel. Radix reserves the empty string, so an unset option has to carry SELECT_EMPTY_VALUE and be converted at the boundary with toSelectValue / fromSelectValue. Those three ship next to FormField and are documented on the Combobox page.

Validation

error is the single source of truth: a value means the field is invalid. The message replaces the description rather than stacking under it, and it arrives with an AlertCircle icon so the state is not carried by colour alone. Type into the field below to watch the swap in both directions.

Task content

Accent

accent tells one group of fields from another, and as of 1.4.0 it makes two marks rather than one: a 4px rule down the left edge, and — when the section has an icon — the tint of the icon tile beside the title. Both carry information, so both are non-text contrast targets under WCAG 1.4.11 and have to clear 3:1; a decorative hairline would not. Each card below is titled with the accent it was given and passes the same icon, so the accent is the only thing changing from row to row.

none

No rule — this is the default.

Icon tile: bg-muted, 3.83:1.

primary

Rule: 6.51:1 light · 11.90:1 dark on --card.

Deliberate exception, not a bug — the rule is gold, the tile is not.

Icon tile: stays bg-muted on purpose — a gold tint would measure 1.64:1.

info

Rule: 6.68:1 light · 9.54:1 dark on --card.

Icon tile: bg-info/15 + text-info-ink, 5.54:1.

success

Rule: 6.52:1 light · 10.19:1 dark on --card.

Icon tile: bg-success/15 + text-success-ink, 5.60:1.

warning

Rule: 6.51:1 light · 11.60:1 dark on --card.

Icon tile: bg-warning/20 + text-warning-ink, 5.93:1.

destructive

Rule: 7.15:1 light · 6.42:1 dark on --card.

Icon tile: bg-destructive/15 + text-destructive-ink, 3.57:1.

The rule uses the -ink step of each role, not the fill step, and the earlier version that reached for the fill is exactly why. On a white card --primary measured 1.86:1 and --warning 1.59:1 — for the two most-used roles the rule all but vanished in light mode. The rest of the fills were not wrong so much as unreliable: --success scraped past at 3.13:1, and --destructive went from a comfortable 5.03:1 in light to 3.53:1 in dark — still over the bar, but with nothing left to spend. The ink steps clear it with room in both themes; the measured figures are in the preview above.

The icon tile joined the rule in 1.4.0 because leaving it neutral made a single header say two things. accent="destructive" drew a red rule next to a grey icon, and the mark carrying more visual weight — a filled tile, against a 4px line — was the one saying nothing: on a settings page the delete-everything group ended up with the same icon colour as the ordinary group above it. The four tinted tones are the same scale StatCard uses for its icon tile, so there is one tone ramp for icon tiles in the package rather than two that drift apart.

primary is left out of the tint on purpose, and that is the one row of the gallery that looks like a mistake and is not. In the default palette --primary-ink resolves straight to --primary, so a tinted tile would be gold on a gold wash: 1.64:1, under the 3:1 bar and under the 3.83:1 the plain muted tile already manages. Filling it solid would read at 10.19:1, but a solid gold tile shouts louder than the destructive group and inverts the severity order the accents exist to express. So the tile stays muted until brand gets a real ink step of its own. The tile figures in the preview are that default palette measured in light; data-contrast="high" redefines --primary-ink to a darker gold, where the same pair would pass — which is exactly why the exclusion has to be judged against the palette that fails, not the one that does not.

Collapsible sections

Set collapsible and the heading becomes a real Collapsible trigger with a chevron that rotates. Leave open unset and the section keeps its own state from defaultOpen; pass open and onOpenChange to drive several sections from one control, as the button below does.

Note where actions lands: outside the trigger, never inside it. A button nested inside a button is invalid HTML and does not survive a round trip through server-rendered markup — the parser closes the outer button at the inner one, so what ships is not the tree you wrote. Keeping the slot a sibling of the trigger sidesteps the question entirely.

Icons are components, not elements

icon is typed IconComponent, the shared composite-tier icon type, and it wants the component itself so the composite can render it with its own sizing and aria-hidden — and, since 1.4.0, in the tile colour that accent asks for. Handing over an element instead of a component would put all three of those out of the composite's reach.

tsx
import { Building2 } from 'lucide-react'
import { FormSection } from '@comitor/ui'

// ✓ Pass the component itself.
<FormSection title="Workspace details" icon={Building2}>…</FormSection>

// ✗ Not an element. This is a type error, and deliberately so:
//   a Lucide icon is a forwardRef object rather than a plain function, so
//   nothing at runtime can tell "component" from "already-rendered element".
//   Guessing would fail silently, so the type refuses the ambiguity up front.
<FormSection title="Workspace details" icon={<Building2 />}>…</FormSection>

Props

FormSection:

PropTypeDefaultDescription
titlerequiredReactNodeGroup heading, rendered as an <h3>. It deliberately does not truncate — a long Vietnamese group title lost about 26px of text at 320px, and wrapping is less bad than cutting.
descriptionReactNodeMuted second line under the title.
iconIconComponentIcon component (not an element) shown in a rounded tile beside the title, marked aria-hidden. Since 1.4.0 the tile takes its tint from accent: info, warning, success and destructive tint it, none and primary leave it muted.
actionsReactNodeSlot to the right of the heading for a secondary button or switch. Rendered outside the collapsible trigger so you never nest a button inside a button.
collapsiblebooleanfalseWraps the body in a Collapsible and turns the heading into a trigger with a rotating chevron.
defaultOpenbooleantrueInitial open state when the section is uncontrolled.
openbooleanControlled open state. When set, the internal state is bypassed entirely.
onOpenChange(open: boolean) => voidFires on every toggle, controlled or not.
accent'none' | 'primary' | 'info' | 'warning' | 'success' | 'destructive''none'Two marks from one prop: a 4px rule down the left edge in the matching -ink token, and the tint of the icon tile when icon is set. primary is the exception — it draws the rule but deliberately keeps the tile muted (FormSectionAccent).
childrenrequiredReactNodeThe body. It is a grid-cols-12 container, so direct children should be FormField (or anything else that declares its own col-span).
classNamestringMerged onto the outer <section> shell.
contentClassNamestringMerged onto the 12-column grid, e.g. to change the gap or padding.

FormField — the composite from @comitor/ui, not the react-hook-form wrapper from @comitor/ui/form:

PropTypeDefaultDescription
labelReactNodeRendered as a FieldLabel bound to the control by htmlFor. Omit it when the control carries its own label.
requiredbooleanfalseDraws an aria-hidden asterisk after the label and emits aria-required on the control.
descriptionReactNodeHelper text under the control. Hidden while an error is showing, so the two are never read one on top of the other.
errorReactNodeThe message. Having a value is what makes the field invalid — there is no separate boolean to keep in sync.
htmlForstringThe id of the control. Leave it out and useId generates one; pass it when something else needs to focus the field.
colSpanFormFieldColSpan (1 – 12)12Columns taken in the parent FormSection grid. See the breakpoint rules above — every span is full width on small screens.
orientation'vertical' | 'horizontal' | 'responsive''vertical'Passed straight through to the Tier 1 Field: stacked, side by side, or stacked until the enclosing FieldGroup reaches its @md container width.
classNamestringMerged onto the Field, after the col-span class.
childrenrequiredReactNode | ((control: FormFieldControlProps) => ReactNode)The control. Function form receives the accessibility attributes to spread; node form means you own the wiring yourself.

FormFieldControlProps — the object handed to function children:

PropTypeDefaultDescription
idstringAlways present. Equals htmlFor when you passed one, otherwise the generated useId value that the label already points at.
aria-invalidtrue | undefinedSet only when error has a value. Undefined otherwise, so aria-invalid="false" never reaches the DOM.
aria-requiredtrue | undefinedSet only when required is true. This, not the asterisk, is what a screen reader announces.
aria-describedbystring | undefinedPoints at the error element when there is an error, otherwise at the description element, otherwise undefined. Never both at once.

Accessibility

  • FormField renders a Tier 1 Field, so every field is a role="group" holding its label, control and message together, with data-invalid set when there is an error.
  • The label is a native <label> bound by htmlFor. If you do not supply an id, useId generates one and both sides use it — the pairing cannot be forgotten.
  • The required asterisk is aria-hidden on purpose. The real signal is aria-required on the control; without hiding the glyph a screen reader would announce a literal “star” after every mandatory label.
  • The error is a FieldError, which carries role="alert" — appearing is enough to have it announced — and it is wired to the control through aria-describedby at `${id}-error`.
  • Description and error are mutually exclusive. aria-describedby points at one or the other, never both, so an invalid field is not read out as hint-then-error in one breath.
  • aria-invalid and aria-required are emitted only when true. They are undefined otherwise, which keeps aria-invalid="false" — valid but noisy — out of the DOM.
  • The asterisk is painted with --destructive-ink (7.15:1 light, 6.42:1 dark on a card), not --destructive, which is the fill behind a delete button and reaches only 3.53:1 as text on a card in dark mode.
  • The accent rule is treated as a non-text contrast target: -ink steps clear 3:1 with margin in both themes, whereas the fill steps it replaced ranged from 1.59:1 to 5.03:1 depending on role and theme — two of the five coloured roles under the threshold, and two more sitting on it.
  • The tinted icon tile is held to the same 3:1, measured on the tint behind it rather than on the card. On the default palette in light: info 5.54:1, success 5.60:1, warning 5.93:1, destructive 3.57:1, and the untinted bg-muted tile 3.83:1. The tile and the glyph inside it are aria-hidden, so the accent is a visual grouping cue only — whatever the colour means has to be said in the title as well.
  • primary is excluded from the tile tint for that reason and not by oversight. The default palette resolves --primary-ink straight to --primary, so gold on a gold tint lands at 1.64:1 — below the bar, and below the 3.83:1 the muted tile already reaches. A solid gold tile would be legible at 10.19:1 but would out-shout the destructive group, so the muted tile stays. Under data-contrast="high" the same pair passes, which is why this one has to be judged on the default palette.
  • A collapsible section's trigger is a real button and Radix wires aria-expanded plus aria-controls for it; Enter and Space toggle. The chevron is aria-hidden and actions is a sibling of the trigger, so keyboard users reach both controls in order instead of losing the inner one to invalid nesting.
  • The section title does not truncate. At 320px a long group heading lost about 26px of text to an ellipsis; wrapping to a second line is the lesser evil, and min-w-0 on the wrapper still stops the heading from pushing actions off screen.