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.
npx shadcn@latest add @paragon/form-sectionAlso installs: button
"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>
);
}