Combobox

Clearable Value

A clearable combobox: the selected value sits as a chip in the field, the cross removes it and keeps focus in place, and the dropped value can be restored in one press.

  • combobox
  • clear
  • filter
  • undo

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

Личный кабинет
  • Все проекты
  • Витрина
  • Личный кабинет
  • Мобильное приложение
  • Панель оператора
  • Складской учёт

Выбрано: Личный кабинет

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-011" (Clearable Value) 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-011.json

Registry item: https://vibeui.ru/r/combobox-011.json
Installs to: components/vibeui/combobox-011.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 clearable combobox: the selected value sits as a chip in the field, the cross removes it and keeps focus in place, and the dropped value can be restored in one press.

A combobox with a clear cross and an undo for the dropped value: the value is shown as a chip and focus stays in the field after clearing. One file, zero dependencies, client component.

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

<Combobox011
  label="Project"
  options={["Storefront", "Account area"]}
  defaultValue="Storefront"
  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-011-* palette — do not swap it for your theme tokens
- the cross aria-label carrying the value itself: "Clear" alone says nothing in a row of filters
- keeping focus in the field after clearing — otherwise the next filter has to be caught with the mouse
- the two-step Escape: first the query, then the value, so a stray Escape does not wipe the choice
- the focus ring on the container through :has(): the chip and the field look like one control
- 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 initial value through defaultValue
- the cross caption through clearLabel and the undo caption through undoLabel
- the field caption through label and the placeholder through placeholder
- the accent color through accent and the chip width through max-width

## 7. Rules
- Clearing hands an empty string to onSelect: check for emptiness if your handler expects a value.
- The undo keeps exactly one dropped value — there is no history here.
- Backspace on an empty query removes the whole value, not one letter: that is deliberate filter behavior.
- 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-011.json
https://vibeui.ru/r/combobox-011.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра --vibeui-combobox-011-*. Клиентский: useState держит значение, снятое значение, запрос и курсор. Фишка со значением и поле фильтра лежат в одном контейнере, обводка фокуса ставится на контейнер через :has(input:focus-visible) — визуально это один элемент, значит и фокус общий. Крестик несёт aria-label с самим значением: «Очистить» без названия в списке фильтров бесполезно. После очистки фокус остаётся в поле, чтобы менять фильтр подряд без мыши. Escape сначала стирает запрос, затем снимает значение — две ступени вместо одной. Строка снизу с 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 Combobox011Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onSelect"
> & {
  label?: string
  placeholder?: string
  options?: string[]
  defaultValue?: string
  clearLabel?: string
  undoLabel?: string
  onSelect?: (value: string) => void
  accent?: string
}

