Combobox

Multi Chips

Multi-select with chips: a picked row stays in the list with a check mark, a counter holds the limit, and Backspace in an empty field drops the last chip.

  • combobox
  • multiselect
  • chips
  • keyboard

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-003?lang=en

2 из 5
ДизайнФронтенд
  • Аналитика
  • Бэкенд
  • Дизайн
  • Документация
  • Инфраструктура
  • Мобильные
  • Поддержка
  • Фронтенд
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-003" (Multi Chips) 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-003.json

Registry item: https://vibeui.ru/r/combobox-003.json
Installs to: components/vibeui/combobox-003.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
Multi-select with chips: a picked row stays in the list with a check mark, a counter holds the limit, and Backspace in an empty field drops the last chip.

Multi-select from a closed list: chips in the field, check marks in the list, a limit with a counter and Backspace to drop the last chip. One file, zero dependencies.

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

<Combobox003
  label="Teams"
  options={["Design", "Frontend", "Backend"]}
  defaultSelected={["Design"]}
  maxSelected={3}
  onChange={(values) => console.log(values)}
/>

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-003-* palette — do not swap it for your theme tokens
- aria-multiselectable on the list and aria-selected on the rows: without them multi-select is never announced
- keeping selected rows in the list — vanishing rows shift the cursor and hide the state
- the focus ring on the field through :has(): chips and input look like one control, so the focus must be shared
- the separate button with aria-label on every chip: a bare cross does not say what it removes
- the block's own light surface: without it the dark text disappears on the dark catalog card

## 6. You may change
- the list content through options and the starting set through defaultSelected
- the selection limit through maxSelected and the field caption through label
- the placeholder text through placeholder and the empty-result text through emptyLabel
- the accent color through accent

## 7. Rules
- At the limit the rows are not disabled: a press simply does nothing — add aria-disabled if you need a hard block.
- Backspace drops a chip only while the query is empty, otherwise it would erase text and selection at once.
- Many chips wrap the field onto a second line: check the layout on a phone.
- 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-003.json
https://vibeui.ru/r/combobox-003.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра --vibeui-combobox-003-*. Клиентский: useState держит массив выбранного, запрос и курсор. Список объявлен aria-multiselectable="true", каждая строка несёт aria-selected по факту выбора и рисует свой квадратик-отметку. В отличие от тег-пикера выбранное не удаляется из списка: повторное нажатие снимает выбор там же, где ставилось. Фишки лежат в общем поле с input, обводка фокуса ставится на поле через :has(input:focus-visible). Лимит maxSelected блокирует добавление и прячет заглушку, счётчик в шапке объявлен aria-live="polite".

Component source

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

"use client"

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

export type Combobox003Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange"
> & {
  label?: string
  placeholder?: string
  options?: string[]
  defaultSelected?: string[]
  emptyLabel?: string
  maxSelected?: number
  onChange?: (values: string[]) => void
  accent?: string
}

