Skip to main content

Overview

The MessageComposer component provides the message input interface with emoji picker, file attachments via drag-and-drop or click, and automatic draft persistence. It manages the composition state and triggers send callbacks.

Usage

import { MessageComposer } from "@/components/chat/message-composer"

function ChatInterface() {
  return (
    <MessageComposer
      chatId={activeChat.id}
      draftText={savedDraft?.text}
      draftAttachments={savedDraft?.attachments}
      onChangeDraft={handleDraftUpdate}
      onClearDraft={handleDraftClear}
      onSend={handleSendMessage}
    />
  )
}

Props

chatId
string
required
The ID of the chat conversation this composer is for
draftText
string
Initial draft text to populate the input field
draftAttachments
MediaAttachment[]
Initial draft attachments to display as previews
onChangeDraft
(chatId: string, draft: { text?: string; attachments?: MediaAttachment[] }) => void
required
Callback when draft content changes (debounced for persistence)
onClearDraft
(chatId: string) => void
required
Callback to clear the draft after successfully sending
onSend
(chatId: string, payload: { text?: string; media?: MediaAttachment }) => void
required
Callback when a message is sent (called once per attachment + once for text if present)

Type definitions

interface MessageComposerProps {
  chatId: string
  draftText?: string
  draftAttachments?: MediaAttachment[]
  onChangeDraft: (chatId: string, draft: { text?: string; attachments?: MediaAttachment[] }) => void
  onClearDraft: (chatId: string) => void
  onSend: (chatId: string, payload: { text?: string; media?: MediaAttachment }) => void
}

interface MediaAttachment {
  id: string
  type: "image" | "audio"
  url: string
  thumbnailUrl?: string
  width?: number
  height?: number
  sizeInBytes?: number
  caption?: string
  waveform?: number[]
  localObjectUrl?: string
}

Draft persistence

The component automatically saves drafts as the user types:
const [text, setText] = useState(draftText)
const [attachments, setAttachments] = useState<MediaAttachment[]>(draftAttachments)

useEffect(() => {
  onChangeDraft(chatId, { text, attachments })
}, [chatId, text, attachments, onChangeDraft])
Drafts are persisted in real-time, so users never lose their work when switching conversations.

Sending messages

The send handler processes text and attachments:
const handleSend = useCallback(() => {
  if (!canSend) return
  
  // Send each attachment as a separate message
  attachments.forEach((attachment) => {
    onSend(chatId, { media: attachment })
    if (attachment.localObjectUrl) {
      URL.revokeObjectURL(attachment.localObjectUrl)
    }
  })
  
  // Send text message if present
  const trimmed = text.trim()
  if (trimmed.length > 0) {
    onSend(chatId, { text: trimmed })
  }
  
  setText("")
  setAttachments([])
  onClearDraft(chatId)
}, [attachments, canSend, chatId, onClearDraft, onSend, text])
Each attachment is sent as a separate message. Text is sent only if non-empty after trimming.

Keyboard shortcuts

  • Enter: Send message
  • Shift + Enter: Add new line
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
  (event) => {
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault()
      handleSend()
    }
  },
  [handleSend]
)

Emoji picker

Emoji picker is powered by @emoji-mart/react:
const EmojiPicker = dynamic<PickerProps>(() => import("@emoji-mart/react"), {
  ssr: false,
})

<Popover open={isEmojiOpen} onOpenChange={setIsEmojiOpen}>
  <PopoverTrigger asChild>
    <Button size="icon" variant="ghost" className="h-10 w-10 text-muted-foreground">
      <Smiley className="h-5 w-5" />
      <span className="sr-only">Open emoji picker</span>
    </Button>
  </PopoverTrigger>
  <PopoverContent className="border-none bg-transparent p-0 shadow-2xl" align="start">
    <EmojiPicker
      data={data}
      onEmojiSelect={(emoji: { native?: string }) => {
        if (emoji?.native) {
          setText((prev) => `${prev}${emoji.native}`)
        }
      }}
      theme="light"
      previewPosition="none"
    />
  </PopoverContent>
</Popover>
The emoji picker is dynamically imported with ssr: false to reduce initial bundle size.

File attachments

The component supports two methods for adding attachments:

Drag and drop

