# Quick Add Popover

> A "+ New" popover form with title, type segmented control, due-date chips and validation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/quick-add-popover
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/quick-add-popover.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/quick-add-popover-demo.tsx"
"use client"

import * as React from "react"
import { Bug, CheckCircle2, Lightbulb, ListTodo } from "lucide-react"

import { QuickAddPopover, quickAddDueOptions, type QuickAddItem } from "@/components/quick-add-popover"
import { cn } from "@/lib/utils"

interface Entry extends QuickAddItem {
  id: number
}

const icons: Record<string, React.ReactNode> = {
  Task: <ListTodo className="size-4" aria-hidden />,
  Bug: <Bug className="size-4" aria-hidden />,
  Idea: <Lightbulb className="size-4" aria-hidden />,
}

const tones: Record<string, string> = {
  Task: "bg-sky-500/15 text-sky-600",
  Bug: "bg-rose-500/15 text-rose-600",
  Idea: "bg-amber-500/15 text-amber-600",
}

const initial: Entry[] = [
  { id: 1, title: "Review pull request #482", type: "Task", due: "today" },
  { id: 2, title: "Tooltip clips on small screens", type: "Bug", due: "tomorrow" },
]

export default function QuickAddPopoverDemo() {
  const [items, setItems] = React.useState(initial)
  const nextId = React.useRef(3)

  function add(item: QuickAddItem) {
    setItems((list) => [{ ...item, id: nextId.current++ }, ...list])
  }

  return (
    <div className="mx-auto flex min-h-[26rem] w-full max-w-md flex-col gap-4">
      <div className="flex items-center justify-between gap-3">
        <div className="min-w-0">
          <h3 className="text-sm font-semibold">This week</h3>
          <p className="text-xs text-muted-foreground" aria-live="polite">
            {items.length} {items.length === 1 ? "item" : "items"}
          </p>
        </div>
        <QuickAddPopover onAdd={add} align="end" />
      </div>

      <ul aria-label="Items" className="grid gap-2">
        {items.map((item) => (
          <li key={item.id} className="flex min-w-0 items-center gap-3 rounded-xl border bg-card p-3 shadow-xs">
            <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", tones[item.type] ?? "bg-muted text-muted-foreground")}>
              {icons[item.type] ?? <CheckCircle2 className="size-4" aria-hidden />}
            </span>
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm font-medium">{item.title}</p>
              <p className="truncate text-xs text-muted-foreground">
                {item.type} · {quickAddDueOptions.find((o) => o.id === item.due)?.label ?? "No due date"}
              </p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  )
}
```

## Source

```tsx title="components/quick-add-popover.tsx"
"use client"

import * as React from "react"
import { Plus } from "lucide-react"

import { cn } from "@/lib/utils"

export interface QuickAddItem {
  title: string
  type: string
  /** Quick-chip id: "today" | "tomorrow" | "next-week" or "" for no date */
  due: string
}

export interface QuickAddPopoverProps extends Omit<React.ComponentProps<"div">, "children"> {
  onAdd: (item: QuickAddItem) => void
  /** Label of the trigger button */
  label?: string
  types?: string[]
  align?: "start" | "end"
  /** Panel width in px (never wider than the viewport) */
  width?: number
}

export const quickAddDueOptions = [
  { id: "today", label: "Today" },
  { id: "tomorrow", label: "Tomorrow" },
  { id: "next-week", label: "Next week" },
] as const

const focusRing = "focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"

export function QuickAddPopover({
  onAdd,
  label = "New",
  types = ["Task", "Bug", "Idea"],
  align = "start",
  width = 320,
  className,
  ...props
}: QuickAddPopoverProps) {
  const [open, setOpen] = React.useState(false)
  const [title, setTitle] = React.useState("")
  const [type, setType] = React.useState(types[0])
  const [due, setDue] = React.useState("")
  const [error, setError] = React.useState("")
  const root = React.useRef<HTMLDivElement>(null)
  const trigger = React.useRef<HTMLButtonElement>(null)
  const input = React.useRef<HTMLInputElement>(null)
  const id = React.useId()

  const close = React.useCallback((restoreFocus = true) => {
    setOpen(false)
    if (restoreFocus) trigger.current?.focus()
  }, [])

  React.useEffect(() => {
    if (open) input.current?.focus()
  }, [open])

  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") close()
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onKeyDown)
    }
  }, [open, close])

  function submit(event: React.FormEvent) {
    event.preventDefault()
    const value = title.trim()
    if (!value) {
      setError("Give it a title first.")
      input.current?.focus()
      return
    }
    onAdd({ title: value, type, due })
    setTitle("")
    setDue("")
    setError("")
    close()
  }

  function onTypeKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
    const index = types.indexOf(type)
    let next: number | null = null
    if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % types.length
    else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + types.length) % types.length
    if (next === null) return
    event.preventDefault()
    setType(types[next])
    event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus()
  }

  return (
    <div ref={root} className={cn("relative inline-block", className)} {...props}>
      <button
        ref={trigger}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls={open ? id : undefined}
        onClick={() => (open ? close(false) : setOpen(true))}
        className={cn(
          "inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-xs transition-opacity hover:opacity-90",
          focusRing
        )}
      >
        <Plus className="size-4" aria-hidden /> {label}
      </button>
      {open && (
        <form
          id={id}
          role="dialog"
          aria-label="Quick add"
          noValidate
          onSubmit={submit}
          style={{ width }}
          className={cn(
            "absolute top-full z-30 mt-2 max-w-[calc(100vw-2rem)] origin-top animate-in rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg duration-150 fade-in zoom-in-95 motion-reduce:animate-none",
            align === "start" ? "left-0" : "right-0"
          )}
        >
          <div className="grid gap-4">
            <div className="grid gap-1.5">
              <label htmlFor={`${id}-title`} className="text-sm font-medium">
                Title
              </label>
              <input
                ref={input}
                id={`${id}-title`}
                value={title}
                onChange={(event) => {
                  setTitle(event.target.value)
                  if (error) setError("")
                }}
                aria-invalid={Boolean(error)}
                aria-describedby={error ? `${id}-error` : undefined}
                placeholder="What needs doing?"
                autoComplete="off"
                className={cn(
                  "h-9 w-full min-w-0 rounded-lg border bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/20",
                  error && "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20"
                )}
              />
              {error && (
                <p id={`${id}-error`} role="alert" className="text-xs text-destructive">
                  {error}
                </p>
              )}
            </div>

            <div className="grid gap-1.5">
              <span id={`${id}-type`} className="text-sm font-medium">
                Type
              </span>
              <div role="radiogroup" aria-labelledby={`${id}-type`} className="grid auto-cols-fr grid-flow-col gap-1 rounded-lg bg-muted p-1">
                {types.map((option) => {
                  const active = option === type
                  return (
                    <button
                      key={option}
                      type="button"
                      role="radio"
                      aria-checked={active}
                      tabIndex={active ? 0 : -1}
                      onClick={() => setType(option)}
                      onKeyDown={onTypeKeyDown}
                      className={cn(
                        "h-7 min-w-0 truncate rounded-md px-2 text-sm font-medium transition-colors",
                        focusRing,
                        active ? "bg-background text-foreground shadow-sm ring-1 ring-border" : "text-muted-foreground hover:text-foreground"
                      )}
                    >
                      {option}
                    </button>
                  )
                })}
              </div>
            </div>

            <div className="grid gap-1.5">
              <span id={`${id}-due`} className="text-sm font-medium">
                Due <span className="font-normal text-muted-foreground">(optional)</span>
              </span>
              <div role="group" aria-labelledby={`${id}-due`} className="flex flex-wrap gap-1.5">
                {quickAddDueOptions.map((option) => {
                  const active = due === option.id
                  return (
                    <button
                      key={option.id}
                      type="button"
                      aria-pressed={active}
                      onClick={() => setDue(active ? "" : option.id)}
                      className={cn(
                        "h-7 rounded-full border px-3 text-xs font-medium transition-colors",
                        focusRing,
                        active ? "border-primary bg-primary text-primary-foreground" : "bg-background text-muted-foreground hover:bg-accent hover:text-foreground"
                      )}
                    >
                      {option.label}
                    </button>
                  )
                })}
              </div>
            </div>

            <div className="flex items-center justify-between gap-3">
              <span className="text-xs text-muted-foreground">Press Enter to add</span>
              <button
                type="submit"
                className={cn("inline-flex h-8 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90", focusRing)}
              >
                Add
              </button>
            </div>
          </div>
        </form>
      )}
    </div>
  )
}
```
