Autocomplete

Keyboard Combobox

A hand-rolled suggestion list with full keyboard support and proper ARIA roles: arrows move the cursor while focus stays in the field.

  • autocomplete
  • combobox
  • 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/autocomplete-002?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 "autocomplete-002" (Keyboard 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/autocomplete-002.json

Registry item: https://vibeui.ru/r/autocomplete-002.json
Installs to: components/vibeui/autocomplete-002.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 hand-rolled suggestion list with full keyboard support and proper ARIA roles: arrows move the cursor while focus stays in the field.

A combobox with its own list: substring filtering, match highlighting, arrows, Enter and Escape, and correct ARIA roles. Zero dependencies, one file, its own palette.

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

<Autocomplete002
  label="City"
  options={["London", "Berlin"]}
  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-autocomplete-002-* palette — do not swap it for your theme tokens
- focus staying in the field: aria-activedescendant moves the cursor, moving real focus to a row breaks typing
- role="combobox" with aria-expanded and aria-controls — a custom list is not announced on its own
- onMouseDown with preventDefault instead of onClick: blur would otherwise close the list before the pick
- the <mark> highlight — it explains why a row is in the results
- Escape closing the list without losing what was typed

## 6. You may change
- the label, placeholder and emptyLabel copy
- the options array and the filtering rule
- the onSelect handler
- the accent through the accent prop — focus ring and highlight

## 7. Rules
- defaultOpen exists for the showcase and screenshots: in a form the list opens on focus.
- An in-flow list pushes the content below it. If you need an overlay, wrap it in a popover — the positioning is then yours.
- The filter matches a substring anywhere. For long lists switch to prefix matching, otherwise the results get noisy.
- 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-002.json
https://vibeui.ru/r/autocomplete-002.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-autocomplete-002-*. Клиентский: "use client", состояние на useState. Поле объявлено role="combobox" с aria-expanded, aria-controls и aria-activedescendant; список — role="listbox", строки — role="option". Выбор идёт на onMouseDown с preventDefault, иначе blur закроет список раньше клика. Список рендерится в потоке, без позиционируемого слоя.

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 Autocomplete002Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onSelect"
> & {
  label?: string
  placeholder?: string
  options?: string[]
  emptyLabel?: string
  /** Открыть список сразу: витрина и скриншоты, в форме не нужен. */
  defaultOpen?: boolean
  onSelect?: (value: string) => void
  accent?: string
}

// Идея компонента: полноценный combobox с клавиатурой. В отличие от datalist
// список рисуем сами — значит обязаны отдать скринридеру то, что браузер
// давал бесплатно: role="combobox" на поле, role="listbox" на списке и
// aria-activedescendant на активной строке. Фокус при этом остаётся в поле:
// перенос фокуса на строку ломает ввод.
const STYLES = `
:where([data-vibeui-block="autocomplete-002"]){
--vibeui-autocomplete-002-bg:oklch(1 0 0);
--vibeui-autocomplete-002-fg:oklch(0.22 0.014 265);
--vibeui-autocomplete-002-muted:oklch(0.52 0.014 265);
--vibeui-autocomplete-002-border:oklch(0.9 0.006 265);
--vibeui-autocomplete-002-field:oklch(0.985 0.002 265);
--vibeui-autocomplete-002-active:oklch(0.95 0.02 265);
--vibeui-autocomplete-002-accent:oklch(0.55 0.17 265);
--vibeui-autocomplete-002-radius:0.625rem;
--vibeui-autocomplete-002-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="autocomplete-002"]{
position:relative;display:flex;flex-direction:column;gap:0.375rem;
width:100%;max-width:22rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-autocomplete-002-bg);
border:1px solid var(--vibeui-autocomplete-002-border);
border-radius:calc(var(--vibeui-autocomplete-002-radius) + 0.25rem);
color:var(--vibeui-autocomplete-002-fg);
font-family:var(--vibeui-autocomplete-002-font);
}
[data-vibeui-block="autocomplete-002"] label{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="autocomplete-002"] input{
box-sizing:border-box;width:100%;height:2.5rem;padding:0 0.75rem;
border:1px solid var(--vibeui-autocomplete-002-border);
border-radius:var(--vibeui-autocomplete-002-radius);
background:var(--vibeui-autocomplete-002-field);
color:inherit;font:inherit;font-size:0.875rem;
}
[data-vibeui-block="autocomplete-002"] input::placeholder{color:var(--vibeui-autocomplete-002-muted)}
[data-vibeui-block="autocomplete-002"] input:focus-visible{
outline:2px solid var(--vibeui-autocomplete-002-accent);outline-offset:1px;border-color:transparent;
}
/* Список в потоке, а не поверх: в карточке каталога и в узкой колонке
   всплывающий слой нечем позиционировать без замера. */
[data-vibeui-block="autocomplete-002"] [data-part="list"]{
margin:0;padding:0.25rem;list-style:none;
max-height:11rem;overflow-y:auto;
border:1px solid var(--vibeui-autocomplete-002-border);
border-radius:var(--vibeui-autocomplete-002-radius);
background:var(--vibeui-autocomplete-002-bg);
}
[data-vibeui-block="autocomplete-002"] [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="autocomplete-002"] [data-part="option"][data-active="true"]{background:var(--vibeui-autocomplete-002-active)}
[data-vibeui-block="autocomplete-002"] [data-part="option"] mark{background:transparent;color:var(--vibeui-autocomplete-002-accent);font-weight:650}
[data-vibeui-block="autocomplete-002"] [data-part="empty"]{padding:0.5rem;font-size:0.8125rem;color:var(--vibeui-autocomplete-002-muted)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="autocomplete-002"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_OPTIONS = [
  "Астрахань",
  "Владивосток",
  "Волгоград",
  "Воронеж",
  "Екатеринбург",
  "Казань",
  "Калининград",
  "Краснодар",
  "Москва",
  "Новосибирск",
  "Пермь",
  "Самара",
]

function highlight(option: string, query: string) {
  if (!query) return option
  const at = option.toLowerCase().indexOf(query.toLowerCase())
  if (at < 0) return option

  return (
    <>
      {option.slice(0, at)}
      <mark>{option.slice(at, at + query.length)}</mark>
      {option.slice(at + query.length)}
    </>
  )
}

/**
 * Combobox с фильтрацией, клавиатурой и правильными ролями ARIA.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Autocomplete002({
  label = "Город",
  placeholder = "Начните вводить",
  options = DEFAULT_OPTIONS,
  emptyLabel = "Ничего не нашлось",
  defaultOpen = false,
  onSelect,
  accent,
  className,
  style,
  ...props
}: Autocomplete002Props) {
  const id = useId()
  const [query, setQuery] = useState("")
  const [open, setOpen] = useState(defaultOpen)
  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-autocomplete-002-accent": accent } : null),
    ...style,
  } as CSSProperties

  const commit = (value: string) => {
    setQuery(value)
    setOpen(false)
    onSelect?.(value)
  }

  const move = (delta: number) => {
    if (!matches.length) return
    const next = (active + delta + matches.length) % matches.length
    setActive(next)
    setOpen(true)
    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" && open && matches[active]) {
      event.preventDefault()
      commit(matches[active])
    } else if (event.key === "Escape") {
      setOpen(false)
    }
  }

  return (
    <>
      <style href="vibeui-autocomplete-002" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="autocomplete-002"
        className={className}
        style={palette}
      >
        <label htmlFor={id}>{label}</label>
        <input
          id={id}
          type="text"
          role="combobox"
          autoComplete="off"
          placeholder={placeholder}
          value={query}
          aria-expanded={open}
          aria-controls={`${id}-list`}
          aria-autocomplete="list"
          aria-activedescendant={
            open && matches[active] ? `${id}-option-${active}` : undefined
          }
          onChange={(event) => {
            setQuery(event.target.value)
            setActive(0)
            setOpen(true)
          }}
          onFocus={() => setOpen(true)}
          onBlur={() => setOpen(false)}
          onKeyDown={onKeyDown}
        />
        {open ? (
          <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={index === active}
                onMouseEnter={() => setActive(index)}
                onMouseDown={(event) => {
                  event.preventDefault()
                  commit(option)
                }}
              >
                {highlight(option, query.trim())}
              </li>
            ))}
            {matches.length === 0 ? (
              <li data-part="empty" role="presentation">
                {emptyLabel}
              </li>
            ) : null}
          </ul>
        ) : null}
      </div>
    </>
  )
}