Combobox

Capped Multi

Multi-select with a cap: the counter shows the remaining allowance at all times, and at the limit unselected options go disabled instead of raising an error.

  • combobox
  • multiselect
  • limit
  • counter

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

2 / 5
  • TypeScript
  • React

Можно добавить ещё 3 навыка

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-016" (Capped Multi) 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-016.json

Registry item: https://vibeui.ru/r/combobox-016.json
Installs to: components/vibeui/combobox-016.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 a cap: the counter shows the remaining allowance at all times, and at the limit unselected options go disabled instead of raising an error.

A multi-select with a hard ceiling: five skills, three categories, ten recipients. The “2 / 5” counter sits next to the field caption and is visible before the first click; once the limit is reached the remaining options go dim through native disabled and the note explains what to do. Zero dependencies, one file, its own palette.

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

<Combobox016 maxItems={5} label="Skills on the CV" />

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-016-* palette — do not swap it for your theme tokens (bg-secondary, text-destructive and the like)
- the counter visible before the first click: a limit must not be announced after the fact
- native disabled on unselected options once the cap is hit — it removes them from the tab order and is announced by screen readers
- the chips for selected values with their remove buttons: at the cap, dropping one has to be easier than finding it in the list
- aria-multiselectable on the list and aria-selected on every option
- the note tied through aria-describedby with aria-live="polite": it covers both the remaining allowance and the reached cap
- the data-full attribute on the root: the cap state is styled by one rule instead of branching in markup

## 6. You may change
- label — the field caption
- placeholder — the hint text in the search field
- options — the list of available values
- defaultValue — the values selected at first render
- maxItems — the cap itself, as a number of values
- onChange — the handler for the value array; accent — the colour of chips and ticks

## 7. Rules
- Do not replace disabled with silently ignoring the click: people will assume the UI is broken.
- A cap of one is not a multi-select but an ordinary list — use a different variant for it.
- Values keep insertion order rather than alphabetical order: sort on the way out if order carries meaning.
- Chips grow taller with long names — check on a narrow width that the panel does not jump.

## 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-016.json
https://vibeui.ru/r/combobox-016.json

Компонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-combobox-016-*. Клиентский: массив выбранных значений и запрос живут в useState. Признак «предел достигнут» поднимается на корень атрибутом data-full, поэтому счётчик и подпись перекрашиваются одним правилом CSS без дублирования логики в JSX. На пределе невыбранные варианты получают нативный disabled — они выпадают из порядка табуляции, и запрет объявляется скринридером, а не только показывается цветом. Список помечен aria-multiselectable, выбранные значения дополнительно вынесены фишками сверху с кнопкой снятия у каждой. Подпись снизу связана с полем через aria-describedby и объявляется через aria-live.

Component source

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

"use client"

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

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