// Идея компонента: у фильтра обязан быть выход. Значение показано фишкой
// внутри поля, крестик снимает его и оставляет фокус в поле — так подряд
// меняют фильтр, не бегая мышью. Снятое значение не пропадает совсем:
// строка снизу предлагает вернуть его одним нажатием.
const STYLES = `
:where([data-vibeui-block="combobox-011"]){
--vibeui-combobox-011-bg:oklch(1 0 0);
--vibeui-combobox-011-fg:oklch(0.22 0.016 215);
--vibeui-combobox-011-muted:oklch(0.54 0.016 215);
--vibeui-combobox-011-border:oklch(0.9 0.008 215);
--vibeui-combobox-011-field:oklch(0.985 0.004 215);
--vibeui-combobox-011-active:oklch(0.95 0.03 215);
--vibeui-combobox-011-accent:oklch(0.52 0.12 215);
--vibeui-combobox-011-radius:0.625rem;
--vibeui-combobox-011-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="combobox-011"]{
display:flex;flex-direction:column;gap:0.375rem;
width:100%;max-width:21rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-combobox-011-bg);
border:1px solid var(--vibeui-combobox-011-border);
border-radius:calc(var(--vibeui-combobox-011-radius) + 0.25rem);
color:var(--vibeui-combobox-011-fg);
font-family:var(--vibeui-combobox-011-font);
}
[data-vibeui-block="combobox-011"] label{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="combobox-011"] [data-part="field"]{
display:flex;align-items:center;gap:0.35rem;
box-sizing:border-box;width:100%;min-height:2.5rem;padding:0.25rem 0.45rem;
border:1px solid var(--vibeui-combobox-011-border);
border-radius:var(--vibeui-combobox-011-radius);
background:var(--vibeui-combobox-011-field);
}
[data-vibeui-block="combobox-011"] [data-part="field"]:has(input:focus-visible){
outline:2px solid var(--vibeui-combobox-011-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="combobox-011"] [data-part="value"]{
display:inline-flex;align-items:center;gap:0.3rem;flex:none;max-width:60%;
height:1.7rem;padding:0 0.25rem 0 0.55rem;border-radius:999px;
background:var(--vibeui-combobox-011-active);
font-size:0.8rem;font-weight:600;
}
[data-vibeui-block="combobox-011"] [data-part="valuetext"]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
[data-vibeui-block="combobox-011"] [data-part="clear"]{
appearance:none;border:0;cursor:pointer;background:transparent;color:inherit;
display:inline-flex;align-items:center;justify-content:center;flex:none;
width:1.2rem;height:1.2rem;border-radius:999px;font-size:0.9rem;line-height:1;
transition:background-color .16s ease;
}
[data-vibeui-block="combobox-011"] [data-part="clear"]:hover{background:var(--vibeui-combobox-011-bg)}
[data-vibeui-block="combobox-011"] [data-part="clear"]:focus-visible{outline:2px solid var(--vibeui-combobox-011-accent);outline-offset:1px}
[data-vibeui-block="combobox-011"] input{
flex:1 1 5rem;min-width:4rem;height:1.9rem;padding:0 0.25rem;
border:0;background:transparent;color:inherit;font:inherit;font-size:0.875rem;outline:none;
}
[data-vibeui-block="combobox-011"] input::placeholder{color:var(--vibeui-combobox-011-muted)}
[data-vibeui-block="combobox-011"] [data-part="list"]{
margin:0;padding:0.25rem;list-style:none;max-height:9.5rem;overflow-y:auto;
border:1px solid var(--vibeui-combobox-011-border);
border-radius:var(--vibeui-combobox-011-radius);
}
[data-vibeui-block="combobox-011"] [data-part="option"]{
display:flex;align-items:center;min-height:2rem;padding:0 0.5rem;
border-radius:0.375rem;font-size:0.875rem;cursor:pointer;
}
[data-vibeui-block="combobox-011"] [data-part="option"][data-active="true"]{background:var(--vibeui-combobox-011-active)}
[data-vibeui-block="combobox-011"] [data-part="option"][aria-selected="true"]{font-weight:650;color:var(--vibeui-combobox-011-accent)}
[data-vibeui-block="combobox-011"] [data-part="foot"]{
display:flex;align-items:center;gap:0.4rem;min-height:1.2rem;
font-size:0.75rem;color:var(--vibeui-combobox-011-muted);
}
[data-vibeui-block="combobox-011"] [data-part="undo"]{
appearance:none;border:0;background:transparent;cursor:pointer;padding:0;
font:inherit;font-size:0.75rem;font-weight:700;
color:var(--vibeui-combobox-011-accent);text-decoration:underline;
}
[data-vibeui-block="combobox-011"] [data-part="undo"]:focus-visible{outline:2px solid var(--vibeui-combobox-011-accent);outline-offset:2px;border-radius:0.2rem}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="combobox-011"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_OPTIONS = [
  "Все проекты",
  "Витрина",
  "Личный кабинет",
  "Мобильное приложение",
  "Панель оператора",
  "Складской учёт",
]

/**
 * Combobox с очисткой значения: фишка с крестиком снимает выбор, фокус
 * остаётся в поле, а снятое значение можно вернуть.
 */
export function Combobox011({
  label = "Проект",
  placeholder = "Найти проект",
  options = DEFAULT_OPTIONS,
  defaultValue = "Личный кабинет",
  clearLabel = "Очистить выбор",
  undoLabel = "вернуть",
  onSelect,
  accent,
  className,
  style,
  ...props
}: Combobox011Props) {
  const id = useId()
  const [value, setValue] = useState(defaultValue)
  const [dropped, setDropped] = useState("")
  const [query, setQuery] = useState("")
  const [active, setActive] = useState(0)
  const inputRef = useRef<HTMLInputElement>(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])

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

  const commit = (option: string) => {
    setValue(option)
    setDropped("")
    setQuery("")
    setActive(0)
    onSelect?.(option)
  }

  const clear = () => {
    if (!value) return
    setDropped(value)
    setValue("")
    setQuery("")
    onSelect?.("")
    inputRef.current?.focus()
  }

  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]) commit(matches[active])
    } else if (event.key === "Escape") {
      event.preventDefault()
      if (query) setQuery("")
      else clear()
    } else if (event.key === "Backspace" && query === "" && value) {
      event.preventDefault()
      clear()
    }
  }

  return (
    <>
      <style href="vibeui-combobox-011" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="combobox-011"
        className={className}
        style={palette}
      >
        <label htmlFor={`${id}-input`}>{label}</label>
        <div data-part="field">
          {value ? (
            <span data-part="value">
              <span data-part="valuetext">{value}</span>
              <button
                type="button"
                data-part="clear"
                aria-label={`${clearLabel}: ${value}`}
                onClick={clear}
              >
                ×
              </button>
            </span>
          ) : null}
          <input
            ref={inputRef}
            id={`${id}-input`}
            type="text"
            role="combobox"
            autoComplete="off"
            placeholder={value ? "" : 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-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)
              }}
            >
              {option}
            </li>
          ))}
        </ul>
        <p data-part="foot" aria-live="polite">
          {dropped ? (
            <>
              <span>Снято: {dropped}</span>
              <button
                type="button"
                data-part="undo"
                onClick={() => commit(dropped)}
              >
                {undoLabel}
              </button>
            </>
          ) : (
            <span>{value ? `Выбрано: ${value}` : "Фильтр не задан"}</span>
          )}
        </p>
      </div>
    </>
  )
}