Combobox

Loading Empty

A combobox with the two forgotten states: skeleton bars while the list is on its way, and a useful empty state with ready-made queries instead of "nothing found".

  • combobox
  • loading
  • empty state
  • skeleton

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-008?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-008" (Loading Empty) 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-008.json
    
    Registry item: https://vibeui.ru/r/combobox-008.json
    Installs to: components/vibeui/combobox-008.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 combobox with the two forgotten states: skeleton bars while the list is on its way, and a useful empty state with ready-made queries instead of "nothing found".
    
    A combobox with honest waiting and empty states: a skeleton instead of a jumping panel and ready queries instead of a dead end. One file, zero dependencies, the timer is cleared on every keystroke.
    
    ## 3. How to use it
    import { Combobox008 } from "@/components/vibeui/combobox-008"
    
    <Combobox008
      label="City"
      options={["Prague", "Prato"]}
      suggestions={["Prague", "Busan"]}
      delay={600}
      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-008-* palette — do not swap it for your theme tokens
    - clearing the timer inside useEffect and deriving the waiting state: without the first a stale answer overwrites the fresh one, without the second setState in the effect body causes cascading renders
    - the panel's min-height and the skeleton row height — otherwise the panel jumps and people miss the row
    - aria-busy on the field and role="status" on the skeleton: waiting must be spoken, not only blinked
    - the suggestion buttons in the empty state — a dead end with no exit reads as broken search
    - the block's own light surface: without it the dark text disappears on the dark catalog card
    
    ## 6. You may change
    - the row source through options and the ready queries through suggestions
    - the wait through delay and the starting query through defaultQuery
    - the state texts through loadingLabel and emptyLabel
    - the field caption through label, the placeholder through placeholder and the color through accent
    
    ## 7. Rules
    - Loading is simulated with a timer: when you plug in fetch, keep the cancellation or the response race comes back.
    - Three skeleton bars are a compromise: more of them looks honest but keeps an empty panel on screen longer.
    - Suggestions in the empty state are not checked for results: make sure they actually find something.
    - 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-008.json
    https://vibeui.ru/r/combobox-008.json

    Компонент самодостаточен: один файл, без зависимостей, собственная палитра --vibeui-combobox-008-*. Клиентский: состояний два — набранный запрос и показанный, — а ожидание из них выводится: пока показанный не догнал набранный, список считается едущим, и отдельного флага загрузки нет. useEffect только ставит таймер на delay и чистит его при следующем нажатии, поэтому устаревший ответ не перетирает свежий, а синхронного setState в теле эффекта нет. Панель держит min-height, заглушки повторяют высоту строк — панель не прыгает при смене состояния. Поле объявляет aria-busy на время ожидания, блок заглушек — role="status" с подписью, поэтому загрузка проговаривается. Пустая выдача не заканчивается сообщением: под ним лежат кнопки с заведомо результативными запросами. Список скрывается атрибутом hidden, а не удаляется, — aria-controls обязан указывать на существующий узел.

    Component source

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

    "use client"
    
    import { useEffect, useId, useMemo, useRef, useState } from "react"
    import type {
      ComponentPropsWithoutRef,
      CSSProperties,
      KeyboardEvent,
    } from "react"
    
    export type Combobox008Props = Omit<
      ComponentPropsWithoutRef<"div">,
      "children" | "onSelect"
    > & {
      label?: string
      placeholder?: string
      options?: string[]
      suggestions?: string[]
      loadingLabel?: string
      emptyLabel?: string
      delay?: number
      defaultQuery?: string
      onSelect?: (value: string) => void
      accent?: string
    }
    
    // Идея компонента: два состояния, которые обычно забывают. Пока список едет,
    // на его месте стоят полосы-заглушки той же высоты — панель не прыгает.
    // Когда не нашлось ничего, пусто не остаётся пустым: под сообщением лежат
    // готовые запросы, по которым точно что-то есть.
    const STYLES = `
    :where([data-vibeui-block="combobox-008"]){
    --vibeui-combobox-008-bg:oklch(1 0 0);
    --vibeui-combobox-008-fg:oklch(0.23 0.02 350);
    --vibeui-combobox-008-muted:oklch(0.55 0.02 350);
    --vibeui-combobox-008-border:oklch(0.9 0.01 350);
    --vibeui-combobox-008-field:oklch(0.985 0.005 350);
    --vibeui-combobox-008-active:oklch(0.95 0.035 350);
    --vibeui-combobox-008-accent:oklch(0.55 0.17 350);
    --vibeui-combobox-008-radius:0.625rem;
    --vibeui-combobox-008-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
    }
    [data-vibeui-block="combobox-008"]{
    display:flex;flex-direction:column;gap:0.375rem;
    width:100%;max-width:21rem;box-sizing:border-box;padding:0.875rem;
    background:var(--vibeui-combobox-008-bg);
    border:1px solid var(--vibeui-combobox-008-border);
    border-radius:calc(var(--vibeui-combobox-008-radius) + 0.25rem);
    color:var(--vibeui-combobox-008-fg);
    font-family:var(--vibeui-combobox-008-font);
    }
    [data-vibeui-block="combobox-008"] label{font-size:0.8125rem;font-weight:600}
    [data-vibeui-block="combobox-008"] [data-part="field"]{position:relative;display:flex;align-items:center}
    [data-vibeui-block="combobox-008"] input{
    box-sizing:border-box;width:100%;height:2.5rem;padding:0 2.25rem 0 0.75rem;
    border:1px solid var(--vibeui-combobox-008-border);
    border-radius:var(--vibeui-combobox-008-radius);
    background:var(--vibeui-combobox-008-field);
    color:inherit;font:inherit;font-size:0.875rem;
    }
    [data-vibeui-block="combobox-008"] input::placeholder{color:var(--vibeui-combobox-008-muted)}
    [data-vibeui-block="combobox-008"] input:focus-visible{outline:2px solid var(--vibeui-combobox-008-accent);outline-offset:1px;border-color:transparent}
    [data-vibeui-block="combobox-008"] [data-part="spinner"]{
    position:absolute;right:0.75rem;width:0.9rem;height:0.9rem;border-radius:999px;
    border:2px solid var(--vibeui-combobox-008-active);
    border-top-color:var(--vibeui-combobox-008-accent);
    animation:vibeui-combobox-008-spin .7s linear infinite;
    }
    @keyframes vibeui-combobox-008-spin{to{transform:rotate(360deg)}}
    [data-vibeui-block="combobox-008"] [data-part="panel"]{
    padding:0.25rem;min-height:6.5rem;
    border:1px solid var(--vibeui-combobox-008-border);
    border-radius:var(--vibeui-combobox-008-radius);
    }
    [data-vibeui-block="combobox-008"] [data-part="list"]{margin:0;padding:0;list-style:none;max-height:9rem;overflow-y:auto}
    [data-vibeui-block="combobox-008"] [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-008"] [data-part="option"][data-active="true"]{background:var(--vibeui-combobox-008-active)}
    [data-vibeui-block="combobox-008"] [data-part="skeleton"]{display:flex;flex-direction:column;gap:0.4rem;padding:0.35rem 0.5rem}
    [data-vibeui-block="combobox-008"] [data-part="skeleton"] span{
    height:0.8rem;border-radius:999px;
    background:linear-gradient(90deg,var(--vibeui-combobox-008-active),var(--vibeui-combobox-008-field),var(--vibeui-combobox-008-active));
    background-size:200% 100%;
    animation:vibeui-combobox-008-shine 1.2s ease-in-out infinite;
    }
    [data-vibeui-block="combobox-008"] [data-part="skeleton"] span:nth-child(2){width:78%}
    [data-vibeui-block="combobox-008"] [data-part="skeleton"] span:nth-child(3){width:56%}
    @keyframes vibeui-combobox-008-shine{to{background-position:-200% 0}}
    [data-vibeui-block="combobox-008"] [data-part="empty"]{display:flex;flex-direction:column;gap:0.45rem;padding:0.5rem}
    [data-vibeui-block="combobox-008"] [data-part="emptytitle"]{font-size:0.8125rem;font-weight:600}
    [data-vibeui-block="combobox-008"] [data-part="emptyhint"]{margin:0;font-size:0.75rem;color:var(--vibeui-combobox-008-muted)}
    [data-vibeui-block="combobox-008"] [data-part="chips"]{display:flex;flex-wrap:wrap;gap:0.3rem}
    [data-vibeui-block="combobox-008"] [data-part="chip"]{
    appearance:none;cursor:pointer;height:1.6rem;padding:0 0.6rem;border-radius:999px;
    border:1px solid var(--vibeui-combobox-008-border);
    background:var(--vibeui-combobox-008-field);
    color:inherit;font:inherit;font-size:0.75rem;font-weight:600;
    }
    [data-vibeui-block="combobox-008"] [data-part="chip"]:hover{background:var(--vibeui-combobox-008-active)}
    [data-vibeui-block="combobox-008"] [data-part="chip"]:focus-visible{outline:2px solid var(--vibeui-combobox-008-accent);outline-offset:1px}
    @media (prefers-reduced-motion:reduce){[data-vibeui-block="combobox-008"] *{animation:none!important;transition:none!important}}
    `
    
    const DEFAULT_OPTIONS = [
      "Прага",
      "Прато",
      "Провиденс",
      "Пуэбла",
      "Портленд",
      "Пусан",
      "Познань",
    ]
    
    /**
     * Combobox с состоянием загрузки и содержательной пустотой: полосы-заглушки
     * вместо прыгающей панели, готовые запросы вместо «ничего не найдено».
     */
    export function Combobox008({
      label = "Город",
      placeholder = "Начните вводить",
      options = DEFAULT_OPTIONS,
      suggestions = ["Прага", "Пусан", "Портленд"],
      loadingLabel = "Ищем совпадения",
      emptyLabel = "Ничего не нашлось",
      delay = 600,
      defaultQuery = "",
      onSelect,
      accent,
      className,
      style,
      ...props
    }: Combobox008Props) {
      const id = useId()
      const [query, setQuery] = useState(defaultQuery)
      const [ready, setReady] = useState("")
      const [value, setValue] = useState("")
      const [active, setActive] = useState(0)
      const listRef = useRef<HTMLUListElement>(null)
    
      // Ожидание не хранится состоянием, а выводится: пока показанный запрос
      // не догнал набранный, список считается едущим. Эффект только ставит
      // таймер, а setState живёт в его колбэке — синхронный setState в теле
      // эффекта устроил бы каскад рендеров.
      const loading = query.trim() !== "" && query !== ready
    
      useEffect(() => {
        if (query === ready) return
    
        const timer = setTimeout(() => setReady(query), query.trim() ? delay : 0)
    
        return () => clearTimeout(timer)
      }, [delay, query, ready])
    
      const rows = useMemo(() => {
        const needle = ready.trim().toLowerCase()
        if (!needle) return []
        return options.filter((option) => option.toLowerCase().includes(needle))
      }, [options, ready])
    
      const palette = {
        ...(accent ? { "--vibeui-combobox-008-accent": accent } : null),
        ...style,
      } as CSSProperties
    
      const commit = (option: string) => {
        setValue(option)
        setQuery(option)
        setReady(option)
        onSelect?.(option)
      }
    
      const move = (delta: number) => {
        if (!rows.length) return
        const next = (active + delta + rows.length) % rows.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 (rows[active]) commit(rows[active])
        } else if (event.key === "Escape") {
          event.preventDefault()
          setQuery("")
        }
      }
    
      const showEmpty = !loading && query.trim() !== "" && rows.length === 0
    
      return (
        <>
          <style href="vibeui-combobox-008" precedence="medium">
            {STYLES}
          </style>
          <div
            {...props}
            data-vibeui-block="combobox-008"
            className={className}
            style={palette}
          >
            <label htmlFor={`${id}-input`}>{label}</label>
            <div data-part="field">
              <input
                id={`${id}-input`}
                type="text"
                role="combobox"
                autoComplete="off"
                placeholder={placeholder}
                aria-expanded="true"
                aria-controls={`${id}-list`}
                aria-autocomplete="list"
                aria-busy={loading}
                aria-activedescendant={
                  rows[active] ? `${id}-option-${active}` : undefined
                }
                value={query}
                onChange={(event) => {
                  setQuery(event.target.value)
                  setActive(0)
                }}
                onKeyDown={onKeyDown}
              />
              {loading ? <span data-part="spinner" aria-hidden="true" /> : null}
            </div>
            <div data-part="panel">
              {loading ? (
                <div data-part="skeleton" role="status" aria-label={loadingLabel}>
                  <span />
                  <span />
                  <span />
                </div>
              ) : null}
              {showEmpty ? (
                <div data-part="empty">
                  <strong data-part="emptytitle">{emptyLabel}</strong>
                  <p data-part="emptyhint">
                    Проверьте раскладку или попробуйте один из запросов ниже.
                  </p>
                  <div data-part="chips">
                    {suggestions.map((suggestion) => (
                      <button
                        key={suggestion}
                        type="button"
                        data-part="chip"
                        onClick={() => setQuery(suggestion)}
                      >
                        {suggestion}
                      </button>
                    ))}
                  </div>
                </div>
              ) : null}
              <ul
                ref={listRef}
                id={`${id}-list`}
                role="listbox"
                aria-label={label}
                data-part="list"
                hidden={loading || showEmpty}
              >
                {rows.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>
            </div>
          </div>
        </>
      )
    }