For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
Home

Combobox

An autocomplete input combining a trigger button with a searchable dropdown list.

import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";

const frameworks = [
  { label: "Next.js", value: "next.js" },
  { label: "SvelteKit", value: "sveltekit" },
  { label: "Nuxt.js", value: "nuxt.js" },
  { label: "Remix", value: "remix" },
  { label: "Astro", value: "astro" },
];

export const ComboboxDemo = () => {
  const [open, setOpen] = useState(false);
  const [value, setValue] = useState("");

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild><Button variant="outline" role="combobox" aria-expanded={open} className="w-[200px] justify-between">
        {value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}
        <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
      </Button></PopoverTrigger>
      <PopoverContent className="w-[200px] p-0"><Command>
        <CommandInput placeholder="Search framework..." />
        <CommandList>
          <CommandEmpty>No framework found.</CommandEmpty>
          <CommandGroup>
            {frameworks.map((framework) => (
              <CommandItem
                key={framework.value}
                value={framework.value}
                onSelect={(currentValue) => {
                  setValue(currentValue === value ? "" : currentValue);
                  setOpen(false);
                }}
              >
                <Check className={cn( "size-4", value === framework.value ? "opacity-100" : "opacity-0", )} />
                {framework.label}
              </CommandItem>
            ))}
          </CommandGroup>
        </CommandList>
      </Command></PopoverContent>
    </Popover>
  );
};

Popover + Command

The Combobox pattern wraps Command inside a Popover. The trigger button shows the selected value and toggles the dropdown. CommandInput handles filtering — cmdk does it automatically, no extra state needed.

"use client";
 
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
 
