Form section
A settings form layout: title on the left, fields in a bordered surface, a save bar below.
npx shadcn@latest add https://ui.goodtabs.co/r/form-section.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/form-section
Name, email and bio rows with Input and Textarea, and the default footer.
Compact, no footer
Two rows and no footer, for sections that save on their own.
Two sections stacked
One form, two sections, the footer only on the last.
Usage
import { FormSection, FormSectionFooter, FormSectionRow } from "@/components/form-section"
import { Input } from "@/components/ui/input"
<form action={save}>
<FormSection title="Profile" description="How you appear to others." footer={<FormSectionFooter />}>
<FormSectionRow label="Name" htmlFor="name">
<Input id="name" name="name" />
</FormSectionRow>
</FormSection>
</form>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | Section title, shown in the left column from md up. | |
| description | ReactNode | One or two sentences under the title. | |
| children | ReactNode | FormSectionRow elements. Hairlines are drawn between them. | |
| footer | ReactNode | A FormSectionFooter, or your own bar, under the rows. | |
| label | ReactNode | On FormSectionRow: the field label. | |
| htmlFor | string | On FormSectionRow: id of the control, so the label focuses it. | |
| cancelLabel, saveLabel | string | "Cancel", "Save" | On FormSectionFooter: the two button labels. Pass children to replace both. |
Source
form-section/form-section.tsx
import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type FormSectionProps = Omit<React.ComponentProps<"section">, "title"> & {
/** Section title, shown in the left column from md up. */
title: React.ReactNode;
/** One or two sentences under the title. */
description?: React.ReactNode;
/** FormSectionRow elements. Hairlines are drawn between them. */
children: React.ReactNode;
/** A FormSectionFooter, or your own bar, rendered under the rows. */
footer?: React.ReactNode;
};
/**
* A settings form layout: title and description on the left, the fields on the right in a bordered surface.
* Server component. Wrap one or more sections in your own form element.
*/
export function FormSection({ title, description, footer, className, children, ...props }: FormSectionProps) {
return (
<section
data-slot="form-section"
className={cn("grid w-full gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-8", className)}
{...props}
>
<div className="flex flex-col gap-1">
<h2 className="m-0 text-sm font-medium text-foreground">{title}</h2>
{description && <p className="m-0 text-sm text-muted-foreground">{description}</p>}
</div>
<div className="flex min-w-0 flex-col overflow-hidden rounded-lg border bg-card">
<div className="divide-y">{children}</div>
{footer}
</div>
</section>
);
}
type FormSectionRowProps = Omit<React.ComponentProps<"div">, "children"> & {
/** Field label. Becomes a label element when htmlFor is set. */
label: React.ReactNode;
/** Help text under the label. */
description?: React.ReactNode;
/** Id of the control, so the label focuses it. */
htmlFor?: string;
/** The control: an Input, Textarea, Switch, Select. */
children: React.ReactNode;
};
/** One field: label and help on the left, the control on the right from sm up. */
export function FormSectionRow({ label, description, htmlFor, className, children, ...props }: FormSectionRowProps) {
const Text = htmlFor ? "label" : "span";
return (
<div
data-slot="form-section-row"
className={cn(
"grid gap-2 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] sm:items-start sm:gap-4",
className,
)}
{...props}
>
<div className="flex flex-col gap-0.5 sm:pt-1.5">
<Text htmlFor={htmlFor} className="text-sm leading-5 font-medium text-foreground">
{label}
</Text>
{description && <span className="text-xs text-muted-foreground">{description}</span>}
</div>
<div className="min-w-0">{children}</div>
</div>
);
}
type FormSectionFooterProps = React.ComponentProps<"div"> & {
/** Label of the cancel button. */
cancelLabel?: string;
/** Label of the save button. It submits the surrounding form. */
saveLabel?: string;
/** Replace the default buttons with your own. */
children?: React.ReactNode;
};
/** The bar under the rows: a ghost cancel and a primary save. */
export function FormSectionFooter({
cancelLabel = "Cancel",
saveLabel = "Save",
className,
children,
...props
}: FormSectionFooterProps) {
return (
<div
data-slot="form-section-footer"
className={cn("flex items-center justify-end gap-2 border-t bg-muted/40 px-4 py-3", className)}
{...props}
>
{children ?? (
<>
<Button type="reset" variant="ghost">
{cancelLabel}
</Button>
<Button type="submit">{saveLabel}</Button>
</>
)}
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/form-section/prompt.md, or the Copy AI prompt button above.
When to use
- Settings and profile pages with a few fields per topic
- Any form where each field needs a label and a line of help beside it
- Several topics on one page, stacked with one save bar at the end
When not to use
- Sign in, sign up and other short single column forms
- Long wizards or forms with conditional steps
- Tables of editable rows, which want a data table
Agent notes
- Server component with no state; wrap sections in your own form and the footer's save button submits it
- The cancel button is type reset, so it clears uncontrolled fields; pass children to the footer for your own buttons
- Give every control an id and pass it as htmlFor so the row label focuses it
- The title column collapses above the surface below md; rows stack their label and control below sm