// Идея компонента: множественный выбор из закрытого списка. В отличие от
// тег-пикера выбранное НЕ исчезает из списка — оно остаётся с галочкой и
// aria-selected="true", потому что человек должен видеть, что уже отмечено,
// и снимать выбор там же, где ставил.
const STYLES = `
:where([data-vibeui-block="combobox-003"]){
--vibeui-combobox-003-bg:oklch(1 0 0);
--vibeui-combobox-003-fg:oklch(0.22 0.02 300);
--vibeui-combobox-003-muted:oklch(0.53 0.02 300);
--vibeui-combobox-003-border:oklch(0.9 0.01 300);
--vibeui-combobox-003-field:oklch(0.985 0.004 300);
--vibeui-combobox-003-active:oklch(0.95 0.03 300);
--vibeui-combobox-003-accent:oklch(0.53 0.19 300);
--vibeui-combobox-003-chip:oklch(0.95 0.04 300);
--vibeui-combobox-003-radius:0.625rem;
--vibeui-combobox-003-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="combobox-003"]{
display:flex;flex-direction:column;gap:0.375rem;
width:100%;max-width:22rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-combobox-003-bg);
border:1px solid var(--vibeui-combobox-003-border);
border-radius:calc(var(--vibeui-combobox-003-radius) + 0.25rem);
color:var(--vibeui-combobox-003-fg);
font-family:var(--vibeui-combobox-003-font);
}
[data-vibeui-block="combobox-003"] [data-part="head"]{
display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;
}
[data-vibeui-block="combobox-003"] label{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="combobox-003"] [data-part="counter"]{font-size:0.75rem;color:var(--vibeui-combobox-003-muted)}
[data-vibeui-block="combobox-003"] [data-part="field"]{
display:flex;flex-wrap:wrap;align-items:center;gap:0.3rem;
box-sizing:border-box;width:100%;min-height:2.5rem;padding:0.3rem 0.45rem;
border:1px solid var(--vibeui-combobox-003-border);
border-radius:var(--vibeui-combobox-003-radius);
background:var(--vibeui-combobox-003-field);
}
[data-vibeui-block="combobox-003"] [data-part="field"]:has(input:focus-visible){
outline:2px solid var(--vibeui-combobox-003-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="combobox-003"] [data-part="chip"]{
display:inline-flex;align-items:center;gap:0.25rem;
height:1.6rem;padding:0 0.2rem 0 0.5rem;border-radius:999px;
background:var(--vibeui-combobox-003-chip);
font-size:0.78rem;font-weight:600;
}
[data-vibeui-block="combobox-003"] [data-part="chipclose"]{
appearance:none;border:0;cursor:pointer;background:transparent;color:inherit;
display:inline-flex;align-items:center;justify-content:center;
width:1.1rem;height:1.1rem;border-radius:999px;font-size:0.85rem;line-height:1;
}
[data-vibeui-block="combobox-003"] [data-part="chipclose"]:hover{background:var(--vibeui-combobox-003-bg)}
[data-vibeui-block="combobox-003"] [data-part="chipclose"]:focus-visible{outline:2px solid var(--vibeui-combobox-003-accent);outline-offset:1px}
[data-vibeui-block="combobox-003"] input{
flex:1 1 6rem;min-width:5rem;height:1.8rem;padding:0 0.25rem;
border:0;background:transparent;color:inherit;font:inherit;font-size:0.875rem;outline:none;
}
[data-vibeui-block="combobox-003"] input::placeholder{color:var(--vibeui-combobox-003-muted)}
[data-vibeui-block="combobox-003"] [data-part="list"]{
margin:0;padding:0.25rem;list-style:none;max-height:10rem;overflow-y:auto;
border:1px solid var(--vibeui-combobox-003-border);
border-radius:var(--vibeui-combobox-003-radius);
}
[data-vibeui-block="combobox-003"] [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-003"] [data-part="option"][data-active="true"]{background:var(--vibeui-combobox-003-active)}
[data-vibeui-block="combobox-003"] [data-part="box"]{
display:inline-flex;align-items:center;justify-content:center;flex:none;
width:1rem;height:1rem;border-radius:0.3rem;font-size:0.7rem;line-height:1;
border:1.5px solid var(--vibeui-combobox-003-border);
}
[data-vibeui-block="combobox-003"] [data-part="option"][aria-selected="true"] [data-part="box"]{
background:var(--vibeui-combobox-003-accent);border-color:var(--vibeui-combobox-003-accent);color:oklch(1 0 0);
}
[data-vibeui-block="combobox-003"] [data-part="empty"]{padding:0.5rem;font-size:0.8125rem;color:var(--vibeui-combobox-003-muted)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="combobox-003"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_OPTIONS = [
  "Аналитика",
  "Бэкенд",
  "Дизайн",
  "Документация",
  "Инфраструктура",
  "Мобильные",
  "Поддержка",
  "Фронтенд",
]

/**
 * Combobox с множественным выбором фишками: выбранное остаётся в списке
 * с отметкой, Backspace в пустом поле снимает последнюю фишку.
 */
export function Combobox003({
  label = "Команды",
  placeholder = "Добавить команду",
  options = DEFAULT_OPTIONS,
  defaultSelected = ["Дизайн", "Фронтенд"],
  emptyLabel = "Ничего не нашлось",
  maxSelected = 5,
  onChange,
  accent,
  className,
  style,
  ...props
}: Combobox003Props) {
  const id = useId()
  const [selected, setSelected] = useState(defaultSelected)
  const [query, setQuery] = useState("")
  const [active, setActive] = useState(0)
  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])

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

  const apply = (next: string[]) => {
    setSelected(next)
    onChange?.(next)
  }

  const toggle = (option: string) => {
    if (selected.includes(option)) {
      apply(selected.filter((entry) => entry !== option))
      return
    }
    if (selected.length >= maxSelected) return
    apply([...selected, option])
  }

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

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "ArrowDown") {
      event.preventDefault()
      move(1)
    } else if (event.key === "ArrowUp") {
      event.preventDefault()
      move(-1)
    } else if (event.key === "Enter") {
      event.preventDefault()
      if (matches[active]) toggle(matches[active])
    } else if (event.key === "Escape") {
      event.preventDefault()
      setQuery("")
    } else if (event.key === "Backspace" && query === "" && selected.length) {
      apply(selected.slice(0, -1))
    }
  }

  return (
    <>
      <style href="vibeui-combobox-003" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="combobox-003"
        className={className}
        style={palette}
      >
        <div data-part="head">
          <label htmlFor={`${id}-input`}>{label}</label>
          <span data-part="counter" aria-live="polite">
            {selected.length} из {maxSelected}
          </span>
        </div>
        <div data-part="field">
          {selected.map((entry) => (
            <span key={entry} data-part="chip">
              {entry}
              <button
                type="button"
                data-part="chipclose"
                aria-label={`Убрать ${entry}`}
                onClick={() => toggle(entry)}
              >
                ×
              </button>
            </span>
          ))}
          <input
            id={`${id}-input`}
            type="text"
            role="combobox"
            autoComplete="off"
            placeholder={selected.length >= maxSelected ? "" : placeholder}
            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}
          />
        </div>
        <ul
          ref={listRef}
          id={`${id}-list`}
          role="listbox"
          aria-multiselectable="true"
          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={selected.includes(option)}
              onMouseEnter={() => setActive(index)}
              onMouseDown={(event) => {
                event.preventDefault()
                toggle(option)
              }}
            >
              <span data-part="box" aria-hidden="true">
                {selected.includes(option) ? "✓" : ""}
              </span>
              {option}
            </li>
          ))}
          {matches.length === 0 ? (
            <li data-part="empty" role="presentation">
              {emptyLabel}
            </li>
          ) : null}
        </ul>
      </div>
    </>
  )
}