Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Chat composer

New

A message field with @-mentions, a tray of attachments, a shortcut tooltip and a menu on the thread’s title.

chat-composer/chat-composer.tsx
Live · interactive
Start new thread with Rosalyn, Command Shift O

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • The message box at the foot of a chat with an assistant or a teammate, where a message can name the tools it should use.
  • Messages that pull in apps by name: @ opens a searchable list, and a pick becomes a token sent as an id, not as text.
  • Conversations that take files: pictures and documents line up in a tray over the field before they are sent.
  • A thread worth managing from where you type: its title, under the composer, opens Rename, Favourite, Share and Delete.

When not to use

  • A single-line search or command box. Use a search field or a command palette.
  • Long, formatted writing (headings, lists, links). Use a rich-text editor; this field keeps plain text and mentions.
  • Comments on a page or a document, with no thread to manage. A plain text area with a send button is enough.
  • Mentioning people from a large directory. The picker filters a short list in memory; a directory needs remote search and paging.
  • Forms where Enter should move to the next field: here Enter sends, and Shift+Enter starts a new line.

You can add this component with the shadcn CLI or by hand:

Into components/chat-composer of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/chat-composer.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

thread-composer.tsx
import { ChatComposer } from './chat-composer';export function ThreadComposer() {  return (    <ChatComposer      autoplay={false}      person={{ name: 'Rosalyn', role: 'Manager' }}      onSend={(message) => console.log(message.text, message.mentions, message.files)}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A chat composer with its tools along the foot and the thread’s title under it. Type @ to pick an app from a searchable list and it becomes a token in the message. Attached files line up in a tray above the field, and the title opens a menu of the person and the thread’s actions.

  • The field is a contentEditable box that React leaves alone. A mention is a small token marked contenteditable false, so the caret steps over it as a whole.
  • Typing @ at the start of a word opens a picker over it, with a search box of its own. The arrow keys and the pointer share one highlight, and picking an app turns the @ into its token.
  • Attached files sit in a grey tray behind the card’s top edge: a picture as a tile, any other file as a card with its name and type. Each one’s cross shows under the pointer or focus, and always on touch screens.
  • The new-thread button shows its name and its keys in a dark tooltip, which keeps itself inside the stage. ⌘⇧O (Ctrl+Shift+O) starts a new thread too.
  • The title is quiet grey text that becomes a pill while its menu is open. The menu lines up with the title’s right end and holds the person and a way to their profile, then Rename (the title turns into a field), Favourite, Share and Delete, which turns red when highlighted.
  • Every interface colour is a light-dark() token on the root, so the composer follows the page’s color-scheme, or the colorScheme prop; the app glyphs and the attached portrait are pictures and keep their colours, and the demo’s I-beam turns light at night.
PropTypeDefaultDescription
person{ name: string; role: string }{ name: 'Rosalyn', role: 'Manager' }The person the thread is with, shown in the menu and the tooltip.
appsMentionApp[]APPSThe apps a message can mention, each with an id, a name and a 16 × 16 glyph (apps.tsx).
defaultTitlestring'Meeting Preparation and Agenda Planning'The thread’s title, under the composer.
placeholderstring'Ask anything...'Shown in the empty field.
defaultStateChatComposerState—Opens in a given state, with the demo off: mentions and text in the field, the mention picker, attachments (one hovered), the tooltip, the thread’s menu, renaming, favourite or task.
onSend(message: ComposerMessage) => void—Called with the text, the ids mentioned and the files when the message is sent.
onThreadAction(action: ThreadAction | 'new', title: string) => void—Called with the action and the title after Profile, Rename, Favourite, Share or Delete, or with “new” for a new thread.
autoplaybooleantrueWhile on screen and untouched, a ghost pointer goes through the tooltip and the menu, a mention, and the attachments.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

chat-composer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,} from 'react';import { APPS, type MentionApp } from './apps';import { Attachments, type Attachment } from './attachments';import {  atBeforeCaret,  insertMention,  isBlank,  lastAt,  placeCaret,  readMessage,  typeAtEnd,  type MentionAnchor,
chat-composer.module.css
/* Chat composer (chat-composer.tsx): a stage with the composer near its foot and the thread's   title under it; menus, the picker and the tooltip open upward over the stage.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). The apps' glyphs and the attached portrait are   pictures, and keep their colours on both.   Memory. The stage is one layout boundary of a fixed height. A phone keeps a page's pixels in   tiles the width of the screen, about 7 MB each, and a tile painted again gets more buffers, so   nothing the demo changes paints on the page itself. The dock is a layer that paints nothing of   its own, and each part of it paints on a layer of its own: the card (which typing repaints),   the title, the tray, the tooltip, the menu and the picker; the carets blink on tiny layers.   Each keeps one size while it is shown, since a layer that changes size gets new buffers. While   the demo plays, the picker's list and the tray clip rather than scroll: on a phone a box that   scrolls gets layers of its own, drawn again at every highlight. No style reaches an icon while   the demo plays: highlights change backgrounds and text colours, never the colour an icon   inherits, since Safari lays out every drawing a style change reaches, and two at once make it   lay out the whole page. */.root {  /* The stage and its edge; the card, a step up, its ring (darker while it has focus) and its     lift; the popovers, another step up, their ring and their shadows. */  --surface: #fbfbfb;
editor.ts
/* * The message field is a contenteditable box: plain text, plus mentions, which are small tokens * the caret steps over as a whole (contenteditable="false"). These helpers read and change it * directly; React does not render its content. *//** Where an "@" was typed: its text node and its offset in it. */export type MentionAnchor = { node: Text; offset: number };/** The "@" just before the caret, if the caret sits right after one at the start of a word. */export function atBeforeCaret(field: HTMLElement): MentionAnchor | null {  const selection = window.getSelection();  const node = selection?.anchorNode;  if (!selection?.isCollapsed || !node || node.nodeType !== Node.TEXT_NODE) return null;  if (!field.contains(node)) return null;  const offset = selection.anchorOffset - 1;  const text = node.textContent ?? '';  if (offset < 0 || text[offset] !== '@') return null;  if (offset > 0 && !/\s/.test(text[offset - 1])) return null;  return { node: node as Text, offset };}/** Puts the caret at `offset` in `node`. */export function placeCaret(node: Node, offset: number) {
mention-picker.tsx
'use client';import { useEffect, useId, useRef, type Ref } from 'react';import type { MentionApp } from './apps';import styles from './chat-composer.module.css';/* * The mention picker: a small popover over the message field with a search box and the apps that * match it. The search box is a combobox: the arrow keys move through the list, Enter mentions * the app in view, Escape closes the picker and puts the caret back after the "@". A row the * pointer moves over is the one in view, so the pointer and the keys share one highlight. *//** The picker's width, in px; the composer keeps it inside the field's box. */export const PICKER_WIDTH = 201;/** The apps whose names contain `query`, ignoring case. */export const matchesOf = (apps: MentionApp[], query: string) => {  const needle = query.trim().toLowerCase();  return needle ? apps.filter((app) => app.name.toLowerCase().includes(needle)) : apps;};export function MentionPicker({  apps,
thread-menu.tsx
'use client';import { useId, useRef, type ReactNode } from 'react';import { RenameIcon, ShareIcon, StarIcon, TrashIcon } from './icons';import styles from './chat-composer.module.css';/* * The thread's menu, opened from its title: the person the thread is with (a way to their * profile), then the thread's actions. It is a menu: the arrow keys, Home and End move between * its items, Enter or Space runs one, Escape closes it. One highlight serves the keys and the * pointer, and Delete turns red under it. */export type ThreadAction = 'profile' | 'rename' | 'favourite' | 'share' | 'delete';/** The menu's items, in order: the person first. */export const MENU_ITEMS: ThreadAction[] = ['profile', 'rename', 'favourite', 'share', 'delete'];const ACTIONS: { action: Exclude<ThreadAction, 'profile'>; label: string; icon: ReactNode }[] = [  { action: 'rename', label: 'Rename', icon: <RenameIcon /> },  { action: 'favourite', label: 'Favourite', icon: <StarIcon /> },  { action: 'share', label: 'Share', icon: <ShareIcon /> },  { action: 'delete', label: 'Delete', icon: <TrashIcon /> },];
attachments.tsx
'use client';import { AvatarPicture } from './avatar';import { CrossIcon, FileIcon, PlusIcon } from './icons';import styles from './chat-composer.module.css';/* * The files attached to the message, in a grey tray over the field: a picture shows as a square * tile, any other file as a card with its name and type. Each one's cross, at its corner, shows * under the pointer or the keyboard's focus (always, on touch screens); the dashed tile adds more. */export type Attachment = {  id: string;  name: string;  /** "PDF", "PNG"…: shown under a file's name. */  label: string;  /** A picture's address; a picture without one shows the illustrated portrait. */  url?: string;  picture: boolean;};export function Attachments({  items,
apps.tsx
import type { ReactNode } from 'react';/* * The apps a message can mention, each with a small square glyph drawn here. The names and the * glyphs are made up for the demo; pass your own through the composer's `apps` prop. */export type MentionApp = {  id: string;  name: string;  /** A 16 × 16 glyph. */  glyph: ReactNode;};const square = { viewBox: '0 0 16 16', 'aria-hidden': true } as const;/** Two nodes on a looping branch, dark on white. */function LoomGlyph() {  return (    <svg {...square}>      <rect x=".5" y=".5" width="15" height="15" rx="3.5" fill="#fff" stroke="#e4e4e4" />      <path        d="M5.4 4.6v6.8M5.4 8.8c0-2 1.4-2.8 3.2-2.8h1.2"        fill="none"
avatar.tsx
import { useId } from 'react';/* * A picture attached to a message: an illustrated portrait (a man with glasses, a short beard and * a dark jumper on a sand-coloured ground), drawn once with soft gradients. */export function AvatarPicture({ className }: { className?: string }) {  const id = useId();  return (    <svg className={className} viewBox="0 0 64 64" aria-hidden="true">      <defs>        <radialGradient id={`${id}-ground`} cx="0.5" cy="0.35" r="0.75">          <stop offset="0" stopColor="#f1ece2" />          <stop offset="1" stopColor="#d9d0c0" />        </radialGradient>        <radialGradient id={`${id}-skin`} cx="0.42" cy="0.35" r="0.7">          <stop offset="0" stopColor="#ffd9a6" />          <stop offset="0.65" stopColor="#efb878" />          <stop offset="1" stopColor="#d89a5c" />        </radialGradient>        <linearGradient id={`${id}-jumper`} x1="0" y1="0" x2="0" y2="1">          <stop offset="0" stopColor="#3a342e" />          <stop offset="1" stopColor="#1b1815" />
icons.tsx
/* * The composer's icons: outlined, drawn on a 16 × 16 grid with round caps, in the current colour. * They are decorative; the controls that hold them carry the labels. */type IconProps = { className?: string };const outline = {  viewBox: '0 0 16 16',  fill: 'none',  stroke: 'currentColor',  strokeWidth: 1.3,  strokeLinecap: 'round' as const,  strokeLinejoin: 'round' as const,  'aria-hidden': true,};/** A paper clip. */export function ClipIcon({ className }: IconProps) {  return (    <svg className={className} {...outline}>      <path d="m13.2 7.4-5.4 5.4a3.3 3.3 0 0 1-4.7-4.7l5.6-5.6a2.2 2.2 0 0 1 3.1 3.1l-5.5 5.5a1.1 1.1 0 0 1-1.6-1.6l5-5" />    </svg>  );
ghost-pointer.tsx
'use client';import type { Ref } from 'react';import styles from './ghost-pointer.module.css';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. The pointer takes the shape a real one would: an arrow, a hand over buttons, an * I-beam over text. Its shapes are drawn once, each in a small layer of its own, and swapped by * scaling those layers to nothing and back: a change of opacity or of a rule matched through the * pointer would reach the drawings, and Safari would lay out the whole page again. Stopping the * demo (the reader takes over, or it scrolls away) ends the step in progress at once: its promise * rejects, which ends the script right there, and the pointer fades. */export type Shape = 'arrow' | 'hand' | 'text';/** A point in the stage's own pixels. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;
ghost-pointer.module.css
/* The demo's pointer (ghost-pointer.tsx): an arrow, a hand over buttons, an I-beam over text. Its   tip sits at the point it is moved to. Each shape sits in a small layer of its own, inside a layer   that paints nothing, so moving or fading the pointer never repaints the page. Its colours are   the stage's tokens (chat-composer.module.css). */.ghost {  position: absolute;  z-index: 40;  top: 0;  left: 0;  width: 0;  height: 0;  pointer-events: none;  opacity: 0;  transition: opacity 0.3s ease;  will-change: transform;}.shape {  position: absolute;  width: 22px;  height: 22px;  /* The script shows one shape at a time by its scale. */  transform: scale(0);  will-change: transform;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The field is a multiline textbox: Enter sends and Shift+Enter starts a new line. The picker’s search box is a combobox with aria-activedescendant over a listbox, and Escape closes it and puts the caret back after the @.
  • The title is a menu button. Enter or the arrow keys open the menu and focus its first item, the arrow keys, Home and End move through it, and Escape returns focus to the title. Favourite is a menuitemcheckbox.
  • The new-thread button carries its shortcut in aria-keyshortcuts and a description, and its tooltip shows on keyboard focus as well as hover.
  • Sending, mentions, attaching, removing files and thread actions are announced in a polite live region, and removing a file moves focus to the next cross. Under reduced motion the demo never plays.