const options = [
  { value: "next.js", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
];
 
export function Combobox() {
  const [open, setOpen] = useState(false);
  const [value, setValue] = useState("");
 
  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          role="combobox"
          aria-expanded={open}
          className="w-[200px] justify-between"
        >
          {value
            ? options.find((o) => o.value === value)?.label
            : "Select..."}
          <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-[200px] p-0">
        <Command>
          <CommandInput placeholder="Search..." />
          <CommandList>
            <CommandEmpty>No results found.</CommandEmpty>
            <CommandGroup>
              {options.map((option) => (
                <CommandItem
                  key={option.value}
                  value={option.value}
                  onSelect={(current) => {
                    setValue(current === value ? "" : current);
                    setOpen(false);
                  }}
                >
                  <Check
                    className={cn(
                      "size-4",
                      value === option.value ? "opacity-100" : "opacity-0"
                    )}
                  />
                  {option.label}
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

With Groups

Use CommandGroup with a heading to organise options into sections.

<Command>
  <CommandInput placeholder="Search..." />
  <CommandList>
    <CommandEmpty>No results found.</CommandEmpty>
    <CommandGroup heading="Frontend">
      <CommandItem value="react">React</CommandItem>
      <CommandItem value="vue">Vue</CommandItem>
    </CommandGroup>
    <CommandGroup heading="Backend">
      <CommandItem value="node">Node.js</CommandItem>
      <CommandItem value="django">Django</CommandItem>
    </CommandGroup>
  </CommandList>
</Command>

Multi-select

For multiple selections, keep the popover open after each selection and store an array of values.

const [values, setValues] = useState<string[]>([]);
 
const toggle = (current: string) =>
  setValues((prev) =>
    prev.includes(current)
      ? prev.filter((v) => v !== current)
      : [...prev, current]
  );
 
<CommandItem
  key={option.value}
  value={option.value}
  onSelect={toggle}
>
  <Check
    className={cn(
      "size-4",
      values.includes(option.value) ? "opacity-100" : "opacity-0"
    )}
  />
  {option.label}
</CommandItem>

For large datasets, filter server-side by controlling CommandInput and fetching on change.

const [query, setQuery] = useState("");
const [results, setResults] = useState<Option[]>([]);
 
useEffect(() => {
  if (!query) return;
  fetch(`/api/search?q=${query}`)
    .then((r) => r.json())
    .then(setResults);
}, [query]);
 
<Command shouldFilter={false}>
  <CommandInput value={query} onValueChange={setQuery} placeholder="Search..." />
  <CommandList>
    <CommandEmpty>No results.</CommandEmpty>
    <CommandGroup>
      {results.map((r) => (
        <CommandItem key={r.value} value={r.value} onSelect={...}>
          {r.label}
        </CommandItem>
      ))}
    </CommandGroup>
  </CommandList>
</Command>

In a Form

Use with React Hook Form via Controller.

import { Controller } from "react-hook-form";
 
<Controller
  control={form.control}
  name="framework"
  render={({ field }) => (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button variant="outline" role="combobox" className="w-full justify-between">
          {field.value
            ? options.find((o) => o.value === field.value)?.label
            : "Select..."}
          <ChevronsUpDown className="ml-2 size-4 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="p-0">
        <Command>
          <CommandInput placeholder="Search..." />
          <CommandList>
            <CommandEmpty>No results.</CommandEmpty>
            <CommandGroup>
              {options.map((o) => (
                <CommandItem
                  key={o.value}
                  value={o.value}
                  onSelect={(v) => {
                    field.onChange(v);
                    setOpen(false);
                  }}
                >
                  <Check className={cn("size-4", field.value === o.value ? "opacity-100" : "opacity-0")} />
                  {o.label}
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )}
/>

Specs

# Mint — Combobox Specs
 
## Component
- name: combobox
- file: composition pattern — no standalone file
- installs to: composition of components/ui/popover.tsx + components/ui/command.tsx + components/ui/button.tsx
- dependencies: radix-ui, cmdk, lucide-react, class-variance-authority
- registryDependencies: popover, command, button
 
## What is it
A searchable select field: a trigger button that opens a filtered dropdown list
built by nesting Command inside a Popover. cmdk handles client-side filtering
automatically; set shouldFilter={false} for server-side search. Prefer Combobox
over a native select when the option list is long enough to benefit from search,
or when options are loaded asynchronously.
 
## Visual identity
The trigger uses Button variant="outline" — transparent background with a soft
glassy green-tinted border, gaining a tinted accent background on hover. The
dropdown surface uses --popover (white in light, dark grey in dark), giving a
clean floating panel feel consistent with Mint's rounded-md, shadow-md
aesthetic. Compact row heights and a hairline border-b under the search input
maintain density without crowding.
 
## Design tokens
--popover: oklch(1 0 0) / oklch(0.269 0 0)           — dropdown panel background
--popover-foreground: oklch(0.145 0 0) / oklch(0.985 0 0) — dropdown text
--accent: oklch(0.92 0.05 155) / oklch(0.26 0.05 155) — selected/hovered item background
--accent-foreground: oklch(0.32 0.1 155) / oklch(0.88 0.06 155)  — selected/hovered item text
--muted-foreground: oklch(0.52 0.06 155) / oklch(0.6 0.06 155)   — placeholder, search icon, group headings, shortcut text
--foreground: oklch(0.145 0 0) / oklch(0.985 0 0)     — item text
--border: oklch(0.88 0.04 155 / 60%) / oklch(0.32 0.05 155 / 40%)       — CommandInput bottom border, popover border
--input: oklch(0.88 0.04 155 / 60%) / oklch(0.32 0.05 155 / 40%)        — trigger border (outline button in dark)
--ring: oklch(0.5 0.14 155) / oklch(0.62 0.15 155)           — trigger focus ring
--radius: 1.25rem                                     — panel, items, trigger corner radius
 
## Sub-components
Popover              → controls open/close state; accepts sounds prop for audio feedback
PopoverTrigger       → wraps the Button trigger; use asChild
PopoverContent       → floating panel; set p-0 to let Command fill it edge-to-edge
Command              → root container for the filtered list; set shouldFilter={false} for server search
CommandInput         → search field with leading SearchIcon; cmdk wires filtering automatically
CommandList          → scrollable list container (max-h 300px)
CommandEmpty         → zero-results message; always include
CommandGroup         → optional section container; accepts heading prop
CommandItem          → individual option row; onSelect receives the item value string
CommandSeparator     → horizontal rule between groups
CommandShortcut      → right-aligned shortcut label inside a CommandItem
 
## External dependencies
- cmdk: headless command-palette / filtered list primitive; powers Command and all its sub-components
 
## States
- data-[selected=true]: item highlighted via keyboard or hover — bg-accent text-accent-foreground
- data-[disabled=true]: item non-interactive — pointer-events-none opacity-50
- focus-visible (trigger Button): 3px ring in --ring colour at ring/50 opacity
- open (PopoverContent): fade-in-0 zoom-in-95 entrance animation; fade-out-0 zoom-out-95 exit
- disabled (CommandInput): cursor-not-allowed opacity-50
 
## Conventions
- Always set className="p-0" on PopoverContent so Command fills the panel without double padding
- Match PopoverContent width to the trigger width (e.g. w-[200px]) to prevent layout shift
- Always include CommandEmpty — cmdk hides it automatically when results exist
- Add role="combobox" and aria-expanded={open} to the trigger Button for accessibility
- Use a Check icon with opacity-100 / opacity-0 (not conditional render) to keep row width stable
- Deselect: compare onSelect's current value to stored value and reset to "" on match
- Multi-select: store string[]; keep popover open after selection; use Array.includes for Check opacity
- Server search: set shouldFilter={false} on Command, control CommandInput value, debounce fetches
- In forms: wrap with react-hook-form Controller — do not use register for controlled popovers
- sounds: Popover accepts a sounds prop — GIN Design exclusive that plays audio on open/close
- Sub-components expose data-slot attributes for CSS targeting: "popover", "popover-trigger", "popover-content", "command", "command-input-wrapper", "command-input", "command-list", "command-empty", "command-group", "command-item", "command-separator", "command-shortcut"

Installation

Install the required components:

$ npx shadcn@latest add https://gin-design.vercel.app/r/popover.json
npx shadcn@latest add https://gin-design.vercel.app/r/command.json