Combobox

Filter Combobox

A basic combobox: the trigger shows the selection, the panel holds a filter field and the list, and the value can only come from the list.

  • combobox
  • filter
  • keyboard
  • a11y

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/combobox-001?lang=en

Task status
Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "combobox-001" (Filter Combobox) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/combobox-001.json

Registry item: https://vibeui.ru/r/combobox-001.json
Installs to: components/vibeui/combobox-001.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A basic combobox: the trigger shows the selection, the panel holds a filter field and the list, and the value can only come from the list.

A combobox with a filter and full keyboard support: the value is picked from a known list and the filter text never becomes the value. One file, zero dependencies, client component.

## 3. How to use it
import { Combobox001 } from "@/components/vibeui/combobox-001"

<Combobox001
  label="Task status"
  placeholder="Pick a status"
  options={["Active", "In progress", "Done"]}
  onSelect={(value) => console.log(value)}
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-combobox-001-* palette — do not swap it for your theme tokens
- the split between filter and value: the text in the field must not reach the form, otherwise it is free input, not a choice from a list
- the role="combobox" + aria-controls + aria-activedescendant chain: without it a screen reader never announces the row under the cursor
- returning focus to the trigger on Escape — otherwise focus jumps to the top of the page when the panel closes
- onMouseDown with preventDefault on the rows: onClick fires after blur and the mouse selection is lost
- the block's own light surface: without it the dark text disappears on the dark catalog card

## 6. You may change
- the field caption through label and the empty text through placeholder
- the filter hint through searchPlaceholder
- the list content through options and the initial choice through defaultValue
- the highlight color through accent and the width through max-width

## 7. Rules
- The panel sits in the flow and pushes siblings: for an overlay add position:absolute and handle the screen edge yourself.
- An unbounded list hits max-height and scrolling — for hundreds of rows take the windowed variant.
- The filter matches a case-insensitive substring, with no transliteration or keyboard-layout fixes: that is a deliberate minimum.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/combobox-001.json
https://vibeui.ru/r/combobox-001.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-combobox-001-*. Клиентский: useState держит значение, запрос, открытость и индекс курсора, useEffect переводит фокус в поле фильтра при открытии. Кнопка-триггер несёт aria-haspopup="listbox" и aria-expanded, поле внутри панели — role="combobox", aria-controls и aria-activedescendant, список — role="listbox" со строками role="option" и aria-selected. Клавиатура: стрелки ведут курсор по кругу, Home и End прыгают на края, Enter выбирает, Escape закрывает панель и возвращает фокус на кнопку. Панель лежит в потоке, а не поверх: всплывающий слой в узкой колонке нечем позиционировать без замера. Выбор мышью идёт по onMouseDown с preventDefault, чтобы поле не потеряло фокус раньше выбора.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useEffect, useId, useMemo, useRef, useState } from "react"
import type {
  ComponentPropsWithoutRef,
  CSSProperties,
  KeyboardEvent,
} from "react"

export type Combobox001Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onSelect"
> & {
  label?: string
  placeholder?: string
  searchPlaceholder?: string
  options?: string[]
  emptyLabel?: string
  defaultValue?: string
  defaultOpen?: boolean
  onSelect?: (value: string) => void
  accent?: string
}

