Skip to content

Grows with its text, takes files and emoji, and turns its mic into send.

Messagingmotion@base-ui/react

01Preview

MCMaya ChenFinance
  • Can you send over the Q3 forecast before the board call?

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react

03Usage

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

<MessageComposer
  placeholder="Message Maya"
  onSend={({ text, attachments }) => send(conversationId, text, attachments)}
  onVoice={(clip) => sendVoice(conversationId, clip.blob, clip.duration)}
  maxFileSize={25_000_000}
  draftKey={conversationId}
/>

04Source

"use client";
import { Popover } from "@base-ui/react/popover";
import { Tooltip } from "@base-ui/react/tooltip";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ArrowUp, Mic, Paperclip, Trash, X } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

/* -------------------------------------------------------------------------------------------------
 * Types and small helpers
 * -----------------------------------------------------------------------------------------------*/

export type ComposerAttachment = {
  id: string;
  name: string;
  /** Bytes. */
  size: number;
  /** MIME type. Images get a thumbnail. */
  type: string;
  /** The picked file, when it came from this composer. */
  file?: File;
  /** A URL to preview an image. The composer makes an object URL for picked images. */
  preview?: string;
  /** 0–1 while uploading. Leave undefined once it's done. */
  progress?: number;
  /** Why the upload failed, in a few words. */
  error?: string;
};

export type ComposerMessage = {
  text: string;
  attachments: ComposerAttachment[];
};

export type VoiceRecording = {
  blob: Blob;
  /** An object URL for the blob, ready for an audio element. Revoke it when you're done. */
  url: string;
  /** Seconds. */
  duration: number;
  /** 48 loudness samples, 0–1, for drawing a waveform without decoding the audio. */
  peaks: number[];
};

const units = ["B", "KB", "MB", "GB"];
/** "940 KB", "2.4 MB". */
export function formatBytes(bytes: number) {
  let n = bytes;
  let u = 0;
  while (n >= 1000 && u < units.length - 1) {
    n /= 1000;
    u++;
  }
  return `${u === 0 ? n : n < 10 ? n.toFixed(1) : Math.round(n)} ${units[u]}`;
}

const clockFormat = (seconds: number) => {
  const s = Math.max(0, Math.floor(seconds));
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};

const splitName = (name: string) => {
  const dot = name.lastIndexOf(".");
  return dot > 0 && dot > name.length - 8 ? [name.slice(0, dot), name.slice(dot + 1)] : [name, ""];
};

let seq = 0;
const nextId = () => `att-${Date.now().toString(36)}-${(seq++).toString(36)}`;

const coarseQuery = "(pointer: coarse)";
const subscribeCoarse = (cb: () => void) => {
  const mq = window.matchMedia(coarseQuery);
  mq.addEventListener("change", cb);
  return () => mq.removeEventListener("change", cb);
};

const storage = {
  read(key: string) {
    try {
      return window.localStorage.getItem(key) ?? "";
    } catch {
      return "";
    }
  },
  write(key: string, value: string) {
    try {
      if (value) window.localStorage.setItem(key, value);
      else window.localStorage.removeItem(key);
    } catch {
      /* Private mode or blocked storage: the draft just isn't kept. */
    }
  },
};

// Inserts through the editing pipeline so the browser's undo still works.
function insertAtCaret(el: HTMLTextAreaElement, text: string) {
  const start = el.selectionStart;
  const end = el.selectionEnd;
  el.focus({ preventScroll: true });
  el.setSelectionRange(start, end);
  let ok = false;
  try {
    ok = document.execCommand("insertText", false, text);
  } catch {
    ok = false;
  }
  if (!ok) {
    el.setRangeText(text, start, end, "end");
    el.dispatchEvent(new Event("input", { bubbles: true }));
  }
}

export const defaultEmojis: { emoji: string; label: string }[] = [
  { emoji: "😀", label: "Grinning" },
  { emoji: "😂", label: "Tears of joy" },
  { emoji: "😊", label: "Smiling" },
  { emoji: "😍", label: "Heart eyes" },
  { emoji: "🥲", label: "Smiling with tear" },
  { emoji: "😅", label: "Sweat smile" },
  { emoji: "🤔", label: "Thinking" },
  { emoji: "🙃", label: "Upside down" },
  { emoji: "😮", label: "Surprised" },
  { emoji: "😭", label: "Crying" },
  { emoji: "😴", label: "Sleeping" },
  { emoji: "🫡", label: "Salute" },
  { emoji: "👍", label: "Thumbs up" },
  { emoji: "👎", label: "Thumbs down" },
  { emoji: "👏", label: "Clapping" },
  { emoji: "🙌", label: "Raised hands" },
  { emoji: "🙏", label: "Thank you" },
  { emoji: "🤝", label: "Handshake" },
  { emoji: "💪", label: "Flexed arm" },
  { emoji: "👀", label: "Eyes" },
  { emoji: "🎉", label: "Party popper" },
  { emoji: "🔥", label: "Fire" },
  { emoji: "", label: "Sparkles" },
  { emoji: "💯", label: "Hundred" },
  { emoji: "", label: "Done" },
  { emoji: "", label: "Cross" },
  { emoji: "⚠️", label: "Warning" },
  { emoji: "❤️", label: "Heart" },
  { emoji: "", label: "Coffee" },
  { emoji: "🍕", label: "Pizza" },
  { emoji: "🚀", label: "Rocket" },
  { emoji: "💡", label: "Idea" },
  { emoji: "📌", label: "Pin" },
  { emoji: "📎", label: "Paperclip" },
  { emoji: "📅", label: "Calendar" },
  { emoji: "📈", label: "Chart up" },
  { emoji: "🐛", label: "Bug" },
  { emoji: "🧪", label: "Test tube" },
  { emoji: "🔒", label: "Lock" },
  { emoji: "🎯", label: "Target" },
];

/* -------------------------------------------------------------------------------------------------
 * useVoiceRecorder: microphone, live levels and the finished clip, without any UI
 * -----------------------------------------------------------------------------------------------*/

export type RecorderState = "idle" | "starting" | "recording";

