Autocomplete

Command Search

A command line for the whole app: search across sections with group headers and shortcut hints.

  • autocomplete
  • command
  • search
  • palette

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/autocomplete-004?lang=en

Esc
  • Действия

    Создать проектN
  • Пригласить в команду
  • Загрузить файлыU
  • Настройки

    Оплата и счета
  • Уведомления
  • Ключи доступа
  • Помощь

    Документация?
  • Написать в поддержку
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 "autocomplete-004" (Command Search) 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/autocomplete-004.json

Registry item: https://vibeui.ru/r/autocomplete-004.json
Installs to: components/vibeui/autocomplete-004.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 command line for the whole app: search across sections with group headers and shortcut hints.

A command search with groups: filtering by label, section headers, shortcut hints, and arrows that skip the headers. Zero dependencies, one file, its own palette.

## 3. How to use it
import { Autocomplete004 } from "@/components/vibeui/autocomplete-004"

<Autocomplete004
  commands={[{ label: "New project", group: "Actions", hint: "N" }]}
  onSelect={(label) => console.log(label)}
/>

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-autocomplete-004-* palette — do not swap it for your theme tokens
- the grouping by section: people remember "somewhere in settings", not the exact wording
- arrows walking commands only — stopping on a header breaks the count
- headers computed before render, with no variable mutated mid-render
- aria-activedescendant with focus in the field instead of moving focus to a row
- the shortcut hints on the right: they teach the faster path

## 6. You may change
- the commands array: label, group and shortcut
- the placeholder and emptyLabel copy
- the onSelect handler
- the accent through the accent prop

## 7. Rules
- This is the search bar itself, not a modal. For a Cmd+K launcher wrap it in a dialog from the dialog category.
- Group order follows the array: sorting commands inside a group helps, sorting across groups does not.
- The ⌘ glyph suits macOS; substitute Ctrl in the caller on Windows.
- 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/autocomplete-004.json
https://vibeui.ru/r/autocomplete-004.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-autocomplete-004-*. Клиентский: "use client". Заголовки групп считаются до рендера из соседних элементов списка, поэтому во время отрисовки ничего не мутируется. Стрелки ходят по индексам команд и заголовки не задевают. Лупа нарисована кругом и псевдоэлементом, иконочного пакета нет.

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,
  KeyboardEvent,
} from "react"

export type Autocomplete004Command = {
  label: string
  group: string
  hint?: string
}

export type Autocomplete004Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onSelect"
> & {
  placeholder?: string
  commands?: Autocomplete004Command[]
  emptyLabel?: string
  onSelect?: (label: string) => void
  accent?: string
}

