Mention
An @-triggered people picker inside a textarea, chat-composer style: the text after @ filters by name or handle, rows show avatar, name, handle, title and presence, and inserted mentions are painted in colour while the textarea stays the form field.
Preview
- No results found.
{{-- Type @ and a few letters. Rows carry avatar (or initials), handle, title and
presence; inserted mentions are painted in the textarea. --}}
@php
$people = [
['id' => 1, 'label' => 'Ada Lovelace', 'handle' => 'ada', 'description' => 'Engineering', 'status' => 'online'],
['id' => 2, 'label' => 'Alan Turing', 'handle' => 'alan', 'description' => 'Research', 'status' => 'away'],
['id' => 3, 'label' => 'Grace Hopper', 'handle' => 'grace', 'description' => 'Platform', 'status' => 'busy'],
['id' => 4, 'label' => 'Linus Torvalds', 'handle' => 'linus', 'description' => 'Kernel', 'status' => 'offline'],
['id' => 5, 'label' => 'Margaret Hamilton', 'handle' => 'margaret', 'description' => 'Flight software'],
];
@endphp
<x-ui.mention name="comment" :items="$people" value="Thanks @Ada Lovelace for the review — @grace can you take a look too?" class="w-96" />
Installation
php artisan ui:add mention
Note
This component ships an Alpine behavior module at
resources/js/ui/mention.js. Import it once from your bundle so it registers on alpine:init:
import './mention.js';
Registry contract
php artisan ui:add mention
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/mention.blade.php -
resources/js/ui/mention.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Mention (`mention`)
An @-triggered people picker inside a textarea, chat-composer style: the text after @ filters by name or handle, rows show avatar, name, handle, title and presence, and inserted mentions are painted in colour while the textarea stays the form field.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add mention
```
## Usage
```blade
{{-- Type @ and a few letters. Rows carry avatar (or initials), handle, title and
presence; inserted mentions are painted in the textarea. --}}
@php
$people = [
['id' => 1, 'label' => 'Ada Lovelace', 'handle' => 'ada', 'description' => 'Engineering', 'status' => 'online'],
['id' => 2, 'label' => 'Alan Turing', 'handle' => 'alan', 'description' => 'Research', 'status' => 'away'],
['id' => 3, 'label' => 'Grace Hopper', 'handle' => 'grace', 'description' => 'Platform', 'status' => 'busy'],
['id' => 4, 'label' => 'Linus Torvalds', 'handle' => 'linus', 'description' => 'Kernel', 'status' => 'offline'],
['id' => 5, 'label' => 'Margaret Hamilton', 'handle' => 'margaret', 'description' => 'Flight software'],
];
@endphp
<x-ui.mention name="comment" :items="$people" value="Thanks @Ada Lovelace for the review — @grace can you take a look too?" class="w-96" />
```
## Props
- `name` (mixed|null, default `null`) — Form field name for the underlying textarea.
- `value` (string, default ``) — Initial textarea text, including any already-inserted mentions.
- `items` (array, default `[]`) — People to mention: strings, or objects with id, label, handle, avatar, description and status.
- `trigger` (string, default `@`) — Character that opens the picker when typed.
- `placeholder` (string, default `Type @ to mention…`) — Placeholder text shown while the textarea is empty.
- `rows` (int, default `3`) — Visible textarea row count.
- `avatars` (mixed|null, default `null`) — Shows the avatar column; defaults on automatically when any item carries an avatar or description.
- `highlight` (bool, default `true`) — Paints inserted mentions in the info colour inside the textarea via a mirrored backdrop.
## Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- Building a chat, comment or note composer where typing @ should open a filterable people picker.
- Needing inserted mentions painted in colour inside a plain-text field the server still receives as a textarea value.
## Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- Selecting from a small fixed option list with no trigger character; use combobox or select-advanced instead.
- A plain multi-line field with no mention picker; use textarea instead.
## Anti-patterns
- Using freeform entry for a bounded answer set
## Rules
- Use the `<brok:mention>` tag (or `<x-ui.mention>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/mention
- Registry JSON (files, props, contract): https://brokui.dev/r/open/mention.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | mixed | null | null | Form field name for the underlying textarea. |
| value | string | Initial textarea text, including any already-inserted mentions. | |
| items | array | [] | People to mention: strings, or objects with id, label, handle, avatar, description and status. |
| trigger | string | @ | Character that opens the picker when typed. |
| placeholder | string | Type @ to mention… | Placeholder text shown while the textarea is empty. |
| rows | int | 3 | Visible textarea row count. |
| avatars | mixed | null | null | Shows the avatar column; defaults on automatically when any item carries an avatar or description. |
| highlight | bool | true | Paints inserted mentions in the info colour inside the textarea via a mirrored backdrop. |
Slots
default— Unused; the picker renders from the items prop only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Typing the trigger character right before the caret opens the list; the text after it filters by name or handle.
- ArrowDown/Up move the active option, Enter or Tab insert the mention and a trailing space, Escape closes the list.
- A mirrored backdrop behind the transparent-text textarea paints each known mention in colour, since a textarea cannot style its own text.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Collect unpredictable freeform information.
Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- Building a chat, comment or note composer where typing @ should open a filterable people picker.
- Needing inserted mentions painted in colour inside a plain-text field the server still receives as a textarea value.
Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- Selecting from a small fixed option list with no trigger character; use combobox or select-advanced instead.
- A plain multi-line field with no mention picker; use textarea instead.
Use instead
- Radio or checkbox for bounded choices
- Select or combobox for known options
Anti-patterns
- Using freeform entry for a bounded answer set
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Tab Enter Escape ArrowUp ArrowDown
- Focus
managed
- The textarea carries role="combobox" with aria-expanded and aria-controls pointing at the listbox, so assistive technology tracks the open list.
- Presence status on each row is conveyed by both colour and an accessible status word, not colour alone.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="mention-{{ $record->id }}">
{{-- Type @ and a few letters. Rows carry avatar (or initials), handle, title and
presence; inserted mentions are painted in the textarea. --}}
@php
$people = [
['id' => 1, 'label' => 'Ada Lovelace', 'handle' => 'ada', 'description' => 'Engineering', 'status' => 'online'],
['id' => 2, 'label' => 'Alan Turing', 'handle' => 'alan', 'description' => 'Research', 'status' => 'away'],
['id' => 3, 'label' => 'Grace Hopper', 'handle' => 'grace', 'description' => 'Platform', 'status' => 'busy'],
['id' => 4, 'label' => 'Linus Torvalds', 'handle' => 'linus', 'description' => 'Kernel', 'status' => 'offline'],
['id' => 5, 'label' => 'Margaret Hamilton', 'handle' => 'margaret', 'description' => 'Flight software'],
];
@endphp
<x-ui.mention name="comment" :items="$people" value="Thanks @Ada Lovelace for the review — @grace can you take a look too?" class="w-96" />
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:mention
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Props:
- items: people to mention. A string, or ['id', 'label', 'handle', 'avatar',
'description', 'status'] — description is a title or team shown beside the
name; status is online|away|busy|offline for the presence dot.
- avatars: show the avatar column (image, or initials). Defaults to on when
any item carries an avatar or a description.
- highlight: paint inserted mentions in the info colour inside the textarea.
--}}
@props([
'name' => null,
'value' => '',
'items' => [],
'trigger' => '@',
'placeholder' => 'Type @ to mention…',
'rows' => 3,
'avatars' => null,
'highlight' => true,
])
@php
// Normalise items to ['id', 'label', 'handle', 'avatar', 'description', 'status'].
$normalisedItems = collect($items)->map(function ($item) {
if (is_array($item)) {
$label = (string) ($item['label'] ?? ($item['id'] ?? ''));
return [
'id' => (string) ($item['id'] ?? $label),
'label' => $label,
'handle' => (string) ($item['handle'] ?? \Illuminate\Support\Str::slug($label)),
'avatar' => $item['avatar'] ?? null,
'description' => $item['description'] ?? null,
'status' => in_array($item['status'] ?? null, ['online', 'away', 'busy', 'offline'], true) ? $item['status'] : null,
];
}
return ['id' => (string) $item, 'label' => (string) $item, 'handle' => \Illuminate\Support\Str::slug((string) $item), 'avatar' => null, 'description' => null, 'status' => null];
})->values()->all();
$showAvatars = $avatars ?? collect($normalisedItems)->contains(fn ($i) => filled($i['avatar']) || filled($i['description']));
// The backdrop mirrors the textarea's box exactly (same padding, font and
// wrapping) so painted mentions sit under the glyphs the user types.
$textBox = 'p-2 text-sm leading-normal whitespace-pre-wrap break-words';
@endphp
{{--
Mention — an @-triggered people picker inside a textarea, the way a chat
composer does it. Typing the trigger right before the caret opens the list,
the text after it filters by name or handle, ArrowDown/Up move, Enter/Tab
insert `@Name `, Escape closes. The textarea stays the form field; a mirrored
backdrop behind it paints each known mention in colour (a textarea cannot
style its own text), and the textarea's text is transparent over it.
--}}
<div
x-data="uiMention({ items: @js($normalisedItems), value: @js($value), trigger: @js($trigger), highlight: @js((bool) $highlight), avatars: @js((bool) $showAvatars) })"
x-id="['mention-list']"
data-slot="mention"
@keydown.escape="if (open) { $event.stopPropagation(); close() }"
@click.outside="close()"
{{ $attributes->merge(['class' => 'relative w-full']) }}
>
<div class="relative rounded-md border border-input bg-background shadow-xs transition-colors focus-within:ring-2 focus-within:ring-ring motion-reduce:transition-none">
@if ($highlight)
<div
data-slot="mention-backdrop"
x-ref="backdrop"
aria-hidden="true"
x-html="highlighted"
class="pointer-events-none absolute inset-0 overflow-hidden text-foreground {{ $textBox }}"
></div>
@endif
<textarea
@if ($name) name="{{ $name }}" @endif
data-slot="mention-input"
x-ref="input"
x-model="text"
@input="onInput($event)"
@keydown="onKey($event)"
@scroll="syncScroll()"
rows="{{ $rows }}"
role="combobox"
aria-autocomplete="list"
:aria-expanded="open"
x-bind:aria-controls="$id('mention-list')"
placeholder="{{ $placeholder }}"
autocomplete="off"
class="relative block w-full resize-y rounded-md border-0 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 {{ $textBox }} {{ $highlight ? 'text-transparent caret-foreground' : 'text-foreground' }}"
></textarea>
</div>
<ul
data-slot="mention-list"
role="listbox"
x-bind:id="$id('mention-list')"
x-show="open"
x-cloak
x-transition:enter="motion-safe:transition motion-safe:duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="absolute z-dropdown mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
>
<template x-for="(item, i) in filtered()" :key="item.id">
<li
data-slot="mention-item"
role="option"
:aria-selected="activeIndex === i"
@click="select(item)"
@mouseenter="activeIndex = i"
:class="activeIndex === i ? 'bg-accent text-accent-foreground' : ''"
class="flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-2 text-sm outline-none"
>
<template x-if="avatars">
<span class="relative inline-flex size-6 shrink-0 items-center justify-center overflow-visible rounded-full bg-muted text-2xs font-medium text-muted-foreground" data-slot="mention-avatar" aria-hidden="true">
<template x-if="item.avatar">
<img :src="item.avatar" alt="" class="size-full rounded-full object-cover" />
</template>
<template x-if="!item.avatar">
<span x-text="initials(item.label)"></span>
</template>
<template x-if="item.status">
<span
data-slot="mention-status"
class="absolute -end-0.5 -bottom-0.5 size-2 rounded-full ring-2 ring-popover"
:class="{ 'bg-success': item.status === 'online', 'bg-warning': item.status === 'away', 'bg-destructive': item.status === 'busy', 'bg-muted-foreground/40': item.status === 'offline' }"
></span>
</template>
</span>
</template>
<span class="flex min-w-0 flex-1 items-baseline gap-2">
<span class="truncate font-medium" x-text="item.label"></span>
<span class="truncate text-xs text-muted-foreground" x-text="trigger + item.handle"></span>
</span>
<template x-if="item.description">
<span class="ms-auto max-w-[45%] shrink-0 truncate text-xs text-muted-foreground" x-text="item.description"></span>
</template>
</li>
</template>
<li
x-show="filtered().length === 0"
data-slot="mention-empty"
class="px-2 py-6 text-center text-sm text-muted-foreground"
>{{ __('No results found.') }}</li>
</ul>
</div>
/**
* Mention behavior.
*
* An `@`-triggered people picker inside a textarea. Typing the trigger character
* (default `@`) immediately before the caret — with no whitespace between it and
* the caret — opens the list; the text after the trigger filters by name or
* handle. Picking an item replaces the `@query` slice with `@Name ` and closes
* the list. Every known `@Name` in the text is painted on a backdrop mirrored
* behind the (transparent-text) textarea, so mentions read in colour while the
* textarea stays an ordinary form field. Picking also dispatches
* `mention-select` with the item, for a host that keeps mention ids.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiMention', (config = {}) => ({
open: false,
query: '',
activeIndex: 0,
trigger: config.trigger ?? '@',
highlight: config.highlight ?? true,
avatars: Boolean(config.avatars),
items: (config.items ?? []).map((i) =>
typeof i === 'string'
? { id: i, label: i, handle: i.toLowerCase().replace(/\s+/g, '-'), avatar: null, description: null, status: null }
: {
id: i.id ?? i.label,
label: i.label,
handle: i.handle ?? String(i.label).toLowerCase().replace(/\s+/g, '-'),
avatar: i.avatar ?? null,
description: i.description ?? null,
status: i.status ?? null,
},
),
text: config.value ?? '',
_triggerPos: -1,
/** Known mentions (names and handles), longest first so "Ada Lovelace" wins over "Ada". */
get mentionPattern() {
const names = [...new Set(this.items.flatMap((i) => [i.label, i.handle]).filter(Boolean))]
.sort((a, b) => b.length - a.length)
.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
if (names.length === 0) return null;
const trigger = this.trigger.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(^|[\\s(])(${trigger}(?:${names.join('|')}))(?=$|[\\s.,;:!?)])`, 'g');
},
/** The text, HTML-escaped, with each known mention wrapped for the backdrop. */
get highlighted() {
const escaped = this.text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
const pattern = this.mentionPattern;
const painted = pattern
? escaped.replace(pattern, '$1<span data-slot="mention-token" class="rounded-sm bg-info-soft text-info-text ring-[3px] ring-info-soft">$2</span>')
: escaped;
// A trailing newline needs a glyph after it to keep the last line's height.
return painted.endsWith('\n') ? painted + '' : painted;
},
/** Ids of the people currently mentioned in the text. */
get mentioned() {
const pattern = this.mentionPattern;
if (!pattern) return [];
const found = new Set();
for (const match of this.text.matchAll(pattern)) {
const token = match[2].slice(this.trigger.length);
const item = this.items.find((i) => i.label === token || i.handle === token);
if (item) found.add(item.id);
}
return [...found];
},
syncScroll() {
if (this.$refs.backdrop) this.$refs.backdrop.scrollTop = this.$refs.input.scrollTop;
},
initials(name) {
return (name || '')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
},
onInput(e) {
const el = e.target;
const caret = el.selectionStart ?? this.text.length;
const before = this.text.slice(0, caret);
const at = before.lastIndexOf(this.trigger);
if (at === -1) {
this.close();
return;
}
// The slice between the trigger char and the caret is the query; it may
// hold one space (so "@ada lo" still matches a full name) and the trigger
// must start a word (start of text or preceded by whitespace).
const slice = before.slice(at + this.trigger.length);
const preceding = at === 0 ? '' : before[at - 1];
if (/\s\s|\n/.test(slice) || (slice.match(/\s/g) || []).length > 1 || (preceding !== '' && !/\s/.test(preceding))) {
this.close();
return;
}
this._triggerPos = at;
this.query = slice;
this.activeIndex = 0;
this.open = true;
},
filtered() {
const q = this.query.toLowerCase().trim();
return this.items.filter((i) => i.label.toLowerCase().includes(q) || i.handle.toLowerCase().includes(q));
},
close() {
this.open = false;
this.query = '';
this._triggerPos = -1;
},
onKey(e) {
if (!this.open) {
return;
}
const results = this.filtered();
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (results.length) {
this.activeIndex = (this.activeIndex + 1) % results.length;
}
break;
case 'ArrowUp':
e.preventDefault();
if (results.length) {
this.activeIndex = (this.activeIndex - 1 + results.length) % results.length;
}
break;
case 'Enter':
case 'Tab':
if (results.length) {
e.preventDefault();
this.select(results[this.activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
this.close();
break;
}
},
select(item) {
if (!item || this._triggerPos === -1) {
this.close();
return;
}
const el = this.$refs.input;
const caret = el?.selectionStart ?? this.text.length;
const start = this._triggerPos;
const insertion = this.trigger + item.label + ' ';
this.text = this.text.slice(0, start) + insertion + this.text.slice(caret);
this.close();
this.$dispatch('mention-select', { id: item.id, label: item.label, handle: item.handle, mentioned: this.mentioned });
this.$nextTick(() => {
if (el) {
const pos = start + insertion.length;
el.focus();
el.setSelectionRange(pos, pos);
}
});
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0