Email Compose Card
A working email compose card: recipient, Cc and Bcc chips (Enter, comma, paste; addresses only), subject, message with emoji insertion at the caret, attachments by button or drop with size chips, Ctrl/Cmd+Enter to send, minimise and discard — posting a real form or dispatching compose-send.
Preview
{{-- Type an address and press Enter, open Cc, drop a file on the card, pick an
emoji, then Ctrl/Cmd+Enter. With no `action` the card dispatches
`compose-send` instead of posting. --}}
<div class="mx-auto w-full max-w-md">
<x-ui.email-compose-card
heading="New message"
from-name="You"
:recipients="['design@team.co', 'aria@team.co']"
subject="Onboarding revamp — final review"
/>
</div>
Installation
php artisan ui:add email-compose-card
Note
This component ships an Alpine behavior module at
resources/js/ui/email-compose-card.js. Import it once from your bundle so it registers on alpine:init:
import './email-compose-card.js';
Registry contract
php artisan ui:add email-compose-card
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/email-compose-card.blade.php -
resources/js/ui/email-compose-card.js
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: Email Compose Card (`email-compose-card`)
A working email compose card: recipient, Cc and Bcc chips (Enter, comma, paste; addresses only), subject, message with emoji insertion at the caret, attachments by button or drop with size chips, Ctrl/Cmd+Enter to send, minimise and discard — posting a real form or dispatching compose-send.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add email-compose-card
```
## Usage
```blade
{{-- Type an address and press Enter, open Cc, drop a file on the card, pick an
emoji, then Ctrl/Cmd+Enter. With no `action` the card dispatches
`compose-send` instead of posting. --}}
<div class="mx-auto w-full max-w-md">
<x-ui.email-compose-card
heading="New message"
from-name="You"
:recipients="['[email protected]', '[email protected]']"
subject="Onboarding revamp — final review"
/>
</div>
```
## Props
- `heading` (string, default `New message`) — Title shown at the top of the card.
- `fromName` (string, default `You`) — Sender name shown beside the avatar.
- `fromAvatar` (mixed|null, default `null`) — Sender avatar image URL.
- `action` (mixed|null, default `null`) — Form action URL for a real server-side submission.
- `method` (string, default `POST`) — Form HTTP method.
- `recipients` (array, default `[]`) — Initial To recipients, rendered as chips.
- `cc` (array, default `[]`) — Initial Cc recipients, rendered as chips.
- `bcc` (array, default `[]`) — Initial Bcc recipients, rendered as chips.
- `subject` (string, default ``) — Initial subject line.
- `body` (string, default ``) — Initial message body.
- `placeholder` (string, default `Write your message…`) — Placeholder text for the message body.
- `sendLabel` (string, default `Send`) — Label for the send button.
- `accept` (mixed|null, default `null`) — Accepted file types for attachments, as a native file input accept value.
- `maxSize` (int, default `25`) — Maximum attachment size in megabytes.
## Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- Building a full email compose UI, such as a support inbox or webmail-style app feature.
- Needing a working form with recipient chips, attachments, and an emoji picker, not just a subject/body textarea.
## Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- Only a simple contact or feedback form is needed; a plain form with input/textarea is lighter weight.
- The message is a single short reply, not a full compose surface with Cc/Bcc and attachments; use a simpler comment or reply form.
## Anti-patterns
- Using freeform entry for a bounded answer set
## Rules
- Use the `<brok:email-compose-card>` tag (or `<x-ui.email-compose-card>`) 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/email-compose-card
- Registry JSON (files, props, contract): https://brokui.dev/r/open/email-compose-card.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 |
|---|---|---|---|
| heading | string | New message | Title shown at the top of the card. |
| fromName | string | You | Sender name shown beside the avatar. |
| fromAvatar | mixed | null | null | Sender avatar image URL. |
| action | mixed | null | null | Form action URL for a real server-side submission. |
| method | string | POST | Form HTTP method. |
| recipients | array | [] | Initial To recipients, rendered as chips. |
| cc | array | [] | Initial Cc recipients, rendered as chips. |
| bcc | array | [] | Initial Bcc recipients, rendered as chips. |
| subject | string | Initial subject line. | |
| body | string | Initial message body. | |
| placeholder | string | Write your message… | Placeholder text for the message body. |
| sendLabel | string | Send | Label for the send button. |
| accept | mixed | null | null | Accepted file types for attachments, as a native file input accept value. |
| maxSize | int | 25 | Maximum attachment size in megabytes. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Recipient, Cc, and Bcc fields turn typed addresses into chips on Enter, comma, or paste, accepting only valid-looking addresses.
- The message body supports emoji insertion at the caret position via an emoji picker popover.
- Attachments can be added by button or drag-and-drop, each shown as a chip with its file size; Ctrl/Cmd+Enter sends the message.
- Sending either posts a real form to action, or, without one, dispatches a compose-send event with the message data for the host to handle.
- Minimise and discard actions let the user collapse the card or abandon the draft.
- 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 full email compose UI, such as a support inbox or webmail-style app feature.
- Needing a working form with recipient chips, attachments, and an emoji picker, not just a subject/body textarea.
Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- Only a simple contact or feedback form is needed; a plain form with input/textarea is lighter weight.
- The message is a single short reply, not a full compose surface with Cc/Bcc and attachments; use a simpler comment or reply form.
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
- Enter
- Focus
managed
- Recipient chip fields are built on tags-input, so removing a chip and typing a new address both work from the keyboard alone.
- The emoji picker and attachment controls are real buttons with accessible labels, not bare icon-only hit targets.
- 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="email-compose-card-{{ $record->id }}">
{{-- Type an address and press Enter, open Cc, drop a file on the card, pick an
emoji, then Ctrl/Cmd+Enter. With no `action` the card dispatches
`compose-send` instead of posting. --}}
<div class="mx-auto w-full max-w-md">
<x-ui.email-compose-card
heading="New message"
from-name="You"
:recipients="['design@team.co', 'aria@team.co']"
subject="Onboarding revamp — final review"
/>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:email-compose-card
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:
- action / method: post the draft to your endpoint. With no action the card
dispatches `compose-send` (to, cc, bcc, subject, body, files) instead of
submitting, for a Livewire or fetch host.
- recipients / cc / bcc: initial addresses; Cc and Bcc rows open when given
or from the links at the end of the To row.
- accept / maxSize: attachment types and per-file size cap (MB).
- default slot: extra rows between the message and the attachments (a
signature picker, a schedule-send control, tracking options).
--}}
@props([
'heading' => 'New message',
'fromName' => 'You',
'fromAvatar' => null,
'action' => null,
'method' => 'POST',
'recipients' => [],
'cc' => [],
'bcc' => [],
'subject' => '',
'body' => '',
'placeholder' => 'Write your message…',
'sendLabel' => 'Send',
'accept' => null,
'maxSize' => 25,
])
@php
$recipients = array_values(array_filter(is_array($recipients) ? $recipients : [$recipients]));
$cc = array_values(array_filter(is_array($cc) ? $cc : [$cc]));
$bcc = array_values(array_filter(is_array($bcc) ? $bcc : [$bcc]));
$id = 'compose-'.\Illuminate\Support\Str::random(6);
$emailPattern = '^[^\s@]+@[^\s@]+\.[^\s@]+$';
@endphp
{{--
Email compose card — a working draft surface. Recipients are chips (Enter,
comma or paste adds one; Backspace or × removes; a non-address is refused),
Cc/Bcc rows fold out, the emoji button opens the picker and inserts at the
caret, the attach button (or a drop onto the card) lists files with size and
a remove control, Ctrl/Cmd+Enter sends, Minimise folds the body away, and
Discard clears the draft. Composes form, tags-input, input, textarea,
button, popover, emoji-picker and avatar.
--}}
<x-ui.form
data-slot="email-compose-card"
:action="$action"
:method="$method"
has-files
x-data="uiEmailComposeCard({{ Js::from(['hasAction' => filled($action), 'showCc' => $cc !== [], 'showBcc' => $bcc !== [], 'maxSize' => (int) $maxSize, 'tooLarge' => __('is larger than :size MB and was not attached.', ['size' => (int) $maxSize])]) }})"
x-on:submit="onSubmit($event)"
x-on:keydown.meta.enter.prevent="$el.requestSubmit()"
x-on:keydown.ctrl.enter.prevent="$el.requestSubmit()"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.self="dragging = false"
x-on:drop.prevent="addDropped($event)"
x-on:emoji-select="insertEmoji($event.detail.emoji)"
::data-dragging="dragging ? 'true' : null"
{{ $attributes->merge(['class' => 'flex w-full max-w-md flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-lg data-[dragging=true]:ring-2 data-[dragging=true]:ring-ring']) }}
>
<header class="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
<x-ui.avatar size="xs" :src="$fromAvatar" :alt="$fromName" :fallback="$fromName" class="shrink-0" />
<h3 class="flex-1 truncate text-sm font-semibold">{{ __($heading) }}</h3>
<x-ui.button variant="ghost" size="sm" icon-label="{{ __('Minimise') }}" x-on:click="minimised = !minimised" ::aria-expanded="(!minimised).toString()" aria-controls="{{ $id }}-body-region" class="-my-1 text-muted-foreground">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" x-show="!minimised"><path d="M5 12h14" /></svg>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" x-show="minimised" x-cloak><path d="m6 15 6-6 6 6" /></svg>
</x-ui.button>
<x-ui.button variant="ghost" size="sm" icon-label="{{ __('Discard draft') }}" x-on:click="discard()" class="-my-1 text-muted-foreground">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M18 6 6 18M6 6l12 12" /></svg>
</x-ui.button>
</header>
<div id="{{ $id }}-body-region" x-show="!minimised" x-transition:enter="motion-safe:transition motion-safe:duration-100" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="flex flex-col">
<div class="flex flex-col gap-3 p-4">
<div>
<div class="mb-1 flex items-baseline justify-between">
<label for="{{ $id }}-to" class="block text-xs font-medium text-muted-foreground">{{ __('To') }}</label>
<span class="flex gap-2 text-xs text-muted-foreground">
<button type="button" x-show="!showCc" x-on:click="showCc = true; $nextTick(() => $refs.cc?.querySelector('input:not([type=hidden])')?.focus())" class="rounded-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{{ __('Cc') }}</button>
<button type="button" x-show="!showBcc" x-on:click="showBcc = true; $nextTick(() => $refs.bcc?.querySelector('input:not([type=hidden])')?.focus())" class="rounded-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{{ __('Bcc') }}</button>
</span>
</div>
<x-ui.tags-input name="to" :value="$recipients" :pattern="$emailPattern" inputmode="email" placeholder="{{ __('Add recipient…') }}" x-ref="to" />
</div>
<div x-show="showCc" x-cloak x-ref="cc" @if ($cc === []) style="display:none" @endif>
<label for="{{ $id }}-cc" class="mb-1 block text-xs font-medium text-muted-foreground">{{ __('Cc') }}</label>
<x-ui.tags-input name="cc" :value="$cc" :pattern="$emailPattern" inputmode="email" placeholder="{{ __('Add Cc…') }}" />
</div>
<div x-show="showBcc" x-cloak x-ref="bcc" @if ($bcc === []) style="display:none" @endif>
<label for="{{ $id }}-bcc" class="mb-1 block text-xs font-medium text-muted-foreground">{{ __('Bcc') }}</label>
<x-ui.tags-input name="bcc" :value="$bcc" :pattern="$emailPattern" inputmode="email" placeholder="{{ __('Add Bcc…') }}" />
</div>
<div>
<label for="{{ $id }}-subject" class="mb-1 block text-xs font-medium text-muted-foreground">{{ __('Subject') }}</label>
<x-ui.input id="{{ $id }}-subject" name="subject" type="text" :value="$subject" placeholder="{{ __('Subject') }}" />
</div>
<div>
<label for="{{ $id }}-body" class="sr-only">{{ __('Message') }}</label>
<x-ui.textarea id="{{ $id }}-body" name="body" :rows="6" placeholder="{{ __($placeholder) }}" x-ref="body">{{ $body }}</x-ui.textarea>
</div>
{{ $slot }}
{{-- Attachments --}}
<input type="file" name="attachments[]" multiple x-ref="files" @if ($accept) accept="{{ $accept }}" @endif class="sr-only" tabindex="-1" aria-hidden="true" x-on:change="syncFiles()" />
<ul x-show="files.length" x-cloak data-slot="email-compose-card-attachments" class="flex flex-wrap gap-2" aria-label="{{ __('Attachments') }}">
<template x-for="(file, i) in files" :key="file.name + file.size">
<li class="inline-flex max-w-full items-center gap-1 rounded-sm border border-border bg-muted px-2 py-1 text-xs text-foreground">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3 shrink-0 text-muted-foreground"><path d="M21.4 11.05 12.2 20.3a5 5 0 0 1-7.1-7.1l9.2-9.2a3.3 3.3 0 1 1 4.7 4.7l-9.2 9.2a1.7 1.7 0 0 1-2.4-2.4l8.5-8.5" /></svg>
<span class="truncate" x-text="file.name"></span>
<span class="shrink-0 text-muted-foreground" x-text="formatSize(file.size)"></span>
<button type="button" x-on:click="removeFile(i)" :aria-label="'{{ __('Remove') }} ' + file.name" class="ms-1 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</li>
</template>
</ul>
<p x-show="notice" x-cloak x-text="notice" role="status" aria-live="polite" class="text-xs text-destructive"></p>
</div>
<footer class="flex items-center justify-between gap-2 border-t border-border px-4 py-3">
<x-ui.button type="submit" size="sm" with-arrow>{{ __($sendLabel) }}</x-ui.button>
<div class="flex items-center gap-1 text-muted-foreground">
<x-ui.button variant="ghost" size="sm" icon-label="{{ __('Attach file') }}" x-on:click="$refs.files.click()" class="text-muted-foreground">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M21.4 11.05 12.2 20.3a5 5 0 0 1-7.1-7.1l9.2-9.2a3.3 3.3 0 1 1 4.7 4.7l-9.2 9.2a1.7 1.7 0 0 1-2.4-2.4l8.5-8.5" /></svg>
</x-ui.button>
<x-ui.popover>
<x-ui.popover.trigger class="size-control-h-sm rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none" aria-label="{{ __('Insert emoji') }}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><circle cx="12" cy="12" r="9" /><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01" /></svg>
</x-ui.popover.trigger>
<x-ui.popover.content side="top" align="end" class="p-0">
<x-ui.emoji-picker class="border-0 shadow-none" />
</x-ui.popover.content>
</x-ui.popover>
</div>
</footer>
</div>
</x-ui.form>
/**
* Email compose card behaviour.
*
* The chips, the emoji grid and the popover are their own primitives; this
* layer wires the draft together: Cc/Bcc rows, attachments held in the real
* file input (files chosen, dropped, or removed are rebuilt into it so a plain
* form post carries them), emoji inserted at the textarea caret, Minimise,
* Discard, and a `compose-send` event for a host that does not post the form.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiEmailComposeCard', (config = {}) => ({
hasAction: Boolean(config.hasAction),
showCc: Boolean(config.showCc),
showBcc: Boolean(config.showBcc),
maxSize: (config.maxSize ?? 25) * 1024 * 1024,
tooLarge: config.tooLarge ?? 'is too large and was not attached.',
minimised: false,
dragging: false,
files: [],
notice: '',
/** Mirror the chosen files into state, dropping any over the size cap. */
syncFiles() {
this.addFiles([...(this.$refs.files?.files ?? [])], true);
},
addDropped(event) {
this.dragging = false;
this.addFiles([...(event.dataTransfer?.files ?? [])]);
},
addFiles(incoming, replace = false) {
const kept = replace ? [] : [...this.files];
const refused = [];
for (const file of incoming) {
if (file.size > this.maxSize) {
refused.push(file.name);
continue;
}
if (!kept.some((f) => f.name === file.name && f.size === file.size)) kept.push(file);
}
this.files = kept;
this.notice = refused.length ? `${refused.join(', ')} ${this.tooLarge}` : '';
this.writeFiles();
},
removeFile(index) {
this.files.splice(index, 1);
this.writeFiles();
},
/** Rebuild the native input's FileList so the form submits exactly what is listed. */
writeFiles() {
const input = this.$refs.files;
if (!input || typeof DataTransfer === 'undefined') return;
const transfer = new DataTransfer();
for (const file of this.files) transfer.items.add(file);
input.files = transfer.files;
},
formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
},
insertEmoji(emoji) {
const area = this.$refs.body;
if (!area || !emoji) return;
const start = area.selectionStart ?? area.value.length;
const end = area.selectionEnd ?? start;
area.setRangeText(emoji, start, end, 'end');
area.dispatchEvent(new Event('input', { bubbles: true }));
area.focus();
},
payload() {
const form = this.$root;
const values = (name) => [...form.querySelectorAll(`input[type="hidden"][name="${name}[]"]`)].map((el) => el.value);
return {
to: values('to'),
cc: values('cc'),
bcc: values('bcc'),
subject: form.querySelector('[name="subject"]')?.value ?? '',
body: this.$refs.body?.value ?? '',
files: this.files,
};
},
onSubmit(event) {
if (this.hasAction) return;
event.preventDefault();
this.$dispatch('compose-send', this.payload());
},
discard() {
this.$dispatch('compose-discard', this.payload());
this.$root.reset();
this.files = [];
this.notice = '';
this.writeFiles();
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.2.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