Gyoza UI

Combobox

Autocomplete input and command palette with a list of suggestions.

デモ

Installation

npx shadcn-ui@latest add combobox
Copy and paste the following code into your project.
"use client"

import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"

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

interface ComboboxContextType {
  open: boolean
  setOpen: (open: boolean) => void
  value: string
  setValue: (value: string) => void
  activeIndex: number
  setActiveIndex: (index: number) => void
  items: string[]
  registerItem: (value: string) => void
  unregisterItem: (value: string) => void
  inputId: string
  listboxId: string
}

const ComboboxContext = React.createContext<ComboboxContextType | undefined>(
  undefined
)

function useCombobox() {
  const context = React.useContext(ComboboxContext)
  if (!context) {
    throw new Error("useCombobox must be used within a Combobox")
  }
  return context
}

interface ComboboxProps {
  children: React.ReactNode
  value?: string
  onValueChange?: (value: string) => void
  className?: string
}

export function Combobox({
  children,
  value: controlledValue,
  onValueChange,
  className,
}: ComboboxProps) {
  const [uncontrolledValue, setUncontrolledValue] = React.useState("")
  const [open, setOpen] = React.useState(false)
  const [activeIndex, setActiveIndex] = React.useState(-1)
  const [items, setItems] = React.useState<string[]>([])

  const value = controlledValue ?? uncontrolledValue
  const setValue = React.useCallback(
    (newValue: string) => {
      if (onValueChange) {
        onValueChange(newValue)
      } else {
        setUncontrolledValue(newValue)
      }
    },
    [onValueChange]
  )

  const registerItem = React.useCallback((itemValue: string) => {
    setItems((prev) => {
      if (prev.includes(itemValue)) return prev
      return [...prev, itemValue]
    })
  }, [])

  const unregisterItem = React.useCallback((itemValue: string) => {
    setItems((prev) => prev.filter((v) => v !== itemValue))
  }, [])

  const inputId = React.useId()
  const listboxId = React.useId()

  return (
    <ComboboxContext.Provider
      value={{
        open,
        setOpen,
        value,
        setValue,
        activeIndex,
        setActiveIndex,
        items,
        registerItem,
        unregisterItem,
        inputId,
        listboxId,
      }}
    >
      <div className={cn("relative w-full", className)}>{children}</div>
    </ComboboxContext.Provider>
  )
}

interface ComboboxInputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
  displayValue?: (value: string) => string
}

export const ComboboxInput = React.forwardRef<
  HTMLInputElement,
  ComboboxInputProps
>(({ className, displayValue, ...props }, ref) => {
  const {
    open,
    setOpen,
    value,
    items,
    activeIndex,
    setActiveIndex,
    setValue,
    inputId,
    listboxId,
  } = useCombobox()

  const [inputValue, setInputValue] = React.useState(
    displayValue ? displayValue(value) : value
  )

  React.useEffect(() => {
    setInputValue(displayValue ? displayValue(value) : value)
  }, [value, displayValue])

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (!open) {
      if (e.key === "ArrowDown" || e.key === "ArrowUp") {
        setOpen(true)
        e.preventDefault()
      }
      return
    }

    switch (e.key) {
      case "ArrowDown":
        e.preventDefault()
        setActiveIndex((activeIndex + 1) % items.length)
        break
      case "ArrowUp":
        e.preventDefault()
        setActiveIndex((activeIndex - 1 + items.length) % items.length)
        break
      case "Enter":
        e.preventDefault()
        if (activeIndex >= 0 && activeIndex < items.length) {
          setValue(items[activeIndex])
          setOpen(false)
        }
        break
      case "Escape":
        setOpen(false)
        break
    }
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value)
    if (!open) setOpen(true)
  }

  return (
    <div className="relative flex items-center">
      <input
        ref={ref}
        id={inputId}
        type="text"
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={open}
        aria-controls={listboxId}
        aria-activedescendant={
          activeIndex >= 0 ? `${listboxId}-item-${activeIndex}` : undefined
        }
        className={cn(
          "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        value={inputValue}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        onFocus={() => setOpen(true)}
        {...props}
      />
      <ComboboxTrigger />
    </div>
  )
})
ComboboxInput.displayName = "ComboboxInput"