// Идея компонента: это выбор из известного списка, а не свободный ввод.
// Значение живёт на кнопке-триггере, а поле внутри панели только фильтрует
// и никогда не становится значением: закрытие по Escape возвращает фокус на
// кнопку и стирает фильтр, поэтому «полунабранный» текст не утекает в форму.
const STYLES = `
:where([data-vibeui-block="combobox-001"]){
--vibeui-combobox-001-bg:oklch(1 0 0);
--vibeui-combobox-001-fg:oklch(0.22 0.014 265);
--vibeui-combobox-001-muted:oklch(0.53 0.014 265);
--vibeui-combobox-001-border:oklch(0.9 0.006 265);
--vibeui-combobox-001-field:oklch(0.985 0.002 265);
--vibeui-combobox-001-active:oklch(0.955 0.022 265);
--vibeui-combobox-001-accent:oklch(0.55 0.17 265);
--vibeui-combobox-001-radius:0.625rem;
--vibeui-combobox-001-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="combobox-001"]{
display:flex;flex-direction:column;gap:0.375rem;
width:100%;max-width:20rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-combobox-001-bg);
border:1px solid var(--vibeui-combobox-001-border);
border-radius:calc(var(--vibeui-combobox-001-radius) + 0.25rem);
color:var(--vibeui-combobox-001-fg);
font-family:var(--vibeui-combobox-001-font);
}
[data-vibeui-block="combobox-001"] [data-part="label"]{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="combobox-001"] [data-part="trigger"]{
box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:0.5rem;
width:100%;height:2.5rem;padding:0 0.75rem;cursor:pointer;text-align:left;
border:1px solid var(--vibeui-combobox-001-border);
border-radius:var(--vibeui-combobox-001-radius);
background:var(--vibeui-combobox-001-field);
color:inherit;font:inherit;font-size:0.875rem;
transition:border-color .16s ease;
}
[data-vibeui-block="combobox-001"] [data-part="trigger"]:hover{border-color:var(--vibeui-combobox-001-accent)}
[data-vibeui-block="combobox-001"] [data-part="trigger"]:focus-visible{outline:2px solid var(--vibeui-combobox-001-accent);outline-offset:1px}
[data-vibeui-block="combobox-001"] [data-part="trigger"][data-empty="true"]{color:var(--vibeui-combobox-001-muted)}
[data-vibeui-block="combobox-001"] [data-part="chevron"]{
width:0.45rem;height:0.45rem;flex:none;margin-bottom:0.15rem;
border-right:1.5px solid var(--vibeui-combobox-001-muted);
border-bottom:1.5px solid var(--vibeui-combobox-001-muted);
transform:rotate(45deg);transition:transform .16s ease;
}
[data-vibeui-block="combobox-001"] [data-part="trigger"][aria-expanded="true"] [data-part="chevron"]{transform:rotate(-135deg);margin-bottom:-0.15rem}
/* Панель в потоке, а не поверх: в карточке каталога и в узкой колонке
   всплывающий слой нечем позиционировать без замера. */
[data-vibeui-block="combobox-001"] [data-part="panel"]{
display:flex;flex-direction:column;gap:0.25rem;padding:0.375rem;
border:1px solid var(--vibeui-combobox-001-border);
border-radius:var(--vibeui-combobox-001-radius);
background:var(--vibeui-combobox-001-bg);
}
[data-vibeui-block="combobox-001"] input{
box-sizing:border-box;width:100%;height:2.125rem;padding:0 0.625rem;
border:1px solid var(--vibeui-combobox-001-border);
border-radius:0.5rem;background:var(--vibeui-combobox-001-field);
color:inherit;font:inherit;font-size:0.875rem;
}
[data-vibeui-block="combobox-001"] input:focus-visible{outline:2px solid var(--vibeui-combobox-001-accent);outline-offset:1px;border-color:transparent}
[data-vibeui-block="combobox-001"] [data-part="list"]{
margin:0;padding:0;list-style:none;max-height:9.5rem;overflow-y:auto;
}
[data-vibeui-block="combobox-001"] [data-part="option"]{
display:flex;align-items:center;gap:0.5rem;min-height:2rem;padding:0 0.5rem;
border-radius:0.375rem;font-size:0.875rem;cursor:pointer;
}
[data-vibeui-block="combobox-001"] [data-part="option"][data-active="true"]{background:var(--vibeui-combobox-001-active)}
[data-vibeui-block="combobox-001"] [data-part="check"]{
width:0.85rem;flex:none;color:var(--vibeui-combobox-001-accent);font-weight:700;
}
[data-vibeui-block="combobox-001"] [data-part="empty"]{padding:0.5rem;font-size:0.8125rem;color:var(--vibeui-combobox-001-muted)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="combobox-001"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_OPTIONS = [
  "Активен",
  "В работе",
  "На проверке",
  "Отложен",
  "Завершён",
  "Отменён",
  "Черновик",
  "Архив",
]

/**
 * Combobox с фильтром и полной клавиатурой: значение берётся только из
 * списка, фильтр значением не становится.
 */
export function Combobox001({
  label = "Статус задачи",
  placeholder = "Выберите статус",
  searchPlaceholder = "Поиск по списку",
  options = DEFAULT_OPTIONS,
  emptyLabel = "Ничего не нашлось",
  defaultValue = "",
  defaultOpen = false,
  onSelect,
  accent,
  className,
  style,
  ...props
}: Combobox001Props) {
  const id = useId()
  const [value, setValue] = useState(defaultValue)
  const [query, setQuery] = useState("")
  const [open, setOpen] = useState(defaultOpen)
  const [active, setActive] = useState(0)
  const inputRef = useRef<HTMLInputElement>(null)
  const triggerRef = useRef<HTMLButtonElement>(null)
  const listRef = useRef<HTMLUListElement>(null)

  const matches = useMemo(() => {
    const needle = query.trim().toLowerCase()
    if (!needle) return options
    return options.filter((option) => option.toLowerCase().includes(needle))
  }, [options, query])

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

  const palette = {
    ...(accent ? { "--vibeui-combobox-001-accent": accent } : null),
    ...style,
  } as CSSProperties

  const close = (returnFocus: boolean) => {
    setOpen(false)
    setQuery("")
    if (returnFocus) triggerRef.current?.focus()
  }

  const commit = (option: string) => {
    setValue(option)
    onSelect?.(option)
    close(true)
  }

  const move = (next: number) => {
    if (!matches.length) return
    const index = (next + matches.length) % matches.length
    setActive(index)
    listRef.current?.children[index]?.scrollIntoView({ block: "nearest" })
  }

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "ArrowDown") {
      event.preventDefault()
      move(active + 1)
    } else if (event.key === "ArrowUp") {
      event.preventDefault()
      move(active - 1)
    } else if (event.key === "Home") {
      event.preventDefault()
      move(0)
    } else if (event.key === "End") {
      event.preventDefault()
      move(matches.length - 1)
    } else if (event.key === "Enter") {
      event.preventDefault()
      if (matches[active]) commit(matches[active])
    } else if (event.key === "Escape") {
      event.preventDefault()
      close(true)
    }
  }

  return (
    <>
      <style href="vibeui-combobox-001" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="combobox-001"
        className={className}
        style={palette}
      >
        <span data-part="label" id={`${id}-label`}>
          {label}
        </span>
        <button
          ref={triggerRef}
          type="button"
          data-part="trigger"
          data-empty={value === ""}
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-labelledby={`${id}-label ${id}-trigger`}
          id={`${id}-trigger`}
          onClick={() => {
            setActive(Math.max(0, options.indexOf(value)))
            setOpen((previous) => !previous)
          }}
        >
          {value || placeholder}
          <span data-part="chevron" aria-hidden="true" />
        </button>
        {open ? (
          <div data-part="panel">
            <input
              ref={inputRef}
              type="text"
              role="combobox"
              autoComplete="off"
              placeholder={searchPlaceholder}
              aria-label={`${label}: фильтр`}
              aria-expanded="true"
              aria-controls={`${id}-list`}
              aria-autocomplete="list"
              aria-activedescendant={
                matches[active] ? `${id}-option-${active}` : undefined
              }
              value={query}
              onChange={(event) => {
                setQuery(event.target.value)
                setActive(0)
              }}
              onKeyDown={onKeyDown}
            />
            <ul
              ref={listRef}
              id={`${id}-list`}
              role="listbox"
              aria-label={label}
              data-part="list"
            >
              {matches.map((option, index) => (
                <li
                  key={option}
                  id={`${id}-option-${index}`}
                  role="option"
                  data-part="option"
                  data-active={index === active}
                  aria-selected={option === value}
                  onMouseEnter={() => setActive(index)}
                  onMouseDown={(event) => {
                    event.preventDefault()
                    commit(option)
                  }}
                >
                  <span data-part="check" aria-hidden="true">
                    {option === value ? "✓" : ""}
                  </span>
                  {option}
                </li>
              ))}
              {matches.length === 0 ? (
                <li data-part="empty" role="presentation">
                  {emptyLabel}
                </li>
              ) : null}
            </ul>
          </div>
        ) : null}
      </div>
    </>
  )
}