/** Records from the microphone. Live `levels` (0–1) update about 16 times a second while recording. */
export function useVoiceRecorder({ onError }: { onError?: (message: string) => void } = {}) {
  const [state, setState] = useState<RecorderState>("idle");
  const [levels, setLevels] = useState<number[]>([]);
  const [elapsed, setElapsed] = useState(0);
  const session = useRef<{
    stream: MediaStream;
    recorder: MediaRecorder;
    ctx: AudioContext;
    chunks: Blob[];
    all: number[];
    started: number;
    raf: number;
  } | null>(null);
  const onErrorRef = useRef(onError);
  useEffect(() => {
    onErrorRef.current = onError;
  }, [onError]);

  const teardown = useCallback(() => {
    const s = session.current;
    if (!s) return;
    session.current = null;
    cancelAnimationFrame(s.raf);
    s.stream.getTracks().forEach((t) => t.stop());
    void s.ctx.close().catch(() => {});
  }, []);

  useEffect(() => teardown, [teardown]);

  const start = useCallback(async () => {
    if (session.current) return;
    setState("starting");
    setLevels([]);
    setElapsed(0);
    try {
      if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") throw new DOMException("", "NotSupportedError");
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: { echoCancellation: true, noiseSuppression: true },
      });
      const recorder = new MediaRecorder(stream);
      const ctx = new AudioContext();
      const analyser = ctx.createAnalyser();
      analyser.fftSize = 1024;
      ctx.createMediaStreamSource(stream).connect(analyser);
      const buffer = new Uint8Array(analyser.fftSize);
      const s = {
        stream,
        recorder,
        ctx,
        chunks: [] as Blob[],
        all: [] as number[],
        started: performance.now(),
        raf: 0,
      };
      session.current = s;
      recorder.ondataavailable = (e) => e.data.size && s.chunks.push(e.data);
      recorder.start(250);
      setState("recording");

      // Sample loudness ~16 times a second: enough for bars that feel live, cheap enough to re-render.
      let last = 0;
      const tick = (now: number) => {
        s.raf = requestAnimationFrame(tick);
        if (now - last < 60) return;
        last = now;
        analyser.getByteTimeDomainData(buffer);
        let sum = 0;
        for (let i = 0; i < buffer.length; i++) {
          const v = (buffer[i] - 128) / 128;
          sum += v * v;
        }
        const level = Math.min(1, Math.sqrt(sum / buffer.length) * 4.5);
        s.all.push(level);
        setLevels((l) => (l.length >= 72 ? [...l.slice(1), level] : [...l, level]));
        setElapsed((now - s.started) / 1000);
      };
      s.raf = requestAnimationFrame(tick);
    } catch (error) {
      teardown();
      setState("idle");
      const name = error instanceof DOMException ? error.name : "";
      onErrorRef.current?.(
        name === "NotAllowedError" || name === "SecurityError"
          ? "Microphone access is blocked. Allow it in your browser’s site settings."
          : name === "NotFoundError"
            ? "No microphone found. Connect one and try again."
            : name === "NotSupportedError"
              ? "This browser can’t record audio."
              : "Couldn’t start recording. Try again.",
      );
    }
  }, [teardown]);

  const cancel = useCallback(() => {
    session.current?.recorder.state !== "inactive" && session.current?.recorder.stop();
    teardown();
    setState("idle");
  }, [teardown]);

  /** Stops and resolves with the clip, or null if nothing usable was recorded. */
  const stop = useCallback(
    () =>
      new Promise<VoiceRecording | null>((resolve) => {
        const s = session.current;
        if (!s) return resolve(null);
        const duration = (performance.now() - s.started) / 1000;
        s.recorder.onstop = () => {
          const blob = new Blob(s.chunks, {
            type: s.recorder.mimeType || "audio/webm",
          });
          const peaks = Array.from({ length: 48 }, (_, i) => {
            const from = Math.floor((i / 48) * s.all.length);
            const to = Math.max(from + 1, Math.floor(((i + 1) / 48) * s.all.length));
            const slice = s.all.slice(from, to);
            return slice.length ? Math.max(...slice) : 0;
          });
          resolve(blob.size ? { blob, url: URL.createObjectURL(blob), duration, peaks } : null);
        };
        s.recorder.stop();
        teardown();
        setState("idle");
      }),
    [teardown],
  );

  return { state, levels, elapsed, start, stop, cancel };
}

/* -------------------------------------------------------------------------------------------------
 * MessageComposer
 * -----------------------------------------------------------------------------------------------*/

export type MessageComposerProps = Omit<React.ComponentProps<"form">, "onSubmit" | "defaultValue" | "onChange"> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (text: string) => void;
  attachments?: ComposerAttachment[];
  defaultAttachments?: ComposerAttachment[];
  onAttachmentsChange?: (attachments: ComposerAttachment[]) => void;
  /** Called with the trimmed text and the attachments. The composer clears itself straight away. */
  onSend: (message: ComposerMessage) => void;
  /** Turns on voice notes: the button shows a mic while the field is empty. */
  onVoice?: (recording: VoiceRecording) => void;
  /** Fires true while someone is typing and false 3s after they stop, send or leave. */
  onTypingChange?: (typing: boolean) => void;
  placeholder?: string;
  /** Soft limit. A count appears in the last 10% and sending stops past it. */
  maxLength?: number;
  /** Lines before the field scrolls instead of growing. */
  maxRows?: number;
  /** File types the picker offers, as for an input's accept. */
  accept?: string;
  /** Bytes. Larger files are refused with a message that names them. */
  maxFileSize?: number;
  /** Keep the draft in localStorage under this key, so a reload doesn't lose it. */
  draftKey?: string;
  /** Emoji in the picker, in order. */
  emojis?: { emoji: string; label: string }[];
  /** Above the field, inside the box: a reply preview, an editing banner. */
  header?: React.ReactNode;
  disabled?: boolean;
  autoFocus?: boolean;
  /** Where the emoji picker and tooltips portal. Defaults to document.body. */
  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;
  /** The field's accessible name. */
  label?: string;
};

type Notice = { id: number; text: string };

/**
 * A chat composer that grows with its text, takes files by picker, paste or drop, inserts
 * emoji at the caret, and turns its mic into a send button the moment there's something to send.
 */