const { getRootProps, getInputProps, isDragActive } = useDropzone({
  onDrop,
  multiple: true,
  accept: {
    "image/*": [],
  },
})

<div
  {...getRootProps()}
  className={cn(
    "relative border-t border-border/70 bg-background/95 px-4 pb-4 pt-3 transition-colors",
    isDragActive && "border-primary bg-primary/5"
  )}
>
  <input {...getInputProps()} />
  {/* Composer content */}
</div>

Click to upload

<Button
  size="icon"
  variant="ghost"
  className="h-10 w-10 text-muted-foreground"
  type="button"
  onClick={(event) => {
    event.preventDefault()
    const input = document.createElement("input")
    input.type = "file"
    input.accept = "image/*"
    input.multiple = true
    input.onchange = (e) => {
      const target = e.target as HTMLInputElement
      if (target.files) {
        onDrop(Array.from(target.files))
      }
    }
    input.click()
  }}
>
  <Paperclip className="h-5 w-5" />
  <span className="sr-only">Attach media</span>
</Button>

Attachment preview

Selected attachments display as thumbnails with remove buttons:
{attachments.length > 0 ? (
  <div className="mb-3 flex gap-2 overflow-x-auto pb-1">
    {attachments.map((attachment) => (
      <figure
        key={attachment.id}
        className="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl border border-border/60"
      >
        <img
          src={attachment.url}
          alt={attachment.caption ?? "Attachment"}
          className="h-full w-full object-cover"
        />
        <button
          type="button"
          onClick={(event) => {
            event.preventDefault()
            setAttachments((prev) =>
              prev.filter((item) => {
                if (item.id === attachment.id && item.localObjectUrl) {
                  URL.revokeObjectURL(item.localObjectUrl)
                }
                return item.id !== attachment.id
              })
            )
          }}
          className="absolute right-1 top-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-medium text-white"
        >

        </button>
      </figure>
    ))}
  </div>
) : null}

Object URL cleanup

Local file URLs are properly cleaned up to prevent memory leaks:
// Cleanup on unmount
useEffect(() => {
  return () => {
    attachments.forEach((attachment) => {
      if (attachment.localObjectUrl) {
        URL.revokeObjectURL(attachment.localObjectUrl)
      }
    })
  }
}, [attachments])

// Cleanup on send
attachments.forEach((attachment) => {
  onSend(chatId, { media: attachment })
  if (attachment.localObjectUrl) {
    URL.revokeObjectURL(attachment.localObjectUrl)  // Clean up after sending
  }
})
Object URLs created via URL.createObjectURL() must be manually revoked to prevent memory leaks.

Text input

The textarea auto-expands with content:
<Textarea
  placeholder="Type a message"
  value={text}
  rows={1}
  onChange={(event) => setText(event.target.value)}
  onKeyDown={handleKeyDown}
  className="min-h-[48px] flex-1 resize-none rounded-2xl bg-background/90 px-4 py-3 text-sm shadow-inner"
/>

Send button state

The send button is disabled when there’s no content:
const canSend = useMemo(
  () => text.trim().length > 0 || attachments.length > 0,
  [text, attachments]
)

<Button
  size="icon"
  disabled={!canSend}
  onClick={(event) => {
    event.preventDefault()
    handleSend()
  }}
  className="h-11 w-11 rounded-full bg-primary text-primary-foreground shadow-lg transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-muted"
>
  <PaperPlaneTilt className="h-5 w-5" weight="fill" />
  <span className="sr-only">Send message</span>
</Button>

Drag state visual feedback

When files are dragged over the composer, visual feedback is provided:
className={cn(
  "relative border-t border-border/70 bg-background/95 px-4 pb-4 pt-3 transition-colors",
  isDragActive && "border-primary bg-primary/5"
)}

Layout structure

<div {...getRootProps()} className="relative border-t border-border/70 bg-background/95 px-4 pb-4 pt-3">
  <input {...getInputProps()} />
  
  {/* Attachment previews */}
  {attachments.length > 0 && <AttachmentPreviews />}
  
  {/* Input controls */}
  <div className="flex items-end gap-3">
    <EmojiPickerButton />
    <AttachButton />
    <Textarea />
    <SendButton />
  </div>
</div>

Build docs developers (and LLMs) love