Combobox

Thumbnail Options

An option picker with previews: every row carries a thumbnail, and when there is no image URL it falls back to a gradient with initials whose hue is derived deterministically from the name.

  • combobox
  • thumbnail
  • templates
  • visual

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-014?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-014" (Thumbnail Options) 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-014.json

Registry item: https://vibeui.ru/r/combobox-014.json
Installs to: components/vibeui/combobox-014.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
An option picker with previews: every row carries a thumbnail, and when there is no image URL it falls back to a gradient with initials whose hue is derived deterministically from the name.

A list of options that differ by appearance: email templates, themes, cover images. Every row has a 2.4rem preview, a name and a caption with supporting data. When no picture is available a gradient with initials takes its place, and its hue comes from the name, so the same option is always recognisable by colour. Zero dependencies, one file, its own palette.

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

<Combobox014 label="Email template" defaultValue="Abandoned cart" />

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-014-* palette — do not swap it for your theme tokens (bg-muted, text-muted-foreground and the like)
- the hueOf function: the hue has to be deterministic, a random colour would change on every render
- the empty alt on the image: the option name sits right next to it as text, and repeating it in alt reads twice
- object-fit: cover with a fixed preview size — otherwise images of different ratios pull the rows apart
- the ellipsis truncation on name and caption: a long name must not break the row grid
- the plain <img> instead of next/image: the component has to work in any React project
- the fallback gradient with initials: a list without pictures still has to be distinguishable

## 6. You may change
- label — the field caption
- placeholder — the hint text in the search field
- options — the options: a name, a caption and an optional image URL
- defaultValue — the option selected at first render
- onSelect — the handler for the chosen name
- accent — the highlight colour of the selected row

## 7. Rules
- Do not feed full-size images into the preview: the row is 2.4rem tall but the traffic is real.
- Do not derive the hue from an id when people see a name: the colour must map to what is on screen.
- This variant is not a card grid: it is a list where the preview helps to recognise a row.
- If only some options have pictures, mixing gradients and photos looks messy — pick one or the other.

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

Компонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-combobox-014-*. Клиентский: запрос и выбранное значение живут в useState. Строка списка — grid 2.4rem + 1fr: слева превью, справа название и подпись, обе строки текста обрезаются многоточием, поэтому длинное имя не ломает раскладку. Если у варианта есть src, рисуется обычный <img> с object-fit: cover и пустым alt (картинка декоративна, имя рядом текстом); если ссылки нет, показывается градиент, чей оттенок считает hueOf — детерминированный FNV-хэш по названию, поэтому вариант всегда одного цвета. Оттенок передаётся единственной инлайновой переменной --vibeui-combobox-014-hue: это динамическое значение, которое нельзя выразить статическим классом.

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 Combobox014Option = { name: string; hint?: string; src?: string }

export type Combobox014Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onSelect"
> & {
  label?: string
  placeholder?: string
  options?: Combobox014Option[]
  defaultValue?: string
  onSelect?: (value: string) => void
  accent?: string
}

// Идея компонента: когда варианты различаются внешним видом — шаблон письма,
// тема оформления, обложка, — текстовая строка бесполезна. Здесь у каждого
// варианта есть картинка, а если ссылки нет, вместо неё рисуется свой
// градиент с инициалами: оттенок считается из названия, поэтому вариант
// всегда узнаётся по одному и тому же пятну.
const STYLES = `
:where([data-vibeui-block="combobox-014"]){
--vibeui-combobox-014-bg:oklch(1 0 0);
--vibeui-combobox-014-fg:oklch(0.22 0.014 285);
--vibeui-combobox-014-muted:oklch(0.55 0.014 285);
--vibeui-combobox-014-border:oklch(0.9 0.008 285);
--vibeui-combobox-014-field:oklch(0.985 0.004 285);
--vibeui-combobox-014-soft:oklch(0.96 0.008 285);
--vibeui-combobox-014-accent:oklch(0.5 0.13 285);
--vibeui-combobox-014-accentsoft:oklch(0.94 0.04 285);
--vibeui-combobox-014-radius:0.625rem;
--vibeui-combobox-014-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="combobox-014"]{
display:flex;flex-direction:column;gap:0.4rem;
width:100%;max-width:22rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-combobox-014-bg);
border:1px solid var(--vibeui-combobox-014-border);
border-radius:calc(var(--vibeui-combobox-014-radius) + 0.25rem);
color:var(--vibeui-combobox-014-fg);
font-family:var(--vibeui-combobox-014-font);
}
[data-vibeui-block="combobox-014"] label{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="combobox-014"] input{
box-sizing:border-box;width:100%;height:2.4rem;padding:0 0.6rem;
border:1px solid var(--vibeui-combobox-014-border);
border-radius:var(--vibeui-combobox-014-radius);
background:var(--vibeui-combobox-014-field);
color:inherit;font:inherit;font-size:0.875rem;
}
[data-vibeui-block="combobox-014"] input::placeholder{color:var(--vibeui-combobox-014-muted)}
[data-vibeui-block="combobox-014"] input:focus-visible{
outline:2px solid var(--vibeui-combobox-014-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="combobox-014"] [data-part="list"]{
margin:0;padding:0.2rem;list-style:none;display:flex;flex-direction:column;gap:0.15rem;
max-height:14rem;overflow:auto;
border:1px solid var(--vibeui-combobox-014-border);
border-radius:var(--vibeui-combobox-014-radius);
}
[data-vibeui-block="combobox-014"] [data-part="option"]{
appearance:none;cursor:pointer;font:inherit;width:100%;
display:grid;grid-template-columns:2.4rem 1fr;align-items:center;gap:0.55rem;
box-sizing:border-box;padding:0.35rem 0.4rem;
border:0;border-radius:0.5rem;background:transparent;color:inherit;text-align:left;
transition:background-color .16s ease;
}
[data-vibeui-block="combobox-014"] [data-part="option"]:hover{background:var(--vibeui-combobox-014-soft)}
[data-vibeui-block="combobox-014"] [data-part="option"]:focus-visible{
outline:2px solid var(--vibeui-combobox-014-accent);outline-offset:-2px;
}
[data-vibeui-block="combobox-014"] [data-part="option"][aria-selected="true"]{
background:var(--vibeui-combobox-014-accentsoft);
}
[data-vibeui-block="combobox-014"] [data-part="thumb"]{
display:flex;align-items:center;justify-content:center;
width:2.4rem;height:2.4rem;border-radius:0.45rem;overflow:hidden;flex:none;
background:linear-gradient(140deg,
oklch(0.86 0.09 var(--vibeui-combobox-014-hue,250)),
oklch(0.62 0.13 calc(var(--vibeui-combobox-014-hue,250) + 40)));
color:oklch(0.99 0.01 var(--vibeui-combobox-014-hue,250));
font-size:0.75rem;font-weight:700;letter-spacing:0.02em;
}
[data-vibeui-block="combobox-014"] [data-part="thumb"] img{
width:100%;height:100%;object-fit:cover;display:block;
}
[data-vibeui-block="combobox-014"] [data-part="body"]{min-width:0;display:flex;flex-direction:column}
[data-vibeui-block="combobox-014"] [data-part="name"]{
font-size:0.8125rem;font-weight:600;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="combobox-014"] [data-part="hint"]{
font-size:0.72rem;color:var(--vibeui-combobox-014-muted);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="combobox-014"] [data-part="empty"]{
margin:0;padding:0.6rem 0.5rem;font-size:0.8125rem;color:var(--vibeui-combobox-014-muted);
}
[data-vibeui-block="combobox-014"] [data-part="picked"]{
display:flex;align-items:center;gap:0.5rem;
padding-top:0.5rem;border-top:1px solid var(--vibeui-combobox-014-border);
font-size:0.8rem;color:var(--vibeui-combobox-014-muted);
}
[data-vibeui-block="combobox-014"] [data-part="picked"] b{color:var(--vibeui-combobox-014-fg)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="combobox-014"] *{animation:none!important;transition:none!important}}
`

