Form section
A titled block of form fields, stacked or with the heading beside the fields.
Server-compatible: no hooks, no client boundary. The section names itself from a string title, so a caller passing a node should add its own aria-label. A hairline sits under the heading whenever the heading is above the fields, which in the aside layout means below md only. FormRow wires its own error up: a single element child is cloned with aria-invalid and an aria-describedby pointing at the message, merged with any the control already had. A fragment, a list, or plain text is left alone — pass a function child instead and it receives the id, aria-describedby, and aria-invalid to spread where they belong.
Install
npx shadcn@latest add @vibra/form-sectionNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { CurrencyInput } from "@/components/ui/currency-input"
import { FormRow, FormSection } from "@/components/ui/form-section"
import { Input } from "@/components/ui/input"
import { ToggleRow } from "@/components/ui/toggle-row"
export default function FormSectionDemo() {
const [company, setCompany] = React.useState("Northwind")
const [cap, setCap] = React.useState<number | null>(2400)
const [autoRenew, setAutoRenew] = React.useState(true)
return (
<div className="w-full max-w-lg">
<FormSection
title="Billing"
description="How this workspace is charged, and who the invoices go to."
actions={
<Button variant="outline" size="sm">
View invoices
</Button>
}
>
<FormRow
label="Company name"
htmlFor="company"
description="Shown on every invoice."
required
error={company.trim() ? undefined : "Enter a company name"}
>
<Input
id="company"
value={company}
onChange={(event) => setCompany(event.target.value)}
/>
</FormRow>
<FormRow label="Monthly spend cap" htmlFor="spend-cap" description="We pause overages here.">
<CurrencyInput id="spend-cap" value={cap} onValueChange={setCap} step={100} />
</FormRow>
<ToggleRow
label="Renew automatically"
description="Charge the card on file at the start of each cycle"
checked={autoRenew}
onCheckedChange={setAutoRenew}
/>
</FormSection>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | React.ReactNode | — | The section heading; a string also becomes the section's accessible name. |
| description | React.ReactNode | — | One or two lines under the heading. |
| actions | React.ReactNode | — | Sits with the heading — a link out, a secondary action, a status. |
| aside | boolean | false | Moves the heading into its own column beside the fields from md up. |
| as | "h2" | "h3" | "h4" | "h2" | The heading's level: h2 under a page's h1; pass "h3" for a section inside something that already has an h2 — a settings panel under its title, a card under its CardTitle — so the outline never skips a level. |
| FormRow.label | React.ReactNode | — | Names the control the row wraps. |
| FormRow.description | React.ReactNode | — | A hint under the label. |
| FormRow.htmlFor | string | — | The id of the control being labelled; the error takes the same id with -error appended. |
| FormRow.required | boolean | false | Marks the row with an asterisk and the words required for screen readers. |
| FormRow.error | React.ReactNode | — | A message under the control; it also flags the row as invalid and points the control at the message. |
| FormRow.children | React.ReactNode | ((field: FormRowField) => React.ReactNode) | — | A single element, which the row wires the error to, or a function that receives the wiring to spread itself. |
Dependencies
Registry
Source
import * as React from "react"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
// "title" is omitted because <section>'s own title attribute is a tooltip
// string, and this one is the section's heading.
export type FormSectionProps = Omit<React.ComponentProps<"section">, "title"> & {
/** Names the section; also becomes its accessible name when it is a plain string. */
title: React.ReactNode
description?: React.ReactNode
/** Sits with the heading — a link out, a secondary action, a status. */
actions?: React.ReactNode
/** Moves the heading into its own column beside the fields from md up. */
aside?: boolean
/**
* The heading's level. h2 by default, the level under a page's h1; a
* section inside something that already has an h2 — a settings panel under
* its title, a card under its CardTitle — passes "h3", so the outline never
* skips a level.
*/
as?: "h2" | "h3" | "h4"
}
/** A titled block of form fields, stacked by default or with the heading beside the fields. */
function FormSection({
className,
title,
description,
actions,
aside = false,
as: Heading = "h2",
children,
...props
}: FormSectionProps) {
return (
<section
data-slot="form-section"
data-layout={aside ? "aside" : "stacked"}
// No hooks here — this stays a server component — so the name comes from
// the title itself rather than an aria-labelledby to a generated id. A
// caller's own aria-label or aria-labelledby overrides it.
aria-label={typeof title === "string" ? title : undefined}
className={cn(
"flex w-full flex-col gap-4",
aside && "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-x-8 md:gap-y-0",
className
)}
{...props}
>
<div data-slot="form-section-header" className="flex flex-col gap-1">
<div className="flex items-start justify-between gap-4">
<Heading className="text-base leading-6 font-medium">{title}</Heading>
{actions ? (
<div data-slot="form-section-actions" className="flex shrink-0 items-center gap-2">
{actions}
</div>
) : null}
</div>
{description ? (
<p className="max-w-prose text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{/* A hairline under the heading, but only while the heading sits above
the fields — in the aside layout the columns already separate them. */}
<Separator className={cn(aside && "md:hidden")} />
<div data-slot="form-section-content" className="flex flex-col gap-4">
{children}
</div>
</section>
)
}
/** What a row knows about the control it wraps, handed to a function child so it can wire itself up. */
export type FormRowField = {
id?: string
"aria-describedby"?: string
"aria-invalid"?: true
}
export type FormRowProps = Omit<React.ComponentProps<"div">, "children"> & {
label: React.ReactNode
description?: React.ReactNode
/** The id of the control this row labels; the error takes the same id with "-error" appended. */
htmlFor?: string
required?: boolean
error?: React.ReactNode
/** A single element, which the row wires the error to, or a function that receives the wiring. */
children?: React.ReactNode | ((field: FormRowField) => React.ReactNode)
}
// A Fragment accepts no aria props, and neither does a string or an array, so
// only a real element is cloned; everything else is left as it was written and
// can reach for the function form instead.
function isWirableElement(node: React.ReactNode): node is React.ReactElement<FormRowField> {
return React.isValidElement(node) && node.type !== React.Fragment
}
/** One labelled control in a form section, with an optional hint, required mark, and error. */
function FormRow({
className,
label,
description,
htmlFor,
required = false,
error,
children,
...props
}: FormRowProps) {
const errorId = error && htmlFor ? `${htmlFor}-error` : undefined
const field: FormRowField = {
id: htmlFor,
"aria-describedby": errorId,
"aria-invalid": error ? true : undefined,
}
// The row renders the message but not the control, so it reaches into the
// control to point it at the message; without that the error is text nothing
// announces.
function wire(node: React.ReactNode): React.ReactNode {
if (!error || !isWirableElement(node)) return node
const described = [node.props["aria-describedby"], errorId].filter(Boolean).join(" ")
return React.cloneElement(node, {
"aria-describedby": described || undefined,
"aria-invalid": true,
})
}
const content = typeof children === "function" ? children(field) : wire(children)
return (
<div
data-slot="form-row"
data-required={required || undefined}
data-invalid={Boolean(error) || undefined}
className={cn("flex w-full flex-col gap-2", className)}
{...props}
>
<div className="flex flex-col gap-1">
<Label htmlFor={htmlFor} className="gap-1">
{label}
{required ? (
<>
<span aria-hidden="true" className="text-danger">
*
</span>
<span className="sr-only">(required)</span>
</>
) : null}
</Label>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
{content}
{error ? (
// Predictable id so the control can point at it with aria-describedby —
// the row wraps the control rather than rendering it, so it cannot wire
// that up itself.
<p
data-slot="form-row-error"
id={htmlFor ? `${htmlFor}-error` : undefined}
className="text-xs text-danger"
>
{error}
</p>
) : null}
</div>
)
}
export { FormRow, FormSection }