export function ComboboxTrigger() {
  const { setOpen, open } = useCombobox()
  return (
    <button
      type="button"
      onClick={() => setOpen(!open)}
      className="absolute right-2 flex h-4 w-4 items-center justify-center opacity-50 hover:opacity-100"
      tabIndex={-1}
    >
      <ChevronsUpDown className="h-4 w-4" />
    </button>
  )
}

interface ComboboxContentProps extends React.HTMLAttributes<HTMLDivElement> {}

export function ComboboxContent({ className, children, ...props }: ComboboxContentProps) {
  const { open, listboxId, inputId, setOpen } = useCombobox()
  const ref = React.useRef<HTMLDivElement>(null)

  const [position, setPosition] = React.useState({ top: 0, left: 0, width: 0 })

  React.useLayoutEffect(() => {
    if (open) {
      const input = document.getElementById(inputId)
      if (input) {
        const rect = input.getBoundingClientRect()
        setPosition({
          top: rect.bottom + window.scrollY + 4,
          left: rect.left + window.scrollX,
          width: rect.width,
        })
      }
    }
  }, [open, inputId])

  React.useEffect(() => {
    if (!open) return
    const handleClick = (e: MouseEvent) => {
      const input = document.getElementById(inputId)
      if (
        ref.current &&
        !ref.current.contains(e.target as Node) &&
        input &&
        !input.contains(e.target as Node)
      ) {
        setOpen(false)
      }
    }
    document.addEventListener("mousedown", handleClick)
    return () => document.removeEventListener("mousedown", handleClick)
  }, [open, inputId, setOpen])

  if (!open) return null

  return (
    <div
      ref={ref}
      id={listboxId}
      role="listbox"
      // @ts-ignore - popover API
      popover="manual"
      style={{
        position: "absolute",
        top: position.top,
        left: position.left,
        width: position.width,
        margin: 0,
      }}
      className={cn(
        "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
        className
      )}
      {...props}
    >
      <div className="p-1">{children}</div>
    </div>
  )
}

interface ComboboxItemProps extends React.HTMLAttributes<HTMLDivElement> {
  value: string
  onSelect?: () => void
}

export function ComboboxItem({
  className,
  children,
  value: itemValue,
  onSelect,
  ...props
}: ComboboxItemProps) {
  const {
    value,
    setValue,
    setOpen,
    items,
    registerItem,
    unregisterItem,
    activeIndex,
    setActiveIndex,
    listboxId,
  } = useCombobox()

  React.useEffect(() => {
    registerItem(itemValue)
    return () => unregisterItem(itemValue)
  }, [itemValue, registerItem, unregisterItem])

  const index = items.indexOf(itemValue)
  const isActive = index === activeIndex
  const isSelected = value === itemValue

  const handleSelect = () => {
    setValue(itemValue)
    setOpen(false)
    onSelect?.()
  }

  return (
    <div
      role="option"
      id={`${listboxId}-item-${index}`}
      aria-selected={isSelected}
      data-active={isActive}
      className={cn(
        "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[active=true]:bg-accent data-[active=true]:text-accent-foreground",
        className
      )}
      onClick={handleSelect}
      onMouseEnter={() => setActiveIndex(index)}
      {...props}
    >
      <span className={cn("mr-2 flex h-3.5 w-3.5 items-center justify-center")}>
        {isSelected && <Check className="h-4 w-4" />}
      </span>
      {children}
    </div>
  )
}

export function ComboboxEmpty({
  children,
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cn("py-6 text-center text-sm", className)}
      {...props}
    >
      {children}
    </div>
  )
}

export function ComboboxGroup({
  children,
  className,
  heading,
  ...props
}: React.HTMLAttributes<HTMLDivElement> & { heading?: React.ReactNode }) {
  return (
    <div className={cn("overflow-hidden", className)} {...props}>
      {heading && (
        <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
          {heading}
        </div>
      )}
      {children}
    </div>
  )
}
Update the import paths to match your project setup.

Usage

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
} from "@/registry/ui/combobox"
<Combobox>
  <ComboboxInput placeholder="Search framework..." />
  <ComboboxContent>
    <ComboboxEmpty>No framework found.</ComboboxEmpty>
    <ComboboxGroup heading="Frameworks">
      <ComboboxItem value="next.js">Next.js</ComboboxItem>
      <ComboboxItem value="sveltekit">SvelteKit</ComboboxItem>
      <ComboboxItem value="nuxt.js">Nuxt.js</ComboboxItem>
      <ComboboxItem value="remix">Remix</ComboboxItem>
      <ComboboxItem value="astro">Astro</ComboboxItem>
    </ComboboxGroup>
  </ComboboxContent>
