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.
The one on this page comes from @comitor/ui and is a layout composite: grid cell, label, description, error text. The other comes from @comitor/ui/form and is the shadcn wrapper around react-hook-form's Controller — it takes control, name and render and draws nothing at all.
Same name, different entry point, different API. They can never appear in one import statement, so if TypeScript is complaining that render or colSpan does not exist, check which of the two you imported. See Form for the react-hook-form one.
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.
Prefixes every task ID.
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. Notcol-span-12, which is a relative count: inside a grid that declares nogrid-cols-*— aDialogContent, say — the browser has to invent eleven implicit tracks and the field breaks out of the box. 3,4,5and6become half width atsm, then take their real span atmd.colSpan={6}stops at half and stays there.1,2and7–11jump straight from full width to their real span atmd.- 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.
Inputhas its ownlabel,errorandhintprops, but inside aFormFieldthey 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
Switchor a group of checkboxes. The field then only reserves a slot on the grid. - Radix triggers need
w-full. SpreadcontrolontoSelectTriggerrather than ontoSelect, and addclassName="w-full"— the trigger isw-fitby 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_VALUEand be converted at the boundary withtoSelectValue/fromSelectValue. Those three ship next toFormFieldand 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.
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:
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | — | Group 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. |
description | ReactNode | — | Muted second line under the title. |
icon | IconComponent | — | Icon 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. |
actions | ReactNode | — | Slot 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. |
collapsible | boolean | false | Wraps the body in a Collapsible and turns the heading into a trigger with a rotating chevron. |
defaultOpen | boolean | true | Initial open state when the section is uncontrolled. |
open | boolean | — | Controlled open state. When set, the internal state is bypassed entirely. |
onOpenChange | (open: boolean) => void | — | Fires 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). |
childrenrequired | ReactNode | — | The body. It is a grid-cols-12 container, so direct children should be FormField (or anything else that declares its own col-span). |
className | string | — | Merged onto the outer <section> shell. |
contentClassName | string | — | Merged 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:
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Rendered as a FieldLabel bound to the control by htmlFor. Omit it when the control carries its own label. |
required | boolean | false | Draws an aria-hidden asterisk after the label and emits aria-required on the control. |
description | ReactNode | — | Helper text under the control. Hidden while an error is showing, so the two are never read one on top of the other. |
error | ReactNode | — | The message. Having a value is what makes the field invalid — there is no separate boolean to keep in sync. |
htmlFor | string | — | The id of the control. Leave it out and useId generates one; pass it when something else needs to focus the field. |
colSpan | FormFieldColSpan (1 – 12) | 12 | Columns 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. |
className | string | — | Merged onto the Field, after the col-span class. |
childrenrequired | ReactNode | ((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:
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Always present. Equals htmlFor when you passed one, otherwise the generated useId value that the label already points at. |
aria-invalid | true | undefined | — | Set only when error has a value. Undefined otherwise, so aria-invalid="false" never reaches the DOM. |
aria-required | true | undefined | — | Set only when required is true. This, not the asterisk, is what a screen reader announces. |
aria-describedby | string | undefined | — | Points at the error element when there is an error, otherwise at the description element, otherwise undefined. Never both at once. |
Accessibility
FormFieldrenders a Tier 1Field, so every field is arole="group"holding its label, control and message together, withdata-invalidset when there is an error.- The label is a native
<label>bound byhtmlFor. If you do not supply an id,useIdgenerates one and both sides use it — the pairing cannot be forgotten. - The required asterisk is
aria-hiddenon purpose. The real signal isaria-requiredon the control; without hiding the glyph a screen reader would announce a literal “star” after every mandatory label. - The error is a
FieldError, which carriesrole="alert"— appearing is enough to have it announced — and it is wired to the control througharia-describedbyat`${id}-error`. - Description and error are mutually exclusive.
aria-describedbypoints at one or the other, never both, so an invalid field is not read out as hint-then-error in one breath. aria-invalidandaria-requiredare emitted only when true. They areundefinedotherwise, which keepsaria-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:
-inksteps 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:
info5.54:1,success5.60:1,warning5.93:1,destructive3.57:1, and the untintedbg-mutedtile 3.83:1. The tile and the glyph inside it arearia-hidden, so the accent is a visual grouping cue only — whatever the colour means has to be said in the title as well. primaryis excluded from the tile tint for that reason and not by oversight. The default palette resolves--primary-inkstraight 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 thedestructivegroup, so the muted tile stays. Underdata-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-expandedplusaria-controlsfor it; Enter and Space toggle. The chevron isaria-hiddenandactionsis 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-0on the wrapper still stops the heading from pushingactionsoff screen.