Skip to content
Brok UI

Loading…

No results

Email Compose Card

Open source

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.

Version
v1.2.2
Stability
stable
License
MIT
Related
Editor
Dropzone
Chat Thread

Preview

You You

New message

previews.components.email-compose-card.default.blade.php 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="['design@team.co', 'aria@team.co']"
        subject="Onboarding revamp — final review"
    />
</div>

Installation

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

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

  • blade resources/views/components/ui/email-compose-card.blade.php
  • js resources/js/ui/email-compose-card.js
Registry dependencies
form tags-input input textarea button avatar popover emoji-picker
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.

email-compose-card.md
# 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

manifest knowledge + registry-derived coverage

Props

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

email-compose-card email-compose-card-attachments

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

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 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
root email-compose-card-attachments
Theming hooks
email-compose-card email-compose-card-attachments

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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="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.

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

resources/views/components/ui/email-compose-card.blade.php Blade
{{--
    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>
resources/js/ui/email-compose-card.js JS
/**
 * 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