Building a Message Composer with Markdown, Mentions, and File Attachments
The message box is the product
Open Slack, Linear, GitHub, or any tool where people collaborate in text, and you will find that the single most-used control is the message composer. It is where the thought becomes a comment, the bug report, the reply. People spend more time staring at that box than at almost anything else in the app, which makes it strange how often it is treated as an afterthought: a textarea, a paperclip icon, and a send button glued together at the last minute.
A composer that feels good to type in does three jobs at once. It lets you express (markdown, emphasis, lists), it lets you reference (@mentions of people, channels, or issues), and it lets you attach (files, images, the screenshot you just grabbed). Get all three working together, with the small touches (undo, paste-a-screenshot, a thumbnail that shows a loading spinner before the upload finishes), and the box disappears. People just write. This article is about building that.
What a full-featured composer actually needs
It helps to enumerate the surface before reaching for tools, because the list is longer than it looks:
- Inline markdown preview. Typing **bold** should render bold as you type, and *italic* italic, rather than show raw asterisks you have to imagine away.
- @mentions. An @ opens a people picker; the chosen mention becomes a solid, non-editable pill, not a fragile string that breaks when you backspace into it.
- File and image attachments. Drag, click, or paste. Each shows a thumbnail, a loading state while it uploads, and a remove button.
- Paste a screenshot. Cmd-Shift-4, Cmd-V, and the image is just there. This is table stakes now, and most homegrown boxes get it wrong.
- List auto-formatting. Start a line with - and you are in a bullet list; Tab and Shift+Tab indent and outdent.
- Undo/redo. Real history, debounced so each keystroke isn't its own undo step.
That is a lot of behavior, and every item has a long tail of edge cases: IME composition for CJK input, copy/paste that preserves mention data internally but degrades to plain text when pasted elsewhere, auto-grow that expands on focus and shrinks on blur. Building it from a bare contentEditable is a multi-week project that has been done, badly, thousands of times.
A purpose-built input instead of a document editor
The instinct is to reach for Tiptap, Lexical, or Slate. They can do all of this, but they are document frameworks, each carrying two to five dependencies, designed for building Notion or Google Docs, then bent into a chat box. For a Slack-style composer that is a lot of weight and a lot of API surface for what is, conceptually, one growing field.
Prompt Area takes the opposite stance. It is a production-grade contentEditable input built specifically for prompt-style and chat-composer use, with zero extra dependencies beyond React and your stack. One component, PromptArea; one hook, usePromptAreaState(). It ships as an npm package with self-contained CSS, or via the shadcn registry so you copy the source into your own repo. Crucially for a message composer, the features above are not add-ons you assemble. Inline markdown, mentions, list auto-formatting, undo/redo, IME support, and attachments are all in the box.
The data model: segments, not HTML soup
The reason mentions and attachments stay robust is that the composer's value is not an HTML string. It is a Segment[] array. Each element is either a TextSegment ({ type: 'text', text }) or a ChipSegment ({ type: 'chip', trigger, value, displayText, data? }). A mention is a chip. That means it is an immutable pill in the UI and a structured object in your data, so you never have to regex a name back out of a sentence.
When a mention is a real object instead of a substring, "who did this comment notify?" stops being a parsing problem and becomes a .filter().Helpers like getChipsByTrigger(), segmentsToPlainText(), and isSegmentsEmpty() let you move between the rich value and plain text without losing the structured parts. Submit the plain text to your API and the chip list as a separate field, and your backend gets both the prose and the precise set of mentioned users.
Wiring up attachments
Attachments are where the small details add up. Prompt Area exposes images and files props for the controlled lists of what's attached, an onImagePaste callback for the paste-a-screenshot flow, and onFileRemove for the per-thumbnail remove button. You own the upload; the component owns the rendering, with thumbnails, loading states, and remove affordances included. Here is a composer with the full attachment loop wired:
import { PromptArea, usePromptAreaState, segmentsToPlainText } from 'prompt-area' import { useState } from 'react' function MessageComposer({ onSend, uploadFile }) { const { segments, setSegments } = usePromptAreaState() const [images, setImages] = useState([]) const [files, setFiles] = useState([]) // Paste a screenshot: show it immediately, then swap in the uploaded URL. const handleImagePaste = async (file) => { const id = crypto.randomUUID() setImages((prev) => [...prev, { id, file, loading: true }]) const url = await uploadFile(file) setImages((prev) => prev.map((img) => (img.id === id ? { ...img, url, loading: false } : img)), ) } const handleRemove = (id) => setImages((prev) => prev.filter((img) => img.id !== id)) return ( <PromptArea value={segments} onChange={setSegments} markdown images={images} files={files} onImagePaste={handleImagePaste} onFileRemove={handleRemove} placeholder="Write a message... @mention, **bold**, or paste a screenshot" onSubmit={() => onSend({ text: segmentsToPlainText(segments), images, files }) } /> ) }
The pattern in handleImagePaste, rendering a thumbnail in a loading state, then patching in the URL once the upload resolves, is exactly the behavior that makes a paste feel instant. The user sees their screenshot the moment they press Cmd-V; the network catches up a beat later. The markdown prop turns on the live inline preview, so **bold** and *italic* render as the user types.
Mentions that point at real things
Mentions in a message composer rarely point at just people. In Linear you mention issues; in GitHub, pull requests; in Slack, channels. Prompt Area's trigger system handles all of them through one mechanism: register a trigger character (@, #, or anything you like), and when the user types it you open a dropdown of candidates. The selected item resolves into a chip carrying whatever data you attach.
- @ with a mentionTrigger preset for people.
- # with a hashtagTrigger for channels, labels, or topics.
- A callbackTrigger when you'd rather fire an action than insert a pill.
Because each resolved mention is a chip with structured data, your "this comment mentioned Sarah and issue ENG-412" logic reads straight off getChipsByTrigger() at submit time. No second parse, no ambiguity about whether "@sarah" in the middle of a sentence was a real mention or someone typing an email address.
The details that separate good from tolerable
A few behaviors are easy to forget and painful to retrofit, so it's worth checking them off deliberately:
- Undo/redo with debounced snapshots, so Cmd-Z walks back in sensible chunks rather than one character at a time.
- Copy/paste that preserves chips internally but auto-resolves triggers and degrades to clean plain text when pasted into another app.
- Auto-grow on focus, shrink on blur, so an idle composer stays compact and an active one gives you room.
- Keyboard shortcuts: Enter to send, Shift+Enter for a newline, Cmd/Ctrl+B and Cmd/Ctrl+I for emphasis.
- Accessibility: ARIA labels and full keyboard navigation, including through the attachment thumbnails.
Each of these is a small thing. Collectively they are the difference between a composer people tolerate and one they stop noticing, which, for an input you use hundreds of times a day, is the highest praise it can earn.
Where to start
If you are building a Slack-style or Linear-style message composer, the trap is to start from a textarea and slowly reinvent mentions, then attachments, then undo, discovering each edge case the hard way. The faster route is to begin from an input that already treats markdown, mentions, and attachments as first-class, then spend your time on the parts unique to your product: who can be mentioned, where files go, what a sent message becomes.
That is the niche Prompt Area fills. It hands you the composer behavior (chips, images/files props, onImagePaste, onFileRemove, the markdown prop, undo/redo, IME) as a single dependency-free component you can install from npm or copy out of the shadcn registry and own. The message box is the product. Build it like you mean it.