// Идея компонента: ограничение «не больше пяти» нельзя сообщать после
// отправки формы. Счётчик показывает остаток всё время, а на пределе
// невыбранные варианты становятся disabled — правило видно как состояние
// списка, а не как красный текст постфактум.
const STYLES = `
:where([data-vibeui-block="combobox-016"]){
--vibeui-combobox-016-bg:oklch(1 0 0);
--vibeui-combobox-016-fg:oklch(0.22 0.014 145);
--vibeui-combobox-016-muted:oklch(0.55 0.014 145);
--vibeui-combobox-016-border:oklch(0.9 0.008 145);
--vibeui-combobox-016-field:oklch(0.985 0.004 145);
--vibeui-combobox-016-soft:oklch(0.96 0.008 145);
--vibeui-combobox-016-accent:oklch(0.47 0.11 145);
--vibeui-combobox-016-accentsoft:oklch(0.93 0.05 145);
--vibeui-combobox-016-warn:oklch(0.55 0.13 55);
--vibeui-combobox-016-radius:0.625rem;
--vibeui-combobox-016-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="combobox-016"]{
display:flex;flex-direction:column;gap:0.4rem;
width:100%;max-width:22rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-combobox-016-bg);
border:1px solid var(--vibeui-combobox-016-border);
border-radius:calc(var(--vibeui-combobox-016-radius) + 0.25rem);
color:var(--vibeui-combobox-016-fg);
font-family:var(--vibeui-combobox-016-font);
}
[data-vibeui-block="combobox-016"] [data-part="head"]{
display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;
}
[data-vibeui-block="combobox-016"] label{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="combobox-016"] [data-part="counter"]{
font-size:0.75rem;font-weight:700;font-variant-numeric:tabular-nums;
color:var(--vibeui-combobox-016-muted);
}
[data-vibeui-block="combobox-016"][data-full="true"] [data-part="counter"]{color:var(--vibeui-combobox-016-warn)}
[data-vibeui-block="combobox-016"] [data-part="chips"]{
display:flex;flex-wrap:wrap;gap:0.25rem;margin:0;padding:0;list-style:none;
}
[data-vibeui-block="combobox-016"] [data-part="chip"]{
display:inline-flex;align-items:center;gap:0.25rem;
padding:0.15rem 0.2rem 0.15rem 0.5rem;border-radius:999px;
background:var(--vibeui-combobox-016-accentsoft);
font-size:0.75rem;font-weight:600;
}
[data-vibeui-block="combobox-016"] [data-part="drop"]{
appearance:none;cursor:pointer;font:inherit;border:0;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-016"] [data-part="drop"]:hover{background:var(--vibeui-combobox-016-bg)}
[data-vibeui-block="combobox-016"] [data-part="drop"]:focus-visible{
outline:2px solid var(--vibeui-combobox-016-accent);outline-offset:1px;
}
[data-vibeui-block="combobox-016"] input{
box-sizing:border-box;width:100%;height:2.4rem;padding:0 0.6rem;
border:1px solid var(--vibeui-combobox-016-border);
border-radius:var(--vibeui-combobox-016-radius);
background:var(--vibeui-combobox-016-field);
color:inherit;font:inherit;font-size:0.875rem;
}
[data-vibeui-block="combobox-016"] input::placeholder{color:var(--vibeui-combobox-016-muted)}
[data-vibeui-block="combobox-016"] input:focus-visible{
outline:2px solid var(--vibeui-combobox-016-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="combobox-016"] [data-part="list"]{
margin:0;padding:0.2rem;list-style:none;display:flex;flex-direction:column;gap:0.1rem;
max-height:12rem;overflow:auto;
border:1px solid var(--vibeui-combobox-016-border);
border-radius:var(--vibeui-combobox-016-radius);
}
[data-vibeui-block="combobox-016"] [data-part="option"]{
appearance:none;cursor:pointer;font:inherit;width:100%;
display:flex;align-items:center;gap:0.5rem;
box-sizing:border-box;padding:0.4rem 0.5rem;
border:0;border-radius:0.45rem;background:transparent;color:inherit;text-align:left;
font-size:0.8125rem;
transition:background-color .16s ease;
}
[data-vibeui-block="combobox-016"] [data-part="option"]:hover:not(:disabled){background:var(--vibeui-combobox-016-soft)}
[data-vibeui-block="combobox-016"] [data-part="option"]:focus-visible{
outline:2px solid var(--vibeui-combobox-016-accent);outline-offset:-2px;
}
[data-vibeui-block="combobox-016"] [data-part="option"]:disabled{cursor:not-allowed;color:var(--vibeui-combobox-016-muted)}
[data-vibeui-block="combobox-016"] [data-part="box"]{
display:flex;align-items:center;justify-content:center;flex:none;
width:1.05rem;height:1.05rem;border-radius:0.3rem;
border:1px solid var(--vibeui-combobox-016-border);
background:var(--vibeui-combobox-016-bg);
font-size:0.7rem;line-height:1;color:transparent;
}
[data-vibeui-block="combobox-016"] [data-part="option"][aria-selected="true"] [data-part="box"]{
background:var(--vibeui-combobox-016-accent);border-color:transparent;
color:var(--vibeui-combobox-016-bg);
}
[data-vibeui-block="combobox-016"] [data-part="note"]{
margin:0;font-size:0.78rem;color:var(--vibeui-combobox-016-muted);
}
[data-vibeui-block="combobox-016"][data-full="true"] [data-part="note"]{color:var(--vibeui-combobox-016-warn)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="combobox-016"] *{animation:none!important;transition:none!important}}
`

