Layout

Form Section

A form layout primitive with a title/description column, a consistent fields grid, and a sticky save bar that slides up only while the form is dirty.

Install

npx shadcn@latest add @paragon/form-section

Also installs: button

form-section.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

export interface FormSectionProps extends React.ComponentProps<"section"> {
  title: string;
  description?: string;
}

/**
 * A form layout primitive: title and description in a side column,
 * fields stacked in a consistent grid on the right.
 */
export function FormSection({
  title,
  description,
  children,
  className,
  ...props
}: FormSectionProps) {
  const id = React.useId();
  return (
    <section
      aria-labelledby={`${id}-title`}
      className={cn(
        "grid gap-x-12 gap-y-4 py-6 sm:grid-cols-[200px_minmax(0,1fr)]",
        className,
      )}
      {...props}
    >
      <div className="flex flex-col gap-1">
        <h3 id={`${id}-title`} className="text-sm font-medium">
          {title}
        </h3>
        {description && (
          <p className="text-sm text-muted-foreground">{description}</p>
        )}
      </div>
      <div className="grid content-start gap-4">{children}</div>
    </section>
  );
}

export interface FormSectionSaveBarProps {
  /** The bar is visible only while the form is dirty. */
  dirty: boolean;
  onSave?: () => void;
  onDiscard?: () => void;
  saveLabel?: string;
  discardLabel?: string;
  message?: string;
  /** Disables the save button while a save is in flight. */
  saving?: boolean;
  className?: string;
}

/**
 * A sticky save bar that slides up (translateY + fade) only while the
 * form is dirty, and drops away once changes are saved or discarded.
 */
export function FormSectionSaveBar({
  dirty,
  onSave,
  onDiscard,
  saveLabel = "Save changes",
  discardLabel = "Discard",
  message = "You have unsaved changes",
  saving = false,
  className,
}: FormSectionSaveBarProps) {
  const reduced = useReducedMotion();
  return (
    <div aria-live="polite" className={cn("sticky bottom-4 z-10", className)}>
      <AnimatePresence initial={false}>
        {dirty && (
          <motion.div
            initial={
              reduced
                ? { opacity: 0 }
                : { opacity: 0, y: 16, filter: "blur(4px)" }
            }
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            exit={
              reduced
                ? { opacity: 0, transition: { duration: 0.15 } }
                : {
                    opacity: 0,
                    y: 8,
                    filter: "blur(4px)",
                    transition: { duration: 0.15, ease: EASE_EXIT },
                  }
            }
            transition={{ type: "spring", duration: 0.4, bounce: 0 }}
            className="flex items-center justify-between gap-4 rounded-lg bg-card px-4 py-3 shadow-overlay"
          >
            <span className="text-sm text-muted-foreground">{message}</span>
            <span className="flex shrink-0 items-center gap-2">
              <Button variant="ghost" size="sm" onClick={onDiscard}>
                {discardLabel}
              </Button>
              <Button size="sm" onClick={onSave} disabled={saving}>
                {saving ? "Saving…" : saveLabel}
              </Button>
            </span>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}