export function MessageComposer({
  value: valueProp,
  defaultValue = "",
  onValueChange,
  attachments: attachmentsProp,
  defaultAttachments = [],
  onAttachmentsChange,
  onSend,
  onVoice,
  onTypingChange,
  placeholder = "Message",
  maxLength,
  maxRows = 8,
  accept,
  maxFileSize,
  draftKey,
  emojis = defaultEmojis,
  header,
  disabled = false,
  autoFocus = false,
  container,
  label = "Message",
  className,
  ...rest
}: MessageComposerProps) {
  const reduce = !!useReducedMotion();
  const uid = useId();
  const noticeId = `${uid}-notice`;
  const coarse = useSyncExternalStore(
    subscribeCoarse,
    () => window.matchMedia(coarseQuery).matches,
    () => false,
  );

  const [text, setText] = useControllableState({
    value: valueProp,
    defaultValue,
    onChange: onValueChange,
  });
  const [files, setFiles] = useControllableState({
    value: attachmentsProp,
    defaultValue: defaultAttachments,
    onChange: onAttachmentsChange,
  });
  const [notice, setNotice] = useState<Notice | null>(null);
  const [dragging, setDragging] = useState(false);
  const [emojiOpen, setEmojiOpen] = useState(false);
  const [atMax, setAtMax] = useState(false);

  const areaRef = useRef<HTMLTextAreaElement>(null);
  const replicaRef = useRef<HTMLDivElement>(null);
  const fileRef = useRef<HTMLInputElement>(null);
  const noticeTimer = useRef<number>(undefined);
  const typingTimer = useRef<number>(undefined);
  const typing = useRef(false);
  const dragDepth = useRef(0);
  const madeUrls = useRef(new Set<string>());
  const pickedEmoji = useRef(false);

  const say = useCallback((message: string) => {
    window.clearTimeout(noticeTimer.current);
    setNotice((n) => ({ id: (n?.id ?? 0) + 1, text: message }));
    noticeTimer.current = window.setTimeout(() => setNotice(null), 6000);
  }, []);

  const recorder = useVoiceRecorder({ onError: say });
  const recording = recorder.state !== "idle";

  /* ----- draft ----- */
  const draftStorageKey = draftKey ? `stealth:message-draft:${draftKey}` : null;
  const restored = useRef(false);
  useEffect(() => {
    if (!draftStorageKey || restored.current) return;
    restored.current = true;
    if (valueProp !== undefined || text) return;
    const saved = storage.read(draftStorageKey);
    // A one-time sync from storage after hydration.
    if (saved) setText(saved);
  }, [draftStorageKey, valueProp, text, setText]);
  useEffect(() => {
    if (!draftStorageKey || !restored.current) return;
    const t = window.setTimeout(() => storage.write(draftStorageKey, text), 300);
    return () => window.clearTimeout(t);
  }, [draftStorageKey, text]);

  /* ----- typing signal ----- */
  const onTypingRef = useRef(onTypingChange);
  useEffect(() => {
    onTypingRef.current = onTypingChange;
  }, [onTypingChange]);
  const stopTyping = useCallback(() => {
    window.clearTimeout(typingTimer.current);
    if (!typing.current) return;
    typing.current = false;
    onTypingRef.current?.(false);
  }, []);
  const markTyping = () => {
    if (!typing.current) {
      typing.current = true;
      onTypingRef.current?.(true);
    }
    window.clearTimeout(typingTimer.current);
    typingTimer.current = window.setTimeout(stopTyping, 3000);
  };

  useEffect(
    () => () => {
      window.clearTimeout(noticeTimer.current);
      window.clearTimeout(typingTimer.current);
    },
    [],
  );

  /* ----- height: follows the replica; grows upward so the caret line never leaves view ----- */
  const lineHeight = 20;
  const maxHeight = maxRows * lineHeight + 12;
  const height = useMotionValue<number | "auto">("auto");
  useEffect(() => {
    const el = replicaRef.current;
    if (!el) return;
    let last = el.offsetHeight;
    let running: AnimationPlaybackControls | undefined;
    const ro = new ResizeObserver(() => {
      const next = el.offsetHeight;
      setAtMax(el.scrollHeight > el.clientHeight + 1);
      if (next === last) return;
      const from = last;
      last = next;
      running?.stop();
      if (reduce) return height.set(next);
      if (height.get() === "auto") height.set(from);
      running = animate(height, next, { duration: 0.12, ease: ease.out });
    });
    ro.observe(el);
    return () => {
      ro.disconnect();
      running?.stop();
    };
  }, [height, reduce]);

  /* ----- attachments ----- */
  const addFiles = (list: FileList | File[]) => {
    const picked = Array.from(list);
    if (!picked.length) return;
    const tooBig = maxFileSize ? picked.filter((f) => f.size > maxFileSize) : [];
    const ok = picked.filter((f) => !tooBig.includes(f));
    if (tooBig.length) {
      const limit = formatBytes(maxFileSize!);
      say(tooBig.length === 1 ? `${tooBig[0].name} is over ${limit}. Choose a smaller file.` : `${tooBig.length} files are over ${limit}. Choose smaller files.`);
    }
    if (!ok.length) return;
    const added = ok.map<ComposerAttachment>((file) => {
      const preview = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
      if (preview) madeUrls.current.add(preview);
      return {
        id: nextId(),
        name: file.name || "Pasted image",
        size: file.size,
        type: file.type,
        file,
        preview,
      };
    });
    setFiles((prev) => [...prev, ...added]);
    areaRef.current?.focus({ preventScroll: true });
  };

  const removeFile = (id: string) => {
    const gone = files.find((f) => f.id === id);
    if (gone?.preview && madeUrls.current.has(gone.preview)) {
      URL.revokeObjectURL(gone.preview);
      madeUrls.current.delete(gone.preview);
    }
    setFiles((prev) => prev.filter((f) => f.id !== id));
    areaRef.current?.focus({ preventScroll: true });
  };

  /* ----- send ----- */
  const trimmed = text.trim();
  const over = maxLength != null && text.length > maxLength;
  const near = maxLength != null && text.length >= maxLength * 0.9;
  const hasContent = trimmed.length > 0 || files.length > 0;
  const canSend = hasContent && !over && !disabled;
  const showMic = !!onVoice && !hasContent;

  const send = () => {
    if (!canSend) return;
    onSend({ text: trimmed, attachments: files });
    // Sent previews now belong to the message; stop tracking them so they aren't revoked.
    for (const f of files) if (f.preview) madeUrls.current.delete(f.preview);
    setText("");
    setFiles([]);
    if (draftStorageKey) storage.write(draftStorageKey, "");
    stopTyping();
    setNotice(null);
    areaRef.current?.focus({ preventScroll: true });
  };

  const startRecording = () => {
    setNotice(null);
    void recorder.start();
  };

  const finishRecording = async () => {
    const clip = await recorder.stop();
    if (!clip || clip.duration < 1) {
      if (clip) URL.revokeObjectURL(clip.url);
      say("Too short to send. Record for at least a second.");
    } else onVoice?.(clip);
  };

  const cancelRecording = () => recorder.cancel();

  // When a recording ends either way, the caret goes back to the text. It waits for the
  // row to stop being inert, because an inert field can't take focus.
  const wasRecording = useRef(false);
  useEffect(() => {
    if (wasRecording.current && !recording) areaRef.current?.focus({ preventScroll: true });
    wasRecording.current = recording;
  }, [recording]);

  const counter = maxLength != null && near && !recording;
  const noticeText = notice?.text ?? (counter ? (over ? `${text.length - maxLength!} over the ${maxLength} character limit` : `${maxLength! - text.length} characters left`) : "");

  const iconButton = cn(
    "relative grid size-8 shrink-0 place-items-center rounded-full text-fg-3 outline-none",
    "transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75",
    "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
    "disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-hover data-popup-open:text-fg",
    // 32px circles; on touch the target grows to 44px without changing the drawing.
    "before:absolute before:-inset-1.5 before:rounded-full before:content-[''] pointer-fine:before:hidden",
  );

  return (
    <form
      noValidate
      onSubmit={(e) => {
        e.preventDefault();
        send();
      }}
      data-disabled={disabled || undefined}
      data-state={recording ? "recording" : hasContent ? "ready" : "empty"}
      className={cn("flex w-full min-w-0 flex-col gap-1.5", className)}
      {...rest}
    >
      <Tooltip.Provider delay={500} closeDelay={0}>
        <div
          onDragEnter={(e) => {
            if (disabled || recording || !e.dataTransfer.types.includes("Files")) return;
            e.preventDefault();
            dragDepth.current++;
            setDragging(true);
          }}
          onDragOver={(e) => {
            if (dragging) e.preventDefault();
          }}
          onDragLeave={() => {
            dragDepth.current = Math.max(0, dragDepth.current - 1);
            if (!dragDepth.current) setDragging(false);
          }}
          onDrop={(e) => {
            if (!dragging) return;
            e.preventDefault();
            dragDepth.current = 0;
            setDragging(false);
            addFiles(e.dataTransfer.files);
          }}
          onMouseDown={(e) => {
            // A press on the box's padding puts the caret in the text rather than doing nothing.
            if (e.target === e.currentTarget && !recording) {
              e.preventDefault();
              areaRef.current?.focus();
            }
          }}
          className={cn(
            "relative flex min-w-0 flex-col rounded-[20px] border bg-raised shadow-[var(--shadow)]",
            "transition-[border-color,box-shadow,opacity] duration-150 ease-out",
            "has-[textarea:focus-visible]:ring-3",
            over
              ? "border-danger/50 has-[textarea:focus-visible]:border-danger/60 has-[textarea:focus-visible]:ring-danger/12"
              : cn(dragging ? "border-fg-3" : "border-line-2 hover:border-fg-4", "has-[textarea:focus-visible]:border-fg-4 has-[textarea:focus-visible]:ring-fg/8"),
            disabled && "pointer-events-none opacity-50",
          )}
        >
          {header}

          {/* Attachments: a strip that opens above the text and closes when the last one goes. */}
          <AnimatePresence initial={false}>
            {files.length > 0 && !recording && (
              <motion.div
                key="strip"
                initial={{ height: 0, opacity: 0 }}
                animate={{ height: "auto", opacity: 1 }}
                exit={{
                  height: 0,
                  opacity: 0,
                  transition: { duration: reduce ? 0 : 0.16, ease: ease.inOut },
                }}
                transition={
                  reduce
                    ? { duration: 0 }
                    : {
                        height: { duration: 0.22, ease: ease.out },
                        opacity: { duration: 0.16 },
                      }
                }
                className="overflow-hidden"
              >
                <ul aria-label="Attachments" className="flex gap-2 overflow-x-auto overscroll-x-contain px-2 pb-1 pt-2.5 [scrollbar-width:none]">
                  <AnimatePresence initial={false} mode="popLayout">
                    {files.map((f) => (
                      <Chip key={f.id} file={f} reduce={reduce} onRemove={() => removeFile(f.id)} />
                    ))}
                  </AnimatePresence>
                </ul>
              </motion.div>
            )}
          </AnimatePresence>

          <div className="relative flex min-w-0 items-end gap-0.5 p-1">
            {/* The text row stays mounted under the recorder, so the draft, the caret and the
                measured height are all still there when recording ends. */}
            <AnimatePresence initial={false}>
              {recording && (
                <RecordingRow
                  key="rec"
                  reduce={reduce}
                  starting={recorder.state === "starting"}
                  levels={recorder.levels}
                  elapsed={recorder.elapsed}
                  onCancel={cancelRecording}
                  onSend={finishRecording}
                  iconButton={iconButton}
                  container={container}
                />
              )}
            </AnimatePresence>
            <motion.div
              inert={recording || undefined}
              className="flex min-w-0 flex-1 items-end gap-0.5"
              initial={false}
              animate={recording ? { opacity: 0, filter: reduce ? "blur(0px)" : "blur(2px)" } : { opacity: 1, filter: "blur(0px)" }}
              transition={{ duration: recording ? 0.1 : 0.18, ease: ease.out }}
            >
              <Tip label="Attach files" container={container}>
                <button type="button" aria-label="Attach files" disabled={disabled} onClick={() => fileRef.current?.click()} className={iconButton}>
                  <Paperclip className="transition-transform duration-200 ease-out-expo group-hover:rotate-0" />
                </button>
              </Tip>
              <input
                ref={fileRef}
                type="file"
                multiple
                accept={accept}
                tabIndex={-1}
                aria-hidden
                className="hidden"
                onChange={(e) => {
                  if (e.target.files) addFiles(e.target.files);
                  e.target.value = "";
                }}
              />

              {/* The replica sets the height; the textarea fills it. Both share every text metric. */}
              <motion.div style={{ height }} className="relative min-w-0 flex-1 overflow-hidden">
                <div
                  ref={replicaRef}
                  aria-hidden
                  style={{ maxHeight }}
                  className="invisible overflow-hidden whitespace-pre-wrap px-2 py-1.5 text-base leading-5 [overflow-wrap:anywhere] sm:text-[13px]"
                >
                  {text + " "}
                </div>
                <textarea
                  ref={areaRef}
                  value={text}
                  rows={1}
                  disabled={disabled}
                  autoFocus={autoFocus}
                  placeholder={placeholder}
                  aria-label={label}
                  aria-invalid={over || undefined}
                  aria-describedby={noticeText ? noticeId : undefined}
                  aria-keyshortcuts={coarse ? undefined : "Enter"}
                  enterKeyHint={coarse ? "enter" : "send"}
                  autoComplete="off"
                  onChange={(e) => {
                    setText(e.target.value);
                    if (e.target.value) markTyping();
                    else stopTyping();
                    if (notice) setNotice(null);
                  }}
                  onBlur={stopTyping}
                  onPaste={(e) => {
                    if (e.clipboardData.files.length) {
                      e.preventDefault();
                      addFiles(e.clipboardData.files);
                    }
                  }}
                  onKeyDown={(e) => {
                    if (e.nativeEvent.isComposing || e.keyCode === 229) return;
                    if (e.key !== "Enter") return;
                    // Enter sends on a keyboard; on a phone it's a new line and the button sends.
                    const mod = e.metaKey || e.ctrlKey;
                    if (e.shiftKey || e.altKey || (coarse && !mod)) return;
                    e.preventDefault();
                    send();
                  }}
                  className={cn(
                    "absolute inset-0 block size-full resize-none bg-transparent px-2 py-1.5 text-base leading-5 text-fg outline-none [overflow-wrap:anywhere] sm:text-[13px]",
                    "placeholder:text-fg-4 disabled:cursor-not-allowed",
                    // Past the last row it scrolls, and the top edge fades so a cut line reads as "more above".
                    atMax ? "overflow-y-auto overscroll-contain [mask-image:linear-gradient(to_bottom,transparent,black_14px)]" : "overflow-y-hidden",
                  )}
                />
              </motion.div>

              <Popover.Root open={emojiOpen} onOpenChange={setEmojiOpen}>
                <Tip label="Emoji" container={container} disabled={emojiOpen}>
                  <Popover.Trigger aria-label="Add emoji" disabled={disabled} className={cn(iconButton, "group/emoji")}>
                    <SmileGlyph />
                  </Popover.Trigger>
                </Tip>
                <Popover.Portal container={container}>
                  <Popover.Positioner side="top" align="end" sideOffset={10} collisionPadding={8} className="z-(--z-popover)">
                    <Popover.Popup
                      finalFocus={() => {
                        // After a pick the caret goes back to the text; Escape returns to the button.
                        if (pickedEmoji.current) {
                          pickedEmoji.current = false;
                          return areaRef.current;
                        }
                        return true;
                      }}
                      className={cn(
                        "rounded-xl border border-line-2 bg-raised p-1.5 text-fg shadow-pop outline-none",
                        "origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-180 ease-out-expo",
                        "data-starting-style:scale-[0.94] data-starting-style:opacity-0 data-starting-style:blur-[2px] data-[side=top]:data-starting-style:translate-y-1",
                        "data-ending-style:scale-[0.97] data-ending-style:opacity-0 data-ending-style:duration-120 data-ending-style:ease-out-quart",
                        "data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:blur-none",
                      )}
                    >
                      <Popover.Title className="sr-only">Emoji</Popover.Title>
                      <EmojiGrid
                        emojis={emojis}
                        onPick={(emoji) => {
                          pickedEmoji.current = true;
                          setEmojiOpen(false);
                          const el = areaRef.current;
                          if (el) insertAtCaret(el, emoji);
                        }}
                      />
                    </Popover.Popup>
                  </Popover.Positioner>
                </Popover.Portal>
              </Popover.Root>

              <SendButton
                mic={showMic}
                ready={canSend}
                disabled={disabled || (!showMic && !canSend)}
                reduce={reduce}
                shortcut={coarse ? undefined : "Enter"}
                container={container}
                onMic={startRecording}
              />
            </motion.div>
          </div>

          {/* Drop target: the whole box, said plainly. */}
          <AnimatePresence>
            {dragging && (
              <motion.div
                key="drop"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0, transition: { duration: 0.1 } }}
                transition={{ duration: 0.14 }}
                className="pointer-events-none absolute inset-0 grid place-items-center rounded-[inherit] bg-raised/95"
              >
                <span className="flex items-center gap-2 text-[12.5px] font-medium text-fg">
                  <Paperclip className="text-fg-3" />
                  Drop to attach
                </span>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </Tooltip.Provider>

      {/* One line under the box for anything that needs saying: a refused file, the mic, the limit.
          It opens only when there's something to say, so the composer sits flush the rest of the time. */}
      <div
        className={cn(
          "-mt-1.5 grid transition-[grid-template-rows,margin] duration-200 ease-out-expo motion-reduce:transition-none",
          noticeText ? "mt-0 grid-rows-[1fr]" : "grid-rows-[0fr]",
        )}
      >
        <div className="relative min-h-0 overflow-hidden">
          <AnimatePresence initial={false} mode="popLayout">
            {noticeText && (
              <motion.p
                key={notice ? `n${notice.id}` : "count"}
                id={noticeId}
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, transition: { duration: 0.1 } }}
                transition={{ duration: 0.18, ease: ease.out }}
                className={cn("truncate px-3 text-[11.5px] leading-4 tabular", notice || over ? "text-danger" : "text-fg-3", !notice && "text-right")}
              >
                {noticeText}
              </motion.p>
            )}
          </AnimatePresence>
        </div>
      </div>
      <span role="status" aria-live="polite" className="sr-only">
        {notice?.text ?? (over ? "Over the character limit" : "")}
      </span>
    </form>
  );
}