const SKILLS = [
  "TypeScript",
  "React",
  "Node.js",
  "PostgreSQL",
  "Docker",
  "Kubernetes",
  "GraphQL",
  "Rust",
  "Go",
  "Figma",
]

function pluralize(count: number, forms: [string, string, string]) {
  const tens = count % 100
  const ones = count % 10

  if (tens > 10 && tens < 20) return forms[2]
  if (ones === 1) return forms[0]
  if (ones > 1 && ones < 5) return forms[1]

  return forms[2]
}

/**
 * Множественный выбор с потолком и счётчиком остатка.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Combobox016({
  label = "Навыки в резюме",
  placeholder = "Найти навык",
  options = SKILLS,
  defaultValue = ["TypeScript", "React"],
  maxItems = 5,
  onChange,
  accent,
  className,
  style,
  ...props
}: Combobox016Props) {
  const id = useId()
  const [query, setQuery] = useState("")
  const [values, setValues] = useState(defaultValue)

  const full = values.length >= maxItems

  const matches = useMemo(() => {
    const needle = query.trim().toLowerCase()

    return options.filter((option) => option.toLowerCase().includes(needle))
  }, [query, options])

  const toggle = (option: string) => {
    const next = values.includes(option)
      ? values.filter((entry) => entry !== option)
      : [...values, option]

    if (next.length > maxItems) return

    setValues(next)
    onChange?.(next)
  }

  const left = maxItems - values.length

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

  return (
    <>
      <style href="vibeui-combobox-016" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="combobox-016"
        data-full={full}
        className={className}
        style={palette}
      >
        <div data-part="head">
          <label htmlFor={`${id}-input`}>{label}</label>
          <span data-part="counter">
            {values.length} / {maxItems}
          </span>
        </div>
        {values.length > 0 ? (
          <ul data-part="chips" aria-label="Выбранные навыки">
            {values.map((entry) => (
              <li key={entry} data-part="chip">
                {entry}
                <button
                  type="button"
                  data-part="drop"
                  aria-label={`Убрать ${entry}`}
                  onClick={() => toggle(entry)}
                >
                  ×
                </button>
              </li>
            ))}
          </ul>
        ) : null}
        <input
          id={`${id}-input`}
          type="text"
          role="combobox"
          autoComplete="off"
          placeholder={placeholder}
          aria-expanded="true"
          aria-controls={`${id}-list`}
          aria-autocomplete="list"
          aria-describedby={`${id}-note`}
          value={query}
          onChange={(event) => setQuery(event.target.value)}
        />
        <ul
          id={`${id}-list`}
          role="listbox"
          aria-multiselectable="true"
          aria-label={label}
          data-part="list"
        >
          {matches.map((option) => {
            const chosen = values.includes(option)

            return (
              <li key={option} role="none">
                <button
                  type="button"
                  role="option"
                  data-part="option"
                  aria-selected={chosen}
                  disabled={full && !chosen}
                  onClick={() => toggle(option)}
                >
                  <span data-part="box" aria-hidden="true">
                    ✓
                  </span>
                  {option}
                </button>
              </li>
            )
          })}
        </ul>
        <p id={`${id}-note`} data-part="note" aria-live="polite">
          {full
            ? `Предел ${maxItems}: снимите один навык, чтобы добавить другой`
            : `Можно добавить ещё ${left} ${pluralize(left, ["навык", "навыка", "навыков"])}`}
        </p>
      </div>
    </>
  )
}