// Идея компонента: строка команд. Она отличается от обычного combobox тем,
// что ищет по всему приложению и группирует результат по разделам — человек
// помнит «где-то в настройках», а не точное название. Заголовки групп
// пропускаются стрелками: остановка на неинтерактивной строке сбивает счёт.
const STYLES = `
:where([data-vibeui-block="autocomplete-004"]){
--vibeui-autocomplete-004-bg:oklch(1 0 0);
--vibeui-autocomplete-004-fg:oklch(0.22 0.014 265);
--vibeui-autocomplete-004-muted:oklch(0.52 0.014 265);
--vibeui-autocomplete-004-border:oklch(0.9 0.006 265);
--vibeui-autocomplete-004-active:oklch(0.95 0.02 265);
--vibeui-autocomplete-004-accent:oklch(0.55 0.17 265);
--vibeui-autocomplete-004-radius:0.75rem;
--vibeui-autocomplete-004-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="autocomplete-004"]{
display:flex;flex-direction:column;
width:100%;max-width:26rem;box-sizing:border-box;overflow:hidden;
background:var(--vibeui-autocomplete-004-bg);
border:1px solid var(--vibeui-autocomplete-004-border);
border-radius:var(--vibeui-autocomplete-004-radius);
box-shadow:0 18px 40px -24px oklch(0.2 0.02 265 / 45%);
color:var(--vibeui-autocomplete-004-fg);
font-family:var(--vibeui-autocomplete-004-font);
}
[data-vibeui-block="autocomplete-004"] [data-part="search"]{
display:flex;align-items:center;gap:0.5rem;
padding:0 0.875rem;border-bottom:1px solid var(--vibeui-autocomplete-004-border);
}
/* Лупа нарисована рамкой: ради одной иконки не тянуть пакет. */
[data-vibeui-block="autocomplete-004"] [data-part="glass"]{
position:relative;flex:none;width:0.75rem;height:0.75rem;
border:1.5px solid var(--vibeui-autocomplete-004-muted);border-radius:9999px;
}
[data-vibeui-block="autocomplete-004"] [data-part="glass"]::after{
content:"";position:absolute;right:-0.3125rem;bottom:-0.1875rem;
width:0.375rem;height:1.5px;background:var(--vibeui-autocomplete-004-muted);
transform:rotate(45deg);
}
[data-vibeui-block="autocomplete-004"] input{
flex:1;height:2.75rem;border:0;background:transparent;
color:inherit;font:inherit;font-size:0.9375rem;
}
[data-vibeui-block="autocomplete-004"] input:focus{outline:none}
[data-vibeui-block="autocomplete-004"] input::placeholder{color:var(--vibeui-autocomplete-004-muted)}
[data-vibeui-block="autocomplete-004"] kbd{
flex:none;padding:0.125rem 0.375rem;border-radius:0.3125rem;
border:1px solid var(--vibeui-autocomplete-004-border);
font-family:inherit;font-size:0.6875rem;color:var(--vibeui-autocomplete-004-muted);
}
[data-vibeui-block="autocomplete-004"] [data-part="list"]{
margin:0;padding:0.375rem;list-style:none;max-height:14rem;overflow-y:auto;
}
[data-vibeui-block="autocomplete-004"] [data-part="group"]{
padding:0.5rem 0.5rem 0.25rem;
font-size:0.6875rem;font-weight:650;letter-spacing:0.06em;text-transform:uppercase;
color:var(--vibeui-autocomplete-004-muted);
}
[data-vibeui-block="autocomplete-004"] [data-part="option"]{
display:flex;align-items:center;justify-content:space-between;gap:0.75rem;
min-height:2.25rem;padding:0 0.5rem;border-radius:0.5rem;
font-size:0.875rem;cursor:pointer;
}
[data-vibeui-block="autocomplete-004"] [data-part="option"][data-active="true"]{background:var(--vibeui-autocomplete-004-active)}
[data-vibeui-block="autocomplete-004"] [data-part="hint"]{font-size:0.75rem;color:var(--vibeui-autocomplete-004-muted)}
[data-vibeui-block="autocomplete-004"] [data-part="empty"]{padding:1.25rem 0.75rem;text-align:center;font-size:0.875rem;color:var(--vibeui-autocomplete-004-muted)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="autocomplete-004"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_COMMANDS: Autocomplete004Command[] = [
  { label: "Создать проект", group: "Действия", hint: "N" },
  { label: "Пригласить в команду", group: "Действия" },
  { label: "Загрузить файлы", group: "Действия", hint: "U" },
  { label: "Оплата и счета", group: "Настройки" },
  { label: "Уведомления", group: "Настройки" },
  { label: "Ключи доступа", group: "Настройки" },
  { label: "Документация", group: "Помощь", hint: "?" },
  { label: "Написать в поддержку", group: "Помощь" },
]

/**
 * Строка команд: поиск по приложению с группами и клавиатурой.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Autocomplete004({
  placeholder = "Команда или раздел",
  commands = DEFAULT_COMMANDS,
  emptyLabel = "Ничего не нашлось",
  onSelect,
  accent,
  className,
  style,
  ...props
}: Autocomplete004Props) {
  const id = useId()
  const [query, setQuery] = useState("")
  const [active, setActive] = useState(0)

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

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

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (!matches.length) return
    if (event.key === "ArrowDown") {
      event.preventDefault()
      setActive((active + 1) % matches.length)
    } else if (event.key === "ArrowUp") {
      event.preventDefault()
      setActive((active - 1 + matches.length) % matches.length)
    } else if (event.key === "Enter") {
      event.preventDefault()
      onSelect?.(matches[active].label)
    }
  }

  // Заголовок печатается у первой команды своей группы: разметку считаем
  // до рендера, чтобы ничего не менять по ходу отрисовки.
  const heads = matches.map((command, index) =>
    index === 0 || matches[index - 1].group !== command.group
      ? command.group
      : "",
  )

  return (
    <>
      <style href="vibeui-autocomplete-004" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="autocomplete-004"
        className={className}
        style={palette}
      >
        <div data-part="search">
          <span data-part="glass" aria-hidden="true" />
          <input
            id={id}
            type="text"
            role="combobox"
            autoComplete="off"
            placeholder={placeholder}
            value={query}
            aria-expanded="true"
            aria-controls={`${id}-list`}
            aria-autocomplete="list"
            aria-activedescendant={
              matches[active] ? `${id}-option-${active}` : undefined
            }
            onChange={(event) => {
              setQuery(event.target.value)
              setActive(0)
            }}
            onKeyDown={onKeyDown}
          />
          <kbd>Esc</kbd>
        </div>
        <ul
          id={`${id}-list`}
          role="listbox"
          aria-label="Команды"
          data-part="list"
        >
          {matches.map((command, index) => (
            <li key={command.label} role="presentation">
              {heads[index] ? (
                <p data-part="group" role="presentation">
                  {heads[index]}
                </p>
              ) : null}
              <span
                id={`${id}-option-${index}`}
                role="option"
                data-part="option"
                data-active={index === active}
                aria-selected={index === active}
                onMouseEnter={() => setActive(index)}
                onMouseDown={(event) => {
                  event.preventDefault()
                  onSelect?.(command.label)
                }}
              >
                {command.label}
                {command.hint ? (
                  <span data-part="hint">⌘ {command.hint}</span>
                ) : null}
              </span>
            </li>
          ))}
          {matches.length === 0 ? (
            <li data-part="empty" role="presentation">
              {emptyLabel}
            </li>
          ) : null}
        </ul>
      </div>
    </>
  )
}