/* -------------------------------------------------------------------------------------------------
 * The send button: a mic while there's nothing to send, a filled disc the moment there is
 * -----------------------------------------------------------------------------------------------*/

function SendButton({
  mic,
  ready,
  disabled,
  reduce,
  shortcut,
  container,
  onMic,
}: {
  mic: boolean;
  ready: boolean;
  disabled: boolean;
  reduce: boolean;
  shortcut?: string;
  container?: MessageComposerProps["container"];
  onMic: () => void;
}) {
  const filled = ready && !mic;
  const tip = mic ? "Record voice message" : "Send";
  return (
    <Tip label={tip} keys={!mic ? shortcut && "" : undefined} container={container}>
      <button
        type={mic ? "button" : "submit"}
        aria-label={mic ? "Record voice message" : "Send message"}
        aria-keyshortcuts={!mic ? shortcut : undefined}
        disabled={disabled}
        data-state={mic ? "mic" : filled ? "ready" : "empty"}
        onClick={mic ? onMic : undefined}
        className={cn(
          "group/send relative grid size-8 shrink-0 place-items-center rounded-full outline-none",
          "transition-[color,scale] duration-150 ease-out active:scale-[0.9] active:duration-75",
          "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
          "before:absolute before:-inset-1.5 before:rounded-full before:content-[''] pointer-fine:before:hidden",
          filled ? "text-frame" : mic ? "text-fg-3 hover:bg-hover hover:text-fg" : "text-fg-4",
          "disabled:pointer-events-none",
        )}
      >
        {/* The disc fills from the centre when there's something to send, and drains when there isn't. */}
        <motion.span
          aria-hidden
          className="absolute inset-0 rounded-full bg-fg transition-[background-color] duration-150 group-hover/send:bg-fg/90"
          initial={false}
          animate={filled ? { scale: 1, opacity: 1 } : mic ? { scale: 0.4, opacity: 0 } : { scale: 1, opacity: 0.1 }}
          transition={reduce ? { duration: 0.12 } : filled ? spring.pop : { duration: 0.16, ease: ease.out }}
        />
        <span className="relative grid size-4 place-items-center">
          <AnimatePresence initial={false}>
            <motion.span
              key={mic ? "mic" : "send"}
              className="absolute inset-0 grid place-items-center"
              initial={
                reduce
                  ? { opacity: 0 }
                  : {
                      opacity: 0,
                      scale: 0.5,
                      rotate: mic ? 0 : -45,
                      filter: "blur(3px)",
                    }
              }
              animate={{ opacity: 1, scale: 1, rotate: 0, filter: "blur(0px)" }}
              exit={
                reduce
                  ? { opacity: 0 }
                  : {
                      opacity: 0,
                      scale: 0.5,
                      filter: "blur(3px)",
                      transition: { duration: 0.12 },
                    }
              }
              transition={reduce ? { duration: 0.12 } : spring.pop}
            >
              {mic ? <Mic /> : <ArrowUp strokeWidth={1.7} />}
            </motion.span>
          </AnimatePresence>
        </span>
      </button>
    </Tip>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Recording row: cancel, a live clock, bars that scroll in from the right, send
 * -----------------------------------------------------------------------------------------------*/

function RecordingRow({
  reduce,
  starting,
  levels,
  elapsed,
  onCancel,
  onSend,
  iconButton,
  container,
}: {
  reduce: boolean;
  starting: boolean;
  levels: number[];
  elapsed: number;
  onCancel: () => void;
  onSend: () => void;
  iconButton: string;
  container?: MessageComposerProps["container"];
}) {
  const sendRef = useRef<HTMLButtonElement>(null);
  // Focus lands on send once the mic is live, so Enter sends and Escape discards.
  useEffect(() => {
    if (!starting) sendRef.current?.focus({ preventScroll: true });
  }, [starting]);

  return (
    <motion.div
      className="absolute inset-1 z-1 flex min-w-0 items-center gap-0.5"
      initial={reduce ? { opacity: 0 } : { opacity: 0, filter: "blur(2px)" }}
      animate={{ opacity: 1, filter: "blur(0px)" }}
      exit={{ opacity: 0, transition: { duration: 0.1 } }}
      transition={{ duration: 0.18, ease: ease.out }}
      onKeyDown={(e) => {
        if (e.key === "Escape") {
          e.preventDefault();
          onCancel();
        }
      }}
    >
      <Tip label="Discard recording" keys="Esc" container={container}>
        <button type="button" aria-label="Discard recording" onClick={onCancel} className={cn(iconButton, "hover:text-danger")}>
          <Trash />
        </button>
      </Tip>

      <div className="flex h-8 min-w-0 flex-1 items-center gap-2.5 px-1.5">
        <span className="flex shrink-0 items-center gap-1.5 text-[12.5px] text-fg tabular">
          <span aria-hidden className="relative grid size-2 place-items-center">
            <span className="size-2 rounded-full bg-danger" />
            {!starting && <span className="absolute inset-0 rounded-full bg-danger motion-safe:animate-ping-soft" />}
          </span>
          <span className="w-9">{starting ? "0:00" : clockFormat(elapsed)}</span>
        </span>
        {starting ? (
          <span className="truncate text-[12.5px] text-fg-3">Waiting for the microphone…</span>
        ) : (
          <span aria-hidden className="flex h-6 min-w-0 flex-1 items-center justify-end gap-[2px] overflow-hidden">
            {levels.map((l, i) => (
              <span key={levels.length - i} className="w-[2px] shrink-0 rounded-full bg-fg/70" style={{ height: `${Math.max(2, Math.round(2 + l * 20))}px` }} />
            ))}
          </span>
        )}
      </div>

      <Tip label="Send voice message" keys="" container={container}>
        <button
          ref={sendRef}
          type="button"
          aria-label="Send voice message"
          disabled={starting}
          onClick={onSend}
          className={cn(
            "relative grid size-8 shrink-0 place-items-center rounded-full bg-fg text-frame outline-none",
            "transition-[background-color,scale,opacity] duration-150 ease-out hover:bg-fg/90 active:scale-[0.9] active:duration-75 disabled:opacity-40",
            "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
            "before:absolute before:-inset-1.5 before:rounded-full before:content-[''] pointer-fine:before:hidden",
          )}
        >
          <ArrowUp strokeWidth={1.7} />
        </button>
      </Tip>
      <span role="status" aria-live="polite" className="sr-only">
        {starting ? "Waiting for the microphone" : "Recording. Press Enter to send or Escape to discard."}
      </span>
    </motion.div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Attachment chip
 * -----------------------------------------------------------------------------------------------*/

function Chip({ file, reduce, onRemove }: { file: ComposerAttachment; reduce: boolean; onRemove: () => void }) {
  const image = !!file.preview && file.type.startsWith("image/");
  const [base, ext] = splitName(file.name);
  const uploading = file.progress != null && file.progress < 1 && !file.error;
  const [broken, setBroken] = useState(false);

  return (
    <motion.li
      layout={reduce ? false : "position"}
      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85, filter: "blur(2px)" }}
      animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
      exit={
        reduce
          ? { opacity: 0, transition: { duration: 0.1 } }
          : {
              opacity: 0,
              scale: 0.85,
              filter: "blur(2px)",
              transition: { duration: 0.12, ease: ease.in },
            }
      }
      transition={reduce ? { duration: 0.12 } : { ...spring.pop, layout: spring.snappy }}
      data-error={file.error ? "" : undefined}
      className="group/chip relative shrink-0"
    >
      <div
        className={cn(
          "relative flex h-12 items-center overflow-hidden rounded-xl border bg-frame",
          file.error ? "border-danger/50" : "border-line-2",
          image ? "w-12" : "max-w-[220px] gap-2.5 pl-1.5 pr-3",
        )}
      >
        {image && !broken ? (
          // eslint-disable-next-line @next/next/no-img-element -- local object URL preview
          <img src={file.preview} alt="" onError={() => setBroken(true)} className="size-full object-cover" />
        ) : (
          <>
            <span className="grid size-9 shrink-0 place-items-center rounded-lg bg-hover font-mono text-[9.5px] uppercase tracking-[0.04em] text-fg-2">
              {(ext || (image ? "img" : "file")).slice(0, 4)}
            </span>
            {!image && (
              <span className="flex min-w-0 flex-col">
                <span className="flex min-w-0 text-[12.5px] leading-4 text-fg">
                  <span className="truncate">{base}</span>
                  {ext && <span className="shrink-0">.{ext}</span>}
                </span>
                <span className={cn("text-[11px] leading-4 tabular", file.error ? "text-danger" : "text-fg-3")}>
                  {file.error ?? (uploading ? `${Math.round((file.progress ?? 0) * 100)}% of ${formatBytes(file.size)}` : formatBytes(file.size))}
                </span>
              </span>
            )}
          </>
        )}
        {uploading && (
          <span aria-hidden className="absolute inset-x-0 bottom-0 h-[2px] bg-fg/10">
            <span className="block h-full origin-left bg-fg transition-transform duration-200 ease-out" style={{ transform: `scaleX(${file.progress})` }} />
          </span>
        )}
      </div>
      <button
        type="button"
        aria-label={`Remove ${file.name}`}
        onClick={onRemove}
        className={cn(
          "absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] outline-none",
          "transition-[opacity,scale,color,background-color] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.88] active:duration-75",
          "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
          // Hidden until the chip is hovered or focused on a mouse; always there on touch.
          "pointer-fine:opacity-0 pointer-fine:group-hover/chip:opacity-100 pointer-fine:focus-visible:opacity-100",
          "before:absolute before:-inset-3 before:rounded-full before:content-[''] pointer-fine:before:-inset-1",
        )}
      >
        <X size={12} />
      </button>
    </motion.li>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Emoji grid: arrow keys, one tab stop
 * -----------------------------------------------------------------------------------------------*/

const COLUMNS = 8;

function EmojiGrid({ emojis, onPick }: { emojis: { emoji: string; label: string }[]; onPick: (emoji: string) => void }) {
  const [active, setActive] = useState(0);
  const grid = useRef<HTMLDivElement>(null);
  const move = (e: React.KeyboardEvent) => {
    const last = emojis.length - 1;
    const next =
      e.key === "ArrowRight"
        ? Math.min(last, active + 1)
        : e.key === "ArrowLeft"
          ? Math.max(0, active - 1)
          : e.key === "ArrowDown"
            ? Math.min(last, active + COLUMNS)
            : e.key === "ArrowUp"
              ? Math.max(0, active - COLUMNS)
              : e.key === "Home"
                ? 0
                : e.key === "End"
                  ? last
                  : -1;
    if (next < 0) return;
    e.preventDefault();
    setActive(next);
    grid.current?.querySelectorAll<HTMLElement>("[role=gridcell]")[next]?.focus();
  };
  const rows = Array.from({ length: Math.ceil(emojis.length / COLUMNS) }, (_, i) => emojis.slice(i * COLUMNS, i * COLUMNS + COLUMNS));
  return (
    <div ref={grid} role="grid" aria-label="Emoji" onKeyDown={move} className="flex flex-col gap-0.5">
      {rows.map((row, ri) => (
        <div key={ri} role="row" className="flex gap-0.5">
          {row.map((c, ci) => {
            const i = ri * COLUMNS + ci;
            return (
              <button
                key={c.emoji}
                type="button"
                role="gridcell"
                aria-label={c.label}
                tabIndex={i === active ? 0 : -1}
                onFocus={() => setActive(i)}
                onClick={() => onPick(c.emoji)}
                className={cn(
                  "group/cell grid size-8 place-items-center rounded-lg outline-none",
                  "transition-[background-color,scale] duration-150 ease-out hover:bg-hover active:scale-[0.9] active:duration-75",
                  "focus-visible:bg-hover focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4",
                )}
              >
                <span
                  aria-hidden
                  className="text-[18px] leading-none transition-transform duration-200 ease-out-expo group-hover/cell:scale-[1.18] group-focus-visible/cell:scale-[1.18] motion-reduce:transition-none motion-reduce:group-hover/cell:scale-100"
                >
                  {c.emoji}
                </span>
              </button>
            );
          })}
        </div>
      ))}
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Tooltip and the one glyph the shared set doesn't have
 * -----------------------------------------------------------------------------------------------*/

function Tip({
  label,
  keys,
  disabled,
  container,
  children,
}: {
  label: string;
  keys?: string;
  disabled?: boolean;
  container?: MessageComposerProps["container"];
  children: React.ReactElement;
}) {
  return (
    <Tooltip.Root disabled={disabled}>
      <Tooltip.Trigger render={children} />
      <Tooltip.Portal container={container}>
        <Tooltip.Positioner side="top" sideOffset={8} collisionPadding={8} className="z-(--z-tooltip)">
          <Tooltip.Popup
            className={cn(
              "flex items-center gap-2 rounded-md border border-line-2 bg-raised py-1 pl-2 text-[12px] leading-4 text-fg shadow-pop outline-none",
              keys ? "pr-1" : "pr-2",
              "origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-out-expo",
              "data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100",
              "data-instant:transition-none motion-reduce:data-starting-style:scale-100",
            )}
          >
            {label}
            {keys && <kbd className="rounded-[4px] border border-line-2 bg-frame px-1 font-mono text-[10.5px] leading-[14px] text-fg-3">{keys}</kbd>}
          </Tooltip.Popup>
        </Tooltip.Positioner>
      </Tooltip.Portal>
    </Tooltip.Root>
  );
}

function SmileGlyph() {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
      <circle cx="8" cy="8" r="5.75" />
      {/* The smile widens a touch on hover: the button answers before it's pressed. */}
      <path
        d="M5.9 9.4a2.6 2.6 0 0 0 4.2 0"
        className="origin-[8px_9.6px] transition-transform duration-200 ease-out-expo group-hover/emoji:scale-x-[1.18] group-data-popup-open/emoji:scale-x-[1.18]"
      />
      <circle cx="6.1" cy="6.6" r=".7" fill="currentColor" stroke="none" />
      <circle cx="9.9" cy="6.6" r=".7" fill="currentColor" stroke="none" />
    </svg>
  );
}

05Props

MessageComposer

PropTypeDefaultDescription
onSend*(message: { text: string; attachments: ComposerAttachment[] }) => voidCalled with the trimmed text and the attachments. The field clears and keeps focus straight away.
valuestringControlled text.
defaultValuestring""Starting text when uncontrolled.
onValueChange(text: string) => voidEvery edit, including a restored draft and the clear after sending.
attachmentsComposerAttachment[]Controlled attachments. Set progress (0–1) or error on an item to show its upload.
defaultAttachmentsComposerAttachment[][]Starting attachments when uncontrolled.
onAttachmentsChange(attachments: ComposerAttachment[]) => voidFiles added by picker, paste or drop, or removed.
onVoice(clip: VoiceRecording) => voidTurns on voice notes: the button is a mic while the field is empty. Receives the blob, an object URL, the duration and 48 peaks.
onTypingChange(typing: boolean) => voidTrue while typing, false 3s after the last keystroke, on send or on blur. For typing indicators.
placeholderstring"Message"Shown in the empty field.
labelstring"Message"The field's accessible name.
maxLengthnumberSoft limit. A count appears in the last 10%; past it the box turns red and sending stops.
maxRowsnumber8Lines before the field scrolls instead of growing.
acceptstringFile types the picker offers.
maxFileSizenumberBytes. Larger files are refused with a message that names them.
draftKeystringKeeps the draft in localStorage under this key until it's sent.
emojis{ emoji: string; label: string }[]defaultEmojisThe picker's emoji, in order, with names for screen readers.
headerReact.ReactNodeRendered inside the box above the text: a reply preview, an editing banner.
disabledbooleanfalseDims the box and stops input.
containerHTMLElement | RefObject<HTMLElement>Where the emoji picker and tooltips portal.

useVoiceRecorder

PropTypeDefaultDescription
onError(message: string) => voidA plain sentence when the mic is blocked, missing or unsupported. Returns { state, levels, elapsed, start, stop, cancel }; stop resolves with the clip.

06Notes

Behavior

  • Enter sends and Shift+Enter breaks the line on a keyboard; on a touch screen Enter is a new line and the button sends. Nothing sends mid-composition in Chinese or Japanese input.
  • Files come in by picker, paste or drop onto the box. Oversized files are refused by name in a line under the box; image chips show a thumbnail, others the extension and size, and uploads draw a progress hairline.
  • Emoji go in at the caret through the browser's editing pipeline, so undo still works, and the caret returns to the text after a pick.
  • Recording keeps the text row mounted underneath, so the draft and caret survive. A blocked or missing mic says what to do; clips under a second are discarded with a reason.
  • Past the last row the field scrolls and its top edge fades, so a cut line reads as more above.

Motion

  • The send disc fills from the center on the pop spring the moment there's something to send; the mic and arrow swap inside a fixed 16px box with a 45° turn and a 3px blur.
  • The field grows upward in 120ms as lines are added, clipped from the top so the caret line never leaves view.
  • Attachment chips pop in from 0.85 and neighbors slide over on the snappy spring; the strip opens in 220ms. The notice line opens its own space in 200ms instead of reserving it.
  • Reduced motion swaps every transform, blur and spring for 120ms fades and makes the growth instant.

Accessibility

  • A real form: the arrow is a submit button named Send message, with Enter shown in its tooltip and aria-keyshortcuts.
  • Every icon button has a name and a tooltip; remove buttons name their file. The emoji grid is one tab stop with arrow keys, Home and End.
  • Refused files, mic errors and the character count are linked to the field with aria-describedby and announced politely.
  • While recording, focus moves to send: Enter sends, Escape discards, and focus returns to the text afterwards.