Skip to content
goodtabs UI
BrowseBrowse components

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

Profile

How you appear to other people on goodtabs.

Used to sign in. Never shown.
One or two lines, shown on your public page.

Name, email and bio rows with Input and Textarea, and the default footer.

Compact, no footer

Domain

Sent to the custom domain.

Two rows and no footer, for sections that save on their own.

Two sections stacked

Workspace

The name and url your team sees.

goodtabs.co/w/slug

Notifications

What lands in your inbox.

New picks every Monday.
When someone saves your listing.

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

PropTypeDefaultDescription
titleReactNodeSection title, shown in the left column from md up.
descriptionReactNodeOne or two sentences under the title.
childrenReactNodeFormSectionRow elements. Hairlines are drawn between them.
footerReactNodeA FormSectionFooter, or your own bar, under the rows.
labelReactNodeOn FormSectionRow: the field label.
htmlForstringOn FormSectionRow: id of the control, so the label focuses it.
cancelLabel, saveLabelstring"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