Chat Bubble
A chat bubble for a single sender turn with an optional avatar and name label; supports user, other, and system roles.
Preview
<div class="mx-auto flex w-full max-w-md flex-col gap-4">
<x-ui.chat-bubble role="user" name="You">Is the Q3 launch still on track?</x-ui.chat-bubble>
<x-ui.chat-bubble role="other" name="Support">Yes — the rollout is staged behind a flag and we are on schedule.</x-ui.chat-bubble>
{{-- An assistant reply arriving: the text streams in once, with a caret. --}}
<x-ui.chat-bubble role="other" name="Assistant" stream>The billing revamp ships first, then the new onboarding. Both are behind flags, so we can revert in one click.</x-ui.chat-bubble>
{{-- The other side is typing. --}}
<x-ui.chat-bubble role="other" name="Support" typing />
<x-ui.chat-bubble role="system">Conversation summarised to save space.</x-ui.chat-bubble>
</div>
Installation
php artisan ui:add chat-bubble
Registry contract
php artisan ui:add chat-bubble
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/chat-bubble.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2
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: Chat Bubble (`chat-bubble`)
A chat bubble for a single sender turn with an optional avatar and name label; supports user, other, and system roles.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add chat-bubble
```
## Usage
```blade
<div class="mx-auto flex w-full max-w-md flex-col gap-4">
<x-ui.chat-bubble role="user" name="You">Is the Q3 launch still on track?</x-ui.chat-bubble>
<x-ui.chat-bubble role="other" name="Support">Yes — the rollout is staged behind a flag and we are on schedule.</x-ui.chat-bubble>
{{-- An assistant reply arriving: the text streams in once, with a caret. --}}
<x-ui.chat-bubble role="other" name="Assistant" stream>The billing revamp ships first, then the new onboarding. Both are behind flags, so we can revert in one click.</x-ui.chat-bubble>
{{-- The other side is typing. --}}
<x-ui.chat-bubble role="other" name="Support" typing />
<x-ui.chat-bubble role="system">Conversation summarised to save space.</x-ui.chat-bubble>
</div>
```
## Props
- `role` (string, default `user`)
- `name` (mixed|null, default `null`)
- `avatar` (mixed|null, default `null`)
- `typing` (bool, default `false`)
- `stream` (bool, default `false`)
## Use when
- Use to group related content into scannable units for browsing, prioritization, or rearrangement.
- Rendering one turn in a conversation, aligned and coloured by who sent it.
- Showing that the other party is typing, or streaming a reply in as it arrives.
## Avoid when
- Do not use card-like structures when dense row-by-row comparison would be clearer in a list or table.
- Laying out a full conversation; chat-thread stacks the bubbles.
- Displaying a notification or toast; those have their own primitives.
## Anti-patterns
- Turning every object into a card
## Rules
- Use the `<brok:chat-bubble>` tag (or `<x-ui.chat-bubble>`) 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/chat-bubble
- Registry JSON (files, props, contract): https://brokui.dev/r/open/chat-bubble.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 |
|---|---|---|---|
| role | string | user | |
| name | mixed | null | null | |
| avatar | mixed | null | null | |
| typing | bool | false | |
| stream | bool | false |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- role selects user, other or system alignment and colour, and name and avatar render beside the bubble.
- typing replaces the content with an animated indicator; stream reveals the content progressively where motion is allowed.
- Normalizes unknown roles to user, mirrors user/other alignment under RTL, and renders system turns as centered notices.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Organize browsable objects into scannable units.
Use when
- Use to group related content into scannable units for browsing, prioritization, or rearrangement.
- Rendering one turn in a conversation, aligned and coloured by who sent it.
- Showing that the other party is typing, or streaming a reply in as it arrives.
Avoid when
- Do not use card-like structures when dense row-by-row comparison would be clearer in a list or table.
- Laying out a full conversation; chat-thread stacks the bubbles.
- Displaying a notification or toast; those have their own primitives.
Use instead
- Table for exact comparison
- List for compact scanning
Anti-patterns
- Turning every object into a card
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The typing indicator is a role="status" region with a translated name, so it is announced rather than seen only.
- Motion on the typing and streaming states stops under prefers-reduced-motion.
- 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="chat-bubble-{{ $record->id }}">
<div class="mx-auto flex w-full max-w-md flex-col gap-4">
<x-ui.chat-bubble role="user" name="You">Is the Q3 launch still on track?</x-ui.chat-bubble>
<x-ui.chat-bubble role="other" name="Support">Yes — the rollout is staged behind a flag and we are on schedule.</x-ui.chat-bubble>
{{-- An assistant reply arriving: the text streams in once, with a caret. --}}
<x-ui.chat-bubble role="other" name="Assistant" stream>The billing revamp ships first, then the new onboarding. Both are behind flags, so we can revert in one click.</x-ui.chat-bubble>
{{-- The other side is typing. --}}
<x-ui.chat-bubble role="other" name="Support" typing />
<x-ui.chat-bubble role="system">Conversation summarised to save space.</x-ui.chat-bubble>
</div>
</div>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'role' => 'user',
'name' => null,
'avatar' => null,
// The sender is typing: the bubble shows three pulsing dots instead of text.
'typing' => false,
// Stream the text in once, a few characters at a time with a caret, the
// way an assistant reply arrives; reduced motion shows it at once.
'stream' => false,
])
@php
// Normalize role to one of the three supported variants.
$role = in_array($role, ['user', 'other', 'system'], true) ? $role : 'user';
$isUser = $role === 'user';
$isSystem = $role === 'system';
// Build initials for the avatar fallback from the provided name.
$initials = null;
if ($name) {
$parts = preg_split('/\s+/', trim((string) $name));
$initials = collect($parts)
->filter()
->take(2)
->map(fn ($p) => mb_strtoupper(mb_substr($p, 0, 1)))
->implode('');
}
// System turns render as a centered, subtle notice rather than a sided bubble.
if ($isSystem) {
$rowClasses = 'flex w-full justify-center';
} else {
// User turns flow from the end (mirrored in RTL); other senders from the start.
$rowClasses = 'flex w-full items-end gap-2 '.($isUser ? 'flex-row-reverse' : 'flex-row');
}
if ($isSystem) {
$bubbleClasses = 'max-w-[90%] rounded-full bg-accent px-3 py-1 text-center text-xs text-muted-foreground';
} elseif ($isUser) {
// Flatten the end (trailing) corner toward the avatar/edge.
$bubbleClasses = 'max-w-[80%] rounded-md rounded-ee-sm bg-primary px-4 py-2 text-sm text-primary-foreground';
} else {
// Flatten the start (leading) corner toward the avatar/edge.
$bubbleClasses = 'max-w-[80%] rounded-md rounded-es-sm bg-muted px-4 py-2 text-sm text-foreground';
}
@endphp
<div
data-slot="chat-bubble"
data-role="{{ $role }}"
{{ $attributes->merge(['class' => $rowClasses]) }}
>
@unless ($isSystem)
@if ($avatar || $initials)
<div
aria-hidden="true"
class="flex size-8 shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-accent text-xs font-medium text-muted-foreground"
>
@if ($avatar)
<img src="{{ $avatar }}" alt="" class="size-full object-cover" />
@else
{{ $initials }}
@endif
</div>
@endif
@endunless
<div class="flex min-w-0 flex-col gap-1 {{ $isUser ? 'items-end' : 'items-start' }}">
@if ($name && ! $isSystem)
<span class="px-1 text-xs font-medium text-muted-foreground">{{ $name }}</span>
@endif
@if ($typing)
<div class="{{ $bubbleClasses }}" data-slot="chat-bubble-typing" role="status" aria-label="{{ $name ? __(':name is typing', ['name' => $name]) : __('Typing') }}">
<span class="flex h-5 items-center gap-1" aria-hidden="true">
@foreach ([0, 160, 320] as $delay)
<span class="size-2 shrink-0 rounded-full bg-current opacity-50 motion-safe:animate-[ui-ai-typing-bounce_1.2s_ease-in-out_infinite]" style="animation-delay: {{ $delay }}ms"></span>
@endforeach
</span>
</div>
@elseif ($stream)
<div
class="{{ $bubbleClasses }}"
data-slot="chat-bubble-stream"
x-data="{
text: '',
shown: '',
done: false,
init() {
this.text = this.$refs.source.textContent.trim();
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.shown = this.text;
this.done = true;
return;
}
let i = 0;
const tick = () => {
i += 1 + Math.floor(Math.random() * 3);
this.shown = this.text.slice(0, i);
if (i >= this.text.length) {
this.done = true;
return;
}
setTimeout(tick, 28);
};
tick();
},
}"
>
{{-- The full text stays in the DOM for assistive tech; sighted
readers watch it arrive. --}}
<span x-ref="source" class="sr-only">{{ $slot }}</span>
<span aria-hidden="true" class="whitespace-pre-line" x-text="shown"></span><span x-show="!done" aria-hidden="true" class="ms-0.5 inline-block h-[1em] w-px translate-y-[0.15em] bg-current motion-safe:animate-[ui-caret-blink_1s_steps(2)_infinite]"></span>
</div>
@else
<div class="{{ $bubbleClasses }}">
{{ $slot }}
</div>
@endif
</div>
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
2.1.2 - 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