const TEMPLATES: Combobox014Option[] = [
  { name: "Приветственное письмо", hint: "Онбординг · 3 блока" },
  { name: "Брошенная корзина", hint: "Продажи · 2 блока" },
  { name: "Ежемесячный дайджест", hint: "Контент · 6 блоков" },
  { name: "Подтверждение заказа", hint: "Транзакционное · 1 блок" },
  { name: "Возврат клиента", hint: "Реактивация · 4 блока" },
  { name: "Отчёт по проекту", hint: "Внутреннее · 5 блоков" },
]

/** Детерминированный оттенок из названия: вариант всегда одного цвета. */
function hueOf(name: string) {
  let hash = 2166136261

  for (const symbol of name) {
    hash ^= symbol.codePointAt(0) ?? 0
    hash = Math.imul(hash, 16777619)
  }

  return ((hash >>> 0) % 12) * 30
}

function initialsOf(name: string) {
  return name
    .split(/\s+/)
    .slice(0, 2)
    .map((word) => word.charAt(0).toUpperCase())
    .join("")
}

/**
 * Выбор варианта с картинкой: превью слева, название и подпись справа.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Combobox014({
  label = "Шаблон письма",
  placeholder = "Найти шаблон",
  options = TEMPLATES,
  defaultValue = "Брошенная корзина",
  onSelect,
  accent,
  className,
  style,
  ...props
}: Combobox014Props) {
  const id = useId()
  const [query, setQuery] = useState("")
  const [value, setValue] = useState(defaultValue)

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

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

  const picked = options.find((option) => option.name === value)

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

  return (
    <>
      <style href="vibeui-combobox-014" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="combobox-014"
        className={className}
        style={palette}
      >
        <label htmlFor={`${id}-input`}>{label}</label>
        <input
          id={`${id}-input`}
          type="text"
          role="combobox"
          autoComplete="off"
          placeholder={placeholder}
          aria-expanded="true"
          aria-controls={`${id}-list`}
          aria-autocomplete="list"
          value={query}
          onChange={(event) => setQuery(event.target.value)}
        />
        <ul
          id={`${id}-list`}
          role="listbox"
          aria-label={label}
          data-part="list"
        >
          {matches.length === 0 ? (
            <li role="none">
              <p data-part="empty">Шаблон не найден</p>
            </li>
          ) : (
            matches.map((option) => (
              <li key={option.name} role="none">
                <button
                  type="button"
                  role="option"
                  data-part="option"
                  aria-selected={option.name === value}
                  onClick={() => {
                    setValue(option.name)
                    onSelect?.(option.name)
                  }}
                >
                  <span
                    data-part="thumb"
                    style={
                      {
                        "--vibeui-combobox-014-hue": hueOf(option.name),
                      } as CSSProperties
                    }
                  >
                    {option.src ? (
                      <img src={option.src} alt="" />
                    ) : (
                      <span aria-hidden="true">{initialsOf(option.name)}</span>
                    )}
                  </span>
                  <span data-part="body">
                    <span data-part="name">{option.name}</span>
                    {option.hint ? (
                      <span data-part="hint">{option.hint}</span>
                    ) : null}
                  </span>
                </button>
              </li>
            ))
          )}
        </ul>
        <p data-part="picked" aria-live="polite">
          Выбран шаблон: <b>{picked?.name ?? "не выбран"}</b>
        </p>
      </div>
    </>
  )
}