Skip to contentVibraUI
Inputs & filters

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-section

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
titleReact.ReactNode—The section heading; a string also becomes the section's accessible name.
descriptionReact.ReactNode—One or two lines under the heading.
actionsReact.ReactNode—Sits with the heading — a link out, a secondary action, a status.
asidebooleanfalseMoves 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.labelReact.ReactNode—Names the control the row wraps.
FormRow.descriptionReact.ReactNode—A hint under the label.
FormRow.htmlForstring—The id of the control being labelled; the error takes the same id with -error appended.
FormRow.requiredbooleanfalseMarks the row with an asterisk and the words required for screen readers.
FormRow.errorReact.ReactNode—A message under the control; it also flags the row as invalid and points the control at the message.
FormRow.childrenReact.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

Source

components/ui/form-section.tsx
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 }