Composer
A rich-text editor for messages, replies and email drafts. Format content with the built-in toolbar, edit its HTML directly, and keep an HTML model and a plain-text copy in sync.
ComposerView source · API reference · Inline images
Interactive playground
Start with a release note or a support reply. Select a phrase and make it bold, turn a paragraph into a list, or use HTML source at the right of the toolbar to edit the markup. The model inspector shows the values your application receives.
Change the tone, variant and height below the editor. Choose Empty draft to try the placeholder. Disabling the editor locks user input; the preset buttons still demonstrate how the owning app can update its model.
<RegorApp id="composer-demo" src="./playground.ts"/> import {
type ComponentVariant,
createComposerBodyHtml,
defineBadgeComponents,
defineButtonComponents,
defineComposerComponents,
defineFlexComponents,
defineFormComponents,
defineFormInputField,
defineFormSelectField,
defineGridComponents,
defineIconComponents,
definePanelComponents,
defineTabsComponents,
type FormSelectOption,
} from '@purestack/ts-components'
import type { SemanticTone } from '@purestack/ts-style'
import {
lucide_bold,
lucide_chevron_down,
lucide_code,
lucide_eraser,
lucide_italic,
lucide_link,
lucide_list,
lucide_list_ordered,
lucide_underline,
tabler_align_center,
tabler_align_left,
tabler_align_right,
} from '@purestack/ts-svg-icons'
import {
type ComputedRef,
computed,
createApp,
defineComponent,
html,
type Ref,
ref,
} from 'regor'
export interface ComposerPlayground {
messageHtml: Ref<string>
messageText: Ref<string>
editorLabel: Ref<string>
editorPlaceholder: Ref<string>
disabled: Ref<boolean>
tone: Ref<SemanticTone>
variant: Ref<ComponentVariant>
height: Ref<string>
words: ComputedRef<number>
tones: FormSelectOption[]
variants: FormSelectOption[]
heights: FormSelectOption[]
release: () => void
reply: () => void
clear: () => void
reset: () => void
}
const releaseHtml = createComposerBodyHtml(
'<h2>Ready for your next release</h2><p>Build with <strong>typed components</strong>, a shared theme and a little less ceremony.</p><ul><li>Explore the interactive component guides.</li><li>Try both rich text and HTML source.</li><li>Share your feedback with the team.</li></ul><p><a href="https://purestack.studio/">Explore PureStack</a></p>',
)
const replyHtml = createComposerBodyHtml(
'<p>Hi Alex,</p><p>Thanks for trying the preview. Your feedback helped us improve <strong>keyboard navigation</strong> and the new component guides.</p><blockquote>Can we use the same theme across the whole app?</blockquote><p>Yes. Components inherit the active skin, so your interface stays consistent.</p><p>Best,<br>The PureStack team</p>',
)
const composerPlaygroundTemplate = html`<Flex direction="column">
<Flex justify="between" align="center" wrap="true">
<p class="text-eyebrow m-0">A draft, two live outputs</p>
<Badge tone="accent" variant="surface">{{ disabled ? 'Editing disabled' : 'Live editor' }}</Badge>
</Flex>
<Flex wrap="true">
<Btn variant="outline" size="sm" @click="release">Load release note</Btn>
<Btn variant="outline" size="sm" @click="reply">Load support reply</Btn>
<Btn variant="outline" size="sm" @click="clear">Empty draft</Btn>
</Flex>
<Composer id="composer-playground-editor" :html="messageHtml" :text="messageText"
:label="editorLabel" :placeholder="editorPlaceholder" :disabled="disabled"
:tone="tone" :variant="variant" :minHeight="height"/>
<Flex justify="between" wrap="true">
<span class="text-muted" id="composer-counts">{{ words }} words · {{ messageText.length }} text characters</span>
<span class="text-muted">Select text, then choose a toolbar action.</span>
</Flex>
<Grid columns="1" columnsMd="3">
<FormSelectField id="composer-tone" label="Tone" :model="tone" :options="tones"/>
<FormSelectField id="composer-variant" label="Variant" :model="variant" :options="variants"/>
<FormSelectField id="composer-height" label="Minimum height" :model="height" :options="heights"/>
</Grid>
<Grid columns="1" columnsMd="2">
<FormInputField id="composer-label" label="Editor label" :model="editorLabel"/>
<FormInputField id="composer-placeholder" label="Empty-state hint" :model="editorPlaceholder"/>
</Grid>
<Flex justify="between" align="center" wrap="true">
<FormCheck id="composer-disabled" label="Disable editing and toolbar" :checked="disabled"/>
<Btn variant="link" @click="reset">Reset playground</Btn>
</Flex>
<Panel variant="surfaceAlt" bodyClass="p-3 min-w-0">
<p class="text-eyebrow mt-0">Model inspector</p>
<Tabs group="composer-model" selectedTab="composer-model-text" ariaLabel="Live model values" tabVariant="underline">
<TabPane id="composer-model-text" label="Plain text">
<pre id="composer-text-output" class="max-h-inspector overflow-auto ws-pre-wrap m-0">{{ messageText || 'The draft is empty.' }}</pre>
</TabPane>
<TabPane id="composer-model-html" label="HTML">
<pre class="overflow-x-auto m-0"><code id="composer-html-output">{{ messageHtml || 'The draft is empty.' }}</code></pre>
</TabPane>
</Tabs>
<p class="text-muted mb-0">These values update as you type. The HTML source button in the editor lets you edit the markup itself.</p>
</Panel>
</Flex>`
function createComposerPlayground(): ComposerPlayground {
const messageHtml = ref(releaseHtml)
const messageText = ref('')
const editorLabel = ref('Release note')
const editorPlaceholder = ref('What would you like to share?')
const disabled = ref(false)
const tone = ref<SemanticTone>('neutral')
const variant = ref<ComponentVariant>('surfaceAlt')
const height = ref('18rem')
return {
messageHtml,
messageText,
editorLabel,
editorPlaceholder,
disabled,
tone,
variant,
height,
words: computed(() =>
messageText().trim() ? messageText().trim().split(/\s+/).length : 0,
),
tones: [
'neutral',
'accent',
'secondary',
'info',
'success',
'warning',
'danger',
'feature',
].map((value) => ({ value, label: value })),
variants: [
'surfaceAlt',
'surface',
'flat',
'flatAlt',
'outline',
'outlineFill',
'solid',
'subtle',
'none',
].map((value) => ({ value, label: value })),
heights: ['12rem', '18rem', '24rem'].map((value) => ({
value,
label: value,
})),
release: () => messageHtml(releaseHtml),
reply: () => messageHtml(replyHtml),
clear: () => messageHtml(''),
reset: () => {
messageHtml(releaseHtml)
editorLabel('Release note')
editorPlaceholder('What would you like to share?')
disabled(false)
tone('neutral')
variant('surfaceAlt')
height('18rem')
},
}
}
const composerPlayground = defineComponent<ComposerPlayground>(
composerPlaygroundTemplate,
{
context: createComposerPlayground,
},
)
const icons: Record<string, string> = {
'lucide:chevron-down': lucide_chevron_down,
'lucide:bold': lucide_bold,
'lucide:italic': lucide_italic,
'lucide:underline': lucide_underline,
'tabler:align-left': tabler_align_left,
'tabler:align-center': tabler_align_center,
'tabler:align-right': tabler_align_right,
'lucide:list': lucide_list,
'lucide:list-ordered': lucide_list_ordered,
'lucide:link': lucide_link,
'lucide:eraser': lucide_eraser,
'lucide:code': lucide_code,
}
createApp(
{
components: {
ComposerPlayground: composerPlayground,
...defineComposerComponents(),
...defineBadgeComponents(),
...defineButtonComponents(),
...defineFlexComponents(),
...defineFormComponents(),
...defineFormInputField(),
...defineFormSelectField(),
...defineGridComponents(),
...definePanelComponents(),
...defineTabsComponents(),
...defineIconComponents((name) => icons[name] ?? ''),
},
},
{ selector: 'app#composer-demo', template: html`<ComposerPlayground/>` },
) What the toolbar does
| Tool | How to try it | Result |
|---|---|---|
| Bold, italic, underline | Select words, then click a tool. | Formats the selection using the browser's editing commands. |
| Left, center, right | Place the caret in a paragraph. | Changes the current block's alignment. |
| Bulleted, numbered list | Select paragraphs or place the caret in one. | Toggles a list around the selected blocks. |
| Link | Select text, click Link, enter an HTTPS or mailto URL. | Adds a link with target="_blank" and rel="noopener noreferrer". |
| Clear formatting | Select styled text and click the eraser. | Runs the browser's removeFormat command; it does not reset the draft or guarantee removal of links and block structure. |
| HTML source | Click the code icon, edit, then click it again. | Switches between the visual editor and an editable HTML textarea. |
Formatting commands operate in visual mode. In source mode, edit the markup itself; the other toolbar commands do not apply. Only the source toggle reports a pressed state, the toolbar does not track bold/list/alignment state for the current selection.
Save, restore and focus a draft
Open draft editor mounts a Composer with focusOnMount enabled. Edit the message, save a snapshot, make another change, then restore it. Close and reopen the editor to see that the draft lives in the parent component, independently of the editor's lifetime.
The sample saves both models in memory and requires non-empty plain text. That validation is an application choice: Composer itself allows empty and image-only messages.
<RegorApp id="composer-draft-demo" src="./draft-workflow.ts"/> import {
createComposerBodyHtml,
defineBadgeComponents,
defineButtonComponents,
defineComposerComponents,
defineFlexComponents,
defineFormComponents,
defineIconComponents,
definePanelComponents,
} from '@purestack/ts-components'
import {
lucide_bold,
lucide_code,
lucide_eraser,
lucide_italic,
lucide_link,
lucide_list,
lucide_list_ordered,
lucide_underline,
tabler_align_center,
tabler_align_left,
tabler_align_right,
} from '@purestack/ts-svg-icons'
import {
type ComputedRef,
computed,
createApp,
defineComponent,
html,
type Ref,
ref,
} from 'regor'
export interface ComposerDraftWorkflow {
messageHtml: Ref<string>
messageText: Ref<string>
savedHtml: Ref<string>
savedText: Ref<string>
editing: Ref<boolean>
dirty: ComputedRef<boolean>
status: Ref<string>
open: () => void
save: () => void
restore: () => void
}
const composerDraftWorkflowTemplate = html`<Flex direction="column">
<Flex justify="between" align="center" wrap="true">
<div>
<p class="text-eyebrow m-0">Draft workspace</p>
<p class="mb-0">Open the editor, make a change, then save or restore the draft.</p>
</div>
<Badge :tone="dirty ? 'warning' : 'success'" variant="surface">{{ dirty ? 'Unsaved changes' : 'Saved snapshot' }}</Badge>
</Flex>
<Btn r-if="!editing" tone="accent" variant="surface" @click="open">Open draft editor</Btn>
<Composer r-if="editing" id="composer-draft-editor" label="Team update"
placeholder="Write a team update" :html="messageHtml" :text="messageText"
:focusOnMount="true" minHeight="14rem"/>
<Flex r-if="editing" wrap="true">
<Btn tone="accent" :disabled="!dirty || !messageText.trim()" @click="save">Save snapshot</Btn>
<Btn variant="outline" :disabled="!dirty" @click="restore">Restore snapshot</Btn>
<Btn variant="link" @click="editing = false">Close editor</Btn>
</Flex>
<FormStatus role="status">{{ status }}</FormStatus>
<Panel variant="surfaceAlt" bodyClass="p-3 min-w-0">
<p class="text-eyebrow mt-0">Saved plain-text copy</p>
<p id="composer-saved-text" class="ws-pre-wrap mb-0">{{ savedText }}</p>
</Panel>
<p class="text-muted m-0">The snapshot stays in this example's memory. Reloading the page resets it.</p>
</Flex>`
function createComposerDraftWorkflow(): ComposerDraftWorkflow {
const initial = createComposerBodyHtml(
'<p>The preview is ready for review.</p><p>Next: test the keyboard flow and share your notes.</p>',
)
const messageHtml = ref(initial)
const messageText = ref('')
const savedHtml = ref(initial)
const savedText = ref(
'The preview is ready for review.\nNext: test the keyboard flow and share your notes.',
)
const editing = ref(false)
const status = ref(
'Open the editor to continue. Focus moves to the start of the draft.',
)
return {
messageHtml,
messageText,
savedHtml,
savedText,
editing,
status,
dirty: computed(() => messageHtml() !== savedHtml()),
open: () => {
editing(true)
status('Editing. Save captures both HTML and plain text.')
},
save: () => {
if (!messageText().trim()) return
savedHtml(messageHtml())
savedText(messageText())
status('Snapshot saved. You can keep editing or close the editor.')
},
restore: () => {
messageHtml(savedHtml())
status('Restored the saved snapshot.')
},
}
}
const composerDraftWorkflow = defineComponent<ComposerDraftWorkflow>(
composerDraftWorkflowTemplate,
{
context: createComposerDraftWorkflow,
},
)
const icons: Record<string, string> = {
'lucide:bold': lucide_bold,
'lucide:italic': lucide_italic,
'lucide:underline': lucide_underline,
'tabler:align-left': tabler_align_left,
'tabler:align-center': tabler_align_center,
'tabler:align-right': tabler_align_right,
'lucide:list': lucide_list,
'lucide:list-ordered': lucide_list_ordered,
'lucide:link': lucide_link,
'lucide:eraser': lucide_eraser,
'lucide:code': lucide_code,
}
createApp(
{
components: {
ComposerDraftWorkflow: composerDraftWorkflow,
...defineComposerComponents(),
...defineBadgeComponents(),
...defineButtonComponents(),
...defineFlexComponents(),
...defineFormComponents(),
...definePanelComponents(),
...defineIconComponents((name) => icons[name] ?? ''),
},
},
{
selector: 'app#composer-draft-demo',
template: html`<ComposerDraftWorkflow/>`,
},
) One input model, two useful outputs
Bind html to a writable ref. Changes from your app replace the draft; changes from the editor write back to that same ref. Bind text when you also need a searchable or plain-text representation.
const messageHtml = ref('<p>Hello <strong>Alex</strong>.</p>')
const messageText = ref('') <Composer label="Message" :html="messageHtml" :text="messageText"/> text is derived from HTML. Writing to the text ref does not update the editor. Block boundaries and line breaks become newlines, whitespace is normalized, and image alt text is not included. Do not use the text value as a lossless representation of the document.
Composer is not a native form field: it does not serialize itself into FormData, provide a required prop, save drafts, or send messages. Read the refs in your submit handler and apply the validation your workflow needs.
Inline images and file events
Try Insert demo image, choose a local image, or paste/drop an image file into the visual editor. The attachment tab shows the original file and its content ID; the HTML tab shows cid: references instead of temporary preview URLs. Remove an attachment to remove its image and release its URL.
<RegorApp id="composer-images-demo" src="./inline-images.ts"/> import {
createComposerBodyHtml,
defineBadgeComponents,
defineButtonComponents,
defineComposerComponents,
defineFlexComponents,
defineFormComponents,
defineIconComponents,
definePanelComponents,
defineTabsComponents,
} from '@purestack/ts-components'
import {
lucide_bold,
lucide_code,
lucide_eraser,
lucide_italic,
lucide_link,
lucide_list,
lucide_list_ordered,
lucide_underline,
tabler_align_center,
tabler_align_left,
tabler_align_right,
} from '@purestack/ts-svg-icons'
import {
createApp,
defineComponent,
html,
onUnmounted,
type Ref,
ref,
type SRef,
sref,
} from 'regor'
export interface ComposerAttachment {
contentId: string
file: File
previewUrl: string
}
export interface ComposerInlineImages {
messageHtml: Ref<string>
previewUrls: SRef<Record<string, string>>
attachments: SRef<ComposerAttachment[]>
fileInput: SRef<HTMLInputElement | null>
busy: Ref<boolean>
status: Ref<string>
choose: () => void
select: (event: Event) => void
receive: (event: CustomEvent<{ files: File[] }>) => void
insertDemo: () => Promise<void>
remove: (contentId: string) => void
}
const composerInlineImagesTemplate = html`<Flex direction="column">
<Flex justify="between" align="center" wrap="true">
<p class="text-eyebrow m-0">Inline image lab</p>
<Badge tone="info" variant="surface">Local previews · no uploads</Badge>
</Flex>
<Composer id="composer-images-editor" label="Image message" :html="messageHtml"
:imagePreviewUrls="previewUrls" minHeight="14rem" @files="receive"/>
<input :ref="fileInput" type="file" accept="image/png,image/jpeg,image/webp,image/gif"
multiple hidden @change="select"/>
<Flex wrap="true">
<Btn tone="accent" variant="surface" @click="choose">Choose images</Btn>
<Btn variant="outline" :disabled="busy" @click="insertDemo">{{ busy ? 'Loading image…' : 'Insert demo image' }}</Btn>
</Flex>
<FormStatus role="status">{{ status }}</FormStatus>
<Panel variant="surfaceAlt" bodyClass="p-3 min-w-0">
<Tabs group="composer-images-output" selectedTab="composer-attachments" ariaLabel="Image model inspection" tabVariant="underline">
<TabPane id="composer-attachments" label="Attachments">
<p r-if="!attachments.length" class="m-0 text-muted">Add an image to see its file and content ID.</p>
<Flex r-for="attachment in attachments" direction="column" class="py-3 bb-1 b-subtle">
<Flex justify="between" align="center" wrap="true">
<strong class="overflow-x-auto w-full">{{ attachment.file.name }}</strong>
<Btn variant="outline" size="sm" :aria-label="'Remove ' + attachment.file.name" @click="remove(attachment.contentId)">Remove</Btn>
</Flex>
<span class="text-muted">{{ Math.ceil(attachment.file.size / 1024) }} KB · {{ attachment.file.type }}</span>
<code class="overflow-x-auto">cid:{{ attachment.contentId }}</code>
</Flex>
</TabPane>
<TabPane id="composer-images-html" label="HTML with cid: URLs">
<pre class="overflow-x-auto m-0"><code id="composer-image-html">{{ messageHtml }}</code></pre>
</TabPane>
</Tabs>
</Panel>
<p class="text-muted m-0">You can also paste or drop image files into the editor. This example appends them to the message. PNG, JPEG, WebP and GIF files up to 5 MB each are accepted.</p>
</Flex>`
function createComposerInlineImages(): ComposerInlineImages {
const messageHtml = ref(
createComposerBodyHtml(
'<p><strong>A little more than words.</strong></p><p>Add a product image, screenshot or the demo logo below.</p>',
),
)
const previewUrls = sref<Record<string, string>>({})
const attachments = sref<ComposerAttachment[]>([])
const fileInput = sref<HTMLInputElement | null>(null)
const status = ref(
'Images stay in this browser. The HTML model keeps portable cid: references.',
)
const busy = ref(false)
let disposed = false
const add = (files: File[]) => {
const accepted = files.filter(
(file) =>
['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(
file.type,
) &&
file.size > 0 &&
file.size <= 5 * 1024 * 1024,
)
const entries = accepted.map((file) => ({
file,
contentId: `${crypto.randomUUID()}@composer.demo`,
previewUrl: URL.createObjectURL(file),
}))
if (entries.length) {
const document = new DOMParser().parseFromString(
messageHtml(),
'text/html',
)
const first = document.body.firstElementChild
const body =
document.body.children.length === 1 && first?.tagName === 'DIV'
? first
: document.body
for (const entry of entries) {
const paragraph = document.createElement('p')
const image = document.createElement('img')
image.src = `cid:${entry.contentId}`
image.alt = entry.file.name
image.style.width = '240px'
paragraph.append(image)
body.append(paragraph)
}
attachments([...attachments(), ...entries])
previewUrls(
Object.fromEntries(
attachments().map((entry) => [entry.contentId, entry.previewUrl]),
),
)
messageHtml(document.body.innerHTML)
}
const skipped = files.length - entries.length
status(
`${entries.length} ${entries.length === 1 ? 'image' : 'images'} added.` +
(skipped
? ` ${skipped} ${skipped === 1 ? 'file' : 'files'} skipped. Use a supported image under 5 MB.`
: ''),
)
}
onUnmounted(() => {
disposed = true
for (const entry of attachments()) URL.revokeObjectURL(entry.previewUrl)
})
return {
messageHtml,
previewUrls,
attachments,
fileInput,
busy,
status,
choose: () => fileInput()?.click(),
select: (event) => {
const input = event.currentTarget as HTMLInputElement
add(Array.from(input.files ?? []))
input.value = ''
},
receive: (event) => add(event.detail.files),
insertDemo: async () => {
busy(true)
try {
const response = await fetch('/assets/pure-stack-logo.png')
if (!response.ok) throw new Error('Image unavailable')
const blob = await response.blob()
if (!disposed)
add([new File([blob], 'pure-stack-logo.png', { type: 'image/png' })])
} catch {
if (!disposed)
status(
'The demo image could not be loaded. Choose a local image instead.',
)
} finally {
if (!disposed) busy(false)
}
},
remove: (contentId) => {
const entry = attachments().find((item) => item.contentId === contentId)
if (!entry) return
const document = new DOMParser().parseFromString(
messageHtml(),
'text/html',
)
for (const image of document.querySelectorAll('img')) {
if (image.getAttribute('src') !== `cid:${contentId}`) continue
const paragraph = image.parentElement
image.remove()
if (paragraph?.tagName === 'P' && !paragraph.hasChildNodes())
paragraph.remove()
}
messageHtml(document.body.innerHTML)
attachments(attachments().filter((item) => item !== entry))
previewUrls(
Object.fromEntries(
attachments().map((item) => [item.contentId, item.previewUrl]),
),
)
URL.revokeObjectURL(entry.previewUrl)
status(`Removed ${entry.file.name} and released its preview URL.`)
},
}
}
const composerInlineImages = defineComponent<ComposerInlineImages>(
composerInlineImagesTemplate,
{
context: createComposerInlineImages,
},
)
const icons: Record<string, string> = {
'lucide:bold': lucide_bold,
'lucide:italic': lucide_italic,
'lucide:underline': lucide_underline,
'tabler:align-left': tabler_align_left,
'tabler:align-center': tabler_align_center,
'tabler:align-right': tabler_align_right,
'lucide:list': lucide_list,
'lucide:list-ordered': lucide_list_ordered,
'lucide:link': lucide_link,
'lucide:eraser': lucide_eraser,
'lucide:code': lucide_code,
}
createApp(
{
components: {
ComposerInlineImages: composerInlineImages,
...defineComposerComponents(),
...defineBadgeComponents(),
...defineButtonComponents(),
...defineFlexComponents(),
...defineFormComponents(),
...definePanelComponents(),
...defineTabsComponents(),
...defineIconComponents((name) => icons[name] ?? ''),
},
},
{
selector: 'app#composer-images-demo',
template: html`<ComposerInlineImages/>`,
},
) From a file to an inline preview
- Composer emits a bubbling
filesevent with{ files: File[] }inevent.detailwhen files are pasted or dropped. - The app validates the files, retains the accepted
Fileobjects, and assigns content IDs. - The app writes an image such as
<img src="cid:logo@example" alt="Product logo">into the HTML model and mapslogo@exampleto its preview URL throughimagePreviewUrls. - Composer resolves that URL for display and maps it back to
cid:when reading the visual editor. The app revokes object URLs when they are no longer needed.
The event alone does not upload files or insert images. This example appends images to the message; insertion at a saved caret position is an application concern. The file picker also calls the same handler logic, without depending on clipboard access.
Content-ID keys are normalized to lowercase, with surrounding angle brackets removed. For example, cid:%3CLogo%40Example%3E looks up logo@example. An unresolved ID uses a transparent placeholder in the editor. Use distinct preview URLs for distinct IDs so the reverse mapping stays unambiguous.
When sending email, your backend must attach the corresponding bytes with matching content IDs. Object URLs are local to the browser and are not email attachments. The example deliberately retains files until Remove is used, even if the image is deleted manually in the editor.
HTML, paste and presentation
Visual content and source mode
The visual canvas uses a white email-style surface, including in dark mode; the surrounding controls use the active skin. tone and variant style the editor shell, not the message's text and background colors. The canvas grows with its content; minHeight is a floor, not a fixed height or a content limit.
Use the exported helper for a padded message body:
import { createComposerBodyHtml } from '@purestack/ts-components'
const messageHtml = ref(createComposerBodyHtml(
'<p>Hello Alex,</p><p>Your preview is ready.</p>',
)) createComposerBodyHtml(contentHtml = '', placeholder?) returns a div wrapper with padding:1rem and an optional placeholder attribute. It is a presentation helper, not a sanitizer. Pass HTML you intend the editor to process; do not use the helper as a security boundary for rendering arbitrary HTML elsewhere.
What is preserved
Composer normalizes content through its email HTML policy. Supported content includes paragraphs, headings, inline emphasis, lists, blockquotes, links, images and tables, with allowlisted attributes and inline styles. Scripts, forms and embedded interactive content are dropped. Arbitrary classes, event handlers and unsupported attributes are not a document format for this editor.
Links accept HTTP, HTTPS and mailto destinations; relative links resolve against the page URL. Images support HTTP/HTTPS, image data URLs and cid: references. Blob previews belong in imagePreviewUrls, paired with cid: model URLs.
In source mode, the textarea keeps the text you are currently editing while the HTML and plain-text refs receive normalized output. Switching back renders that output; unsupported markup can disappear or change. Validate content again at the application's storage or sending boundary according to your own policy.
Pasting and dropping
- Rich-text paste uses the clipboard's HTML when present and normalizes it before insertion.
- Plain-text paste escapes markup, turns blank lines into paragraphs, and preserves single line breaks.
- Clipboard files trigger
files; a paste can contain both files and text/HTML, so both paths can run. - File drag-and-drop prevents the browser's default file-opening behavior and emits
filesin visual mode. Your app decides what happens next. - Disabled editors ignore typing, paste and file drop handlers. In source mode, the textarea has native text-editing behavior rather than the visual editor's file handlers.
API reference
Models · editor behavior · presentation
Register defineComposerComponents() and the toolbar icons with defineIconComponents(). Every example's TypeScript tab includes a complete registration. Bind refs with colon-prefixed attributes; literal settings can be passed directly.
Content models
html
Ref<string> | SRef<string> - Default
ref('')
The writable HTML model. Initialize it with a draft, read it when saving, or replace its value to load another message. Pass a ref, not a string literal. The editor writes normalized HTML back as the user edits.
:html="messageHtml" · Updating this ref can replace the user's current content and selection; keep it stable during ordinary typing.
text
Ref<string> | SRef<string> - Default
ref('')
Plain-text output derived from the HTML. Useful for text previews, word counts and a plain-text message part. Assigning to this ref does not change the editor.
:text="messageText" · Image-only messages can have an empty text value.
Editing and naming
label
RefOrValue<string> - Default
not set
A visible label above the editor and the accessible name for its textbox. Use a specific label such as Reply to customer.
placeholder
RefOrValue<string> - Default
not set
Hint for an empty draft. It also supplies the accessible name when no label is provided. A visible label is preferable; the hint disappears as content is entered.
disabled
RefOrValue<boolean> - Default
false
Disables all toolbar buttons, removes editability from the visual canvas and disables the source textarea. The parent can still replace the HTML model. This is a disabled state, not a separate read-only mode.
:disabled="isSaving"
focusOnMount
RefOrValue<boolean> - Default
false
Requests focus at the start of the draft after mounting, with retries while the editor becomes visible. Set it when opening a deliberate editing flow, as in the draft example. It is a mount-time action, not a reactive focus command.
Presentation and images
minHeight
RefOrValue<number | string> - Default
13.75rem
Minimum height for both the visual editor and source textarea. Numbers are pixel values (clamped to zero); strings must be CSS lengths.
:minHeight="240" gives 240px. minHeight="18rem" gives a relative length. It does not constrain the toolbar or cap growing content.
tone
RefOrValue<SemanticTone> - Default
inherited
Semantic color of the shell. Accepts neutral, accent, secondary, info, success, warning, danger, feature, custom or ghost. The message canvas retains its email presentation.
variant
RefOrValue<ComponentVariant> - Default
surfaceAlt
Shell treatment. Surface, surfaceAlt and outline are useful starting points for editors. The complete shared type also accepts solid, spotlight, glass, flat, flatAlt, flatSolid, outlineFill, subtle, subtleBtn, link, sheen, underline, rail, bracket and none.
imagePreviewUrls
RefOrValue<Record<string, string>> - Default
not set
Maps normalized content IDs to editor preview URLs, while the model retains cid: image sources. Use a ref for maps that change and replace the map when adding or removing a preview.
The app owns the original files, attachment lifecycle and object-URL cleanup. See the inline-image sample for the complete flow.
Events and composition
files
CustomEvent<{ files: File[] }> - Default
paste or drop
A bubbling event dispatched from the visual editor when a paste or file drop contains files. Read event.detail.files with @files="handleFiles". File types and sizes are not filtered by Composer.
Composer has no content slots or configurable toolbar slot. The public template props are listed above. Context methods such as format and toggleSourceMode are implementation details, not additional template props.
Keyboard and accessibility
The visual editor exposes role="textbox" and aria-multiline="true". Its accessible name comes from label, then placeholder, then “Message body”. Source mode uses a native textarea. Toolbar buttons have individual accessible names and belong to a “Composer tools” toolbar.
Use Tab to reach buttons and the editor; toolbar buttons use normal button keyboard behavior. Editing and formatting rely on the browser's native contenteditable commands, so selection behavior and shortcuts can vary by browser. Provide a visible label, use focusOnMount only after a deliberate user action, and announce save or validation results in your application, as the draft sample does with FormStatus.
Related components
- DropFiles: a dedicated attachment picker and drop zone.
- FormInputField: subject lines and other plain-text fields.
- AppForm: structure and submit behavior around an editing workflow.