Skip to content
Brok UI

Loading…

No results

Mention

Open source

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.

Version
v1.1.3
Stability
stable
License
MIT
Related
Combobox
Advanced Select
Textarea

Preview

  • No results found.
previews.components.mention.default.blade.php 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" />

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/mention.blade.php
  • js resources/js/ui/mention.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

mention.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

mention mention-avatar mention-backdrop mention-empty mention-input mention-item mention-list mention-status

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

Freeform text input

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
root mention-backdrop mention-input mention-list mention-item mention-avatar mention-status mention-empty
Theming hooks
mention mention-avatar mention-backdrop mention-empty mention-input mention-item mention-list mention-status

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/mention.blade.php Blade
{{--
    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>
resources/js/ui/mention.js JS
/**
 * 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, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;');
            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