Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
triggerReactNodeThe element that opens the popover, usually a Button.
titlestringPopover heading, also its accessible name.
descriptionstringOptional muted line under the title.
submitLabelstring"Save"Text of the submit button.
onSubmit(data: FormData) => voidReceives the form data. The popover closes afterwards.
defaultOpenbooleanStart 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