Autocomplete
Mention Picker
Mentions inside the text itself: the list opens on an @ before a word, not on focus in the field.
- autocomplete
- mention
- textarea
- avatar
Preview
Use it with AI
- 1. Copy the link.
- 2. Write to your agent in your own words and drop the link into the sentence.
- 3. The agent opens the link and installs the component from the registry.
put this in the header: https://vibeui.ru/c/autocomplete-011?lang=en
- Марк Ильинфронтенд@mark
- Мария Гуровапродукт@masha
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-011" (Mention Picker) 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-011.json
Registry item: https://vibeui.ru/r/autocomplete-011.json
Installs to: components/vibeui/autocomplete-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
Mentions inside the text itself: the list opens on an @ before a word, not on focus in the field.
A comment field with mentions: the list appears after an @ before a word, each row shows name, role and handle, and picking one inserts the mention into the text. Zero dependencies, one file, its own palette.
## 3. How to use it
import { Autocomplete011 } from "@/components/vibeui/autocomplete-011"
<Autocomplete011
people={[{ name: "Anna Petrova", handle: "anna", role: "design" }]}
onChange={(text) => console.log(text)}
/>
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-011-* palette — do not swap it for your theme tokens
- opening on the @ rather than on focus: a mention lives mid-sentence
- ending the token at a space — otherwise the list hangs around to the end of the paragraph
- tracking the caret on click and arrow keys, not only while typing
- inserting a trailing space after the handle: the next word would stick to the mention
- initials instead of photos: forty avatars are not loaded for a dropdown
## 6. You may change
- the people array: name, handle and role
- the label, placeholder and starting text
- the onChange handler
- the accent through the accent prop
## 7. Rules
- The list is not anchored to the caret: it sits under the field. Popping up at the caret needs measurement, which is a different component.
- A mention is plain text: parsing @handles and sending notifications is your backend's job.
- The avatar hue is derived from the name and stable, but not unique: two people can land on close colours.
- 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-011.jsonhttps://vibeui.ru/r/autocomplete-011.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-autocomplete-011-*. Клиентский: "use client". Токен упоминания вычисляется от последней собаки до курсора и обрывается на пробеле, поэтому список гаснет сам. Позиция курсора отслеживается на onChange, onKeyUp и onClick. Аватар — инициалы на оттенке, посчитанном из имени: сумма кодов символов по модулю 360 отдаётся в локальную переменную оттенка.
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 {
ChangeEvent,
ComponentPropsWithoutRef,
CSSProperties,
} from "react"
export type Autocomplete011Person = {
name: string
handle: string
role: string
}
export type Autocomplete011Props = Omit<
ComponentPropsWithoutRef<"div">,
"children" | "onChange" | "defaultValue"
> & {
label?: string
placeholder?: string
people?: Autocomplete011Person[]
defaultValue?: string
onChange?: (value: string) => void
accent?: string
}
// Идея компонента: подсказка внутри текста. Список появляется не от фокуса, а
// от собаки перед словом — упоминание живёт посреди фразы, и открывать его
// каждый раз при клике в поле значит мешать письму. Аватар — инициалы на
// оттенке из имени: сорок фотографий ради выпадающего списка не грузим.
const STYLES = `
:where([data-vibeui-block="autocomplete-011"]){
--vibeui-autocomplete-011-bg:oklch(1 0 0);
--vibeui-autocomplete-011-fg:oklch(0.22 0.014 265);
--vibeui-autocomplete-011-muted:oklch(0.52 0.014 265);
--vibeui-autocomplete-011-border:oklch(0.9 0.006 265);
--vibeui-autocomplete-011-field:oklch(0.985 0.002 265);
--vibeui-autocomplete-011-active:oklch(0.95 0.02 265);
--vibeui-autocomplete-011-accent:oklch(0.55 0.17 265);
--vibeui-autocomplete-011-radius:0.625rem;
--vibeui-autocomplete-011-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="autocomplete-011"]{
display:flex;flex-direction:column;gap:0.375rem;
width:100%;max-width:24rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-autocomplete-011-bg);
border:1px solid var(--vibeui-autocomplete-011-border);
border-radius:calc(var(--vibeui-autocomplete-011-radius) + 0.25rem);
color:var(--vibeui-autocomplete-011-fg);
font-family:var(--vibeui-autocomplete-011-font);
}
[data-vibeui-block="autocomplete-011"] label{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="autocomplete-011"] textarea{
box-sizing:border-box;width:100%;min-height:4.5rem;resize:vertical;
padding:0.5rem 0.75rem;
border:1px solid var(--vibeui-autocomplete-011-border);
border-radius:var(--vibeui-autocomplete-011-radius);
background:var(--vibeui-autocomplete-011-field);
color:inherit;font:inherit;font-size:0.875rem;line-height:1.5;
}
[data-vibeui-block="autocomplete-011"] textarea::placeholder{color:var(--vibeui-autocomplete-011-muted)}
[data-vibeui-block="autocomplete-011"] textarea:focus-visible{
outline:2px solid var(--vibeui-autocomplete-011-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="autocomplete-011"] [data-part="list"]{
margin:0;padding:0.25rem;list-style:none;max-height:11rem;overflow-y:auto;
border:1px solid var(--vibeui-autocomplete-011-border);
border-radius:var(--vibeui-autocomplete-011-radius);
background:var(--vibeui-autocomplete-011-bg);
}
[data-vibeui-block="autocomplete-011"] [data-part="option"]{
display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:0.25rem 0.625rem;
padding:0.375rem 0.5rem;border-radius:0.4375rem;cursor:pointer;
}
[data-vibeui-block="autocomplete-011"] [data-part="option"]:hover{background:var(--vibeui-autocomplete-011-active)}
[data-vibeui-block="autocomplete-011"] [data-part="avatar"]{
grid-row:span 2;display:flex;align-items:center;justify-content:center;
width:1.75rem;height:1.75rem;border-radius:9999px;
background:oklch(0.9 0.05 var(--vibeui-autocomplete-011-hue,265));
color:oklch(0.35 0.09 var(--vibeui-autocomplete-011-hue,265));
font-size:0.6875rem;font-weight:700;
}
[data-vibeui-block="autocomplete-011"] [data-part="name"]{font-size:0.875rem;line-height:1.2}
[data-vibeui-block="autocomplete-011"] [data-part="handle"]{font-size:0.75rem;color:var(--vibeui-autocomplete-011-muted)}
[data-vibeui-block="autocomplete-011"] [data-part="role"]{
grid-column:3;grid-row:span 2;justify-self:end;
font-size:0.6875rem;color:var(--vibeui-autocomplete-011-muted);
}
[data-vibeui-block="autocomplete-011"] [data-part="hint"]{font-size:0.75rem;color:var(--vibeui-autocomplete-011-muted)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="autocomplete-011"] *{animation:none!important;transition:none!important}}
`
const DEFAULT_PEOPLE: Autocomplete011Person[] = [
{ name: "Анна Петрова", handle: "anna", role: "дизайн" },
{ name: "Марк Ильин", handle: "mark", role: "фронтенд" },
{ name: "Мария Гурова", handle: "masha", role: "продукт" },
{ name: "Олег Дроздов", handle: "oleg", role: "бэкенд" },
{ name: "Ирина Ким", handle: "irina", role: "поддержка" },
]
// Оттенок из имени: FNV-1a, разложенный по двенадцати ступеням круга.
// Сумма кодов символов не годится — кириллические имена ложатся в один
// розовый сектор; ступени в 30° дают заведомо различимые цвета.
function hue(name: string) {
let hash = 2166136261
for (const symbol of name) {
hash ^= symbol.codePointAt(0)!
hash = Math.imul(hash, 16777619)
}
return ((hash >>> 0) % 12) * 30
}
function initials(name: string) {
return name
.split(" ")
.slice(0, 2)
.map((part) => part[0])
.join("")
}
/**
* Упоминания по «@» прямо в тексте: список открывает собака, а не фокус.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Autocomplete011({
label = "Комментарий",
placeholder = "Напишите и позовите коллегу через @",
people = DEFAULT_PEOPLE,
defaultValue = "Проверьте макет, пожалуйста, @ma",
onChange,
accent,
className,
style,
...props
}: Autocomplete011Props) {
const id = useId()
const [value, setValue] = useState(defaultValue)
const [caret, setCaret] = useState(defaultValue.length)
// Токен упоминания — от последней собаки до курсора, без пробелов внутри.
const token = useMemo(() => {
const before = value.slice(0, caret)
const at = before.lastIndexOf("@")
if (at < 0) return null
const word = before.slice(at + 1)
if (/\s/.test(word)) return null
return { at, word }
}, [caret, value])
const matches = useMemo(() => {
if (!token) return []
const needle = token.word.toLowerCase()
return people.filter(
(person) =>
person.handle.startsWith(needle) ||
person.name.toLowerCase().includes(needle),
)
}, [people, token])
const palette = {
...(accent ? { "--vibeui-autocomplete-011-accent": accent } : null),
...style,
} as CSSProperties
const update = (event: ChangeEvent<HTMLTextAreaElement>) => {
setValue(event.target.value)
setCaret(event.target.selectionStart ?? event.target.value.length)
onChange?.(event.target.value)
}
const mention = (handle: string) => {
if (!token) return
const next = `${value.slice(0, token.at)}@${handle} ${value.slice(caret)}`
setValue(next)
setCaret(token.at + handle.length + 2)
onChange?.(next)
}
return (
<>
<style href="vibeui-autocomplete-011" precedence="medium">
{STYLES}
</style>
<div
{...props}
data-vibeui-block="autocomplete-011"
className={className}
style={palette}
>
<label htmlFor={id}>{label}</label>
<textarea
id={id}
placeholder={placeholder}
value={value}
aria-describedby={`${id}-hint`}
onChange={update}
onKeyUp={(event) => setCaret(event.currentTarget.selectionStart ?? 0)}
onClick={(event) => setCaret(event.currentTarget.selectionStart ?? 0)}
/>
{matches.length ? (
<ul role="listbox" aria-label="Коллеги" data-part="list">
{matches.map((person) => (
<li
key={person.handle}
role="option"
aria-selected="false"
data-part="option"
style={
{
"--vibeui-autocomplete-011-hue": hue(person.name),
} as CSSProperties
}
onMouseDown={(event) => {
event.preventDefault()
mention(person.handle)
}}
>
<span data-part="avatar" aria-hidden="true">
{initials(person.name)}
</span>
<span data-part="name">{person.name}</span>
<span data-part="role">{person.role}</span>
<span data-part="handle">@{person.handle}</span>
</li>
))}
</ul>
) : null}
<span data-part="hint" id={`${id}-hint`}>
Список открывается после @ и закрывается на пробеле
</span>
</div>
</>
)
}