</Combobox>

Examples

Form

Form Example (See code below)

"use client"

import * as React from "react"
import { useActionState } from "react"

import { Button } from "@/registry/ui/button"
import { Label } from "@/registry/ui/label"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
} from "@/registry/ui/combobox"
import { toast } from "sonner"

// Mock server action
async function submitLanguage(prevState: any, formData: FormData) {
  // Simulate network delay
  await new Promise((resolve) => setTimeout(resolve, 1000))

  const language = formData.get("language")

  if (!language || typeof language !== "string") {
    return { error: "Please select a language." }
  }

  return {
    message: `You submitted: ${language}`,
  }
}

export default function ComboboxForm() {
  const [state, formAction, isPending] = useActionState(submitLanguage, null)
  const [value, setValue] = React.useState("")

  React.useEffect(() => {
    if (state?.message) {
      toast.success(state.message)
    }
  }, [state])

  return (
    <form action={formAction} className="w-2/3 space-y-6">
      <div className="flex flex-col space-y-2">
        <Label htmlFor="language">Language</Label>
        <Combobox value={value} onValueChange={setValue}>
          <ComboboxInput id="language" placeholder="Select language" />
          <ComboboxContent>
            <ComboboxEmpty>No language found.</ComboboxEmpty>
            <ComboboxGroup heading="Languages">
              <ComboboxItem value="english">English</ComboboxItem>
              <ComboboxItem value="french">French</ComboboxItem>
              <ComboboxItem value="german">German</ComboboxItem>
              <ComboboxItem value="spanish">Spanish</ComboboxItem>
              <ComboboxItem value="portuguese">Portuguese</ComboboxItem>
              <ComboboxItem value="russian">Russian</ComboboxItem>
              <ComboboxItem value="japanese">Japanese</ComboboxItem>
              <ComboboxItem value="korean">Korean</ComboboxItem>
              <ComboboxItem value="chinese">Chinese</ComboboxItem>
            </ComboboxGroup>
          </ComboboxContent>
        </Combobox>
        {/* Hidden input to submit the value with FormData */}
        <input type="hidden" name="language" value={value} />
        
        {state?.error && (
          <p className="text-sm text-red-500">{state.error}</p>
        )}
        <p className="text-sm text-muted-foreground">
          This is the language that will be used in the dashboard.
        </p>
      </div>
      <Button type="submit" disabled={isPending}>
        {isPending ? "Submitting..." : "Submit"}
      </Button>
    </form>
  )
}

Popover

The Combobox component uses the native popover API for the listbox, ensuring it renders on top of other content without z-index issues.

Select Status

The listbox will float above this card.

Current status will be saved automatically.
"use client"

import * as React from "react"

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
} from "@/registry/ui/combobox"

export default function ComboboxPopover() {
  return (
    <div className="flex h-[350px] w-full items-center justify-center bg-muted/50 p-10">
      <div className="relative flex w-full max-w-sm flex-col gap-4 rounded-lg border bg-background p-6 shadow-sm">
        <div className="flex flex-col space-y-1.5">
          <h3 className="font-semibold leading-none tracking-tight">
            Select Status
          </h3>
          <p className="text-sm text-muted-foreground">
            The listbox will float above this card.
          </p>
        </div>
        <Combobox>
          <ComboboxInput placeholder="Change status..." />
          <ComboboxContent>
            <ComboboxEmpty>No status found.</ComboboxEmpty>
            <ComboboxGroup>
              <ComboboxItem value="backlog">Backlog</ComboboxItem>
              <ComboboxItem value="todo">Todo</ComboboxItem>
              <ComboboxItem value="in-progress">In Progress</ComboboxItem>
              <ComboboxItem value="done">Done</ComboboxItem>
              <ComboboxItem value="canceled">Canceled</ComboboxItem>
            </ComboboxGroup>
          </ComboboxContent>
        </Combobox>
        <div className="text-xs text-muted-foreground">
          Current status will be saved automatically.
        </div>
      </div>
    </div>
  )
}