Popover form
A compact form in a popover with a title, fields, cancel and submit.
npx shadcn@latest add https://ui.goodtabs.co/r/popover-form.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/popover-form
One field. Enter submits and Escape closes.
Two fields
Any controls go in as children, each wrapped in PopoverFormField.
Usage
import { PopoverForm, PopoverFormField } from "@/components/ui/popover-form"
<PopoverForm title="Rename" trigger={<Button>Rename</Button>} onSubmit={(data) => save(data.get("name"))}>
<PopoverFormField label="Name" id="name">
<Input id="name" name="name" />
</PopoverFormField>
</PopoverForm>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | ReactNode | The element that opens the popover, usually a Button. | |
| title | string | Popover heading, also its accessible name. | |
| description | string | Optional muted line under the title. | |
| submitLabel | string | "Save" | Text of the submit button. |
| onSubmit | (data: FormData) => void | Receives the form data. The popover closes afterwards. | |
| defaultOpen | boolean | Start open, for demos and tours. |
Source
popover-form/popover-form.tsx
"use client";
import * as React from "react";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
type PopoverFormProps = {
/** The element that opens the popover. Rendered as the trigger child. */
trigger: React.ReactNode;
title: string;
description?: string;
/** Label of the submit button. */
submitLabel?: string;
/** Called with the form data. The popover closes after it returns. */
onSubmit?: (data: FormData) => void;
defaultOpen?: boolean;
className?: string;
/** The fields. Use PopoverFormField for each one. */
children: React.ReactNode;
};
/** A compact form inside a popover. Enter submits, Escape closes, focus returns to the trigger. */
function PopoverForm({
trigger,
title,
description,
submitLabel = "Save",
onSubmit,
defaultOpen,
className,
children,
}: PopoverFormProps) {
const [open, setOpen] = React.useState(defaultOpen ?? false);
const hasOpenedByPerson = React.useRef(false);
return (
<Popover
open={open}
onOpenChange={(next) => {
if (next) hasOpenedByPerson.current = true;
setOpen(next);
}}
>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
align="start"
className={cn("w-64 gap-3", className)}
onOpenAutoFocus={(event) => {
if (defaultOpen && !hasOpenedByPerson.current) event.preventDefault();
}}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
onSubmit?.(new FormData(event.currentTarget));
setOpen(false);
}}
>
<PopoverHeader>
<PopoverTitle>{title}</PopoverTitle>
{description ? <PopoverDescription>{description}</PopoverDescription> : null}
</PopoverHeader>
{children}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" size="sm">
{submitLabel}
</Button>
</div>
</form>
</PopoverContent>
</Popover>
);
}
/** A label above a control. The control gets the id, so the label click focuses it. */
function PopoverFormField({ label, id, children }: { label: string; id: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
export { PopoverForm, PopoverFormField };
For AI agents
Hand your agent this page, the markdown at /components/popover-form/prompt.md, or the Copy AI prompt button above.
When to use
- Renaming, tagging or reporting something without leaving the page
- One to three fields that belong next to the thing they change
When not to use
- Long forms or anything with steps: use a dialog or a page
- Destructive confirmations: use an alert dialog
Agent notes
- Give every control a name attribute, the form data is read with FormData
- Field ids must be unique on the page, the label points at them
- Radix handles Escape, outside clicks and returning focus to the trigger