← All components

ChatMessage

A conversation where the user speaks in bubbles and the answer runs open beside its mark.

  1. Why did churn jump in March?
  2. Reading the question

The idea

The two sides of a conversation are not the same kind of text, so they should not wear the same shape. A question is short and the reader's own, so it sits in a bubble. An answer is long and structured — headings, paragraphs, a chart, a trace — and a bubble around a chart is a box around a box. So the answer runs open, and each turn arrives with a fade and 4px of rise, enough to say new without the list seeming to scroll by itself.

Composes ReasoningTrace.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

ChatMessage — usage16 lines
import { ChatThread, ChatMessage, ChatThinking, ReasoningTrace } from "mihirmodi-ui";

<ChatThread>
  <ChatMessage from="user" attachments={["q3-board-memo.pdf"]}>
    Why did churn jump in March?
  </ChatMessage>

  {waiting ? (
    <ChatThinking>Reading the question</ChatThinking>
  ) : (
    <ChatMessage from="assistant">
      <ReasoningTrace steps={steps} revealed={revealed} />
      <p>Most of it is seasonal, not a loss.</p>
    </ChatMessage>
  )}
</ChatThread>

Props

PropNotes
ChatThreadcomponentThe ordered list around the turns, and the LazyMotion boundary for them.
from"user" | "assistant"Which side is speaking.
avatarReactNodeThe assistant's mark. Defaults to a spark on an ink square.
attachmentsstring[]File names, shown as chips under a user's turn.
ChatThinkingcomponentThe live-region line that holds the place of an answer that has not started.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/Chat.tsx96 lines
"use client";

import type { ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { enter } from "../lib/motion.js";
import { FileIcon, SparkleIcon } from "../lib/glyph.js";

/**
 * A conversation: an ordered list of messages, newest last.
 *
 * It is a list and not a stack of divs because it is one — a screen reader
 * announces how many turns there are and lets you move between them.
 */
export function ChatThread({ children, className = "" }: { children: ReactNode; className?: string }) {
  return (
    <LazyMotion features={domAnimation} strict>
      <ol className={`flex flex-col gap-6 ${className}`}>{children}</ol>
    </LazyMotion>
  );
}

export type ChatMessageProps = {
  /** Who is speaking. The user's turn is a bubble on the right; the assistant's is open text beside a mark. */
  from: "user" | "assistant";
  children: ReactNode;
  /** The assistant's mark. Defaults to a spark on an ink square. */
  avatar?: ReactNode;
  /** File names shown as chips under a user's message. */
  attachments?: string[];
  className?: string;
};

/**
 * One turn.
 *
 * The two sides are deliberately unlike each other. The user's words are short
 * and theirs, so they sit in a bubble. The assistant's are long and structured —
 * a heading, paragraphs, a chart, a trace — and a bubble around a chart is a box
 * around a box, so the answer runs open beside its mark instead.
 *
 * The bubble is a translucent grey rather than a solid step, so it reads on the
 * page, on a grey stage and on glass alike — a solid gray-100 vanishes on a
 * glass panel, which is exactly where a thread tends to be shown.
 *
 * Each turn arrives with a fade and 4px of rise, which is enough to say "new"
 * without the list appearing to scroll on its own.
 */
export function ChatMessage({ from, children, avatar, attachments, className = "" }: ChatMessageProps) {
  const reduce = useReducedMotion();
  const anim = enter(reduce);

  if (from === "user") {
    return (
      <m.li {...anim} className={`flex flex-col items-end gap-1.5 ${className}`}>
        <div className="max-w-[85%] rounded-2xl rounded-br-[4px] bg-gray-alpha-200 px-4 py-2.5 text-[14px] leading-[1.55] text-gray-1000">
          {children}
        </div>
        {attachments && attachments.length > 0 && (
          <ul className="flex flex-wrap justify-end gap-1.5" aria-label="Attached files">
            {attachments.map((n) => (
              <li
                key={n}
                className="inline-flex h-6 items-center gap-1.5 rounded-full border-[0.5px] border-gray-alpha-400 px-2.5 text-[12px] text-gray-900"
              >
                <FileIcon /> {n}
              </li>
            ))}
          </ul>
        )}
      </m.li>
    );
  }

  return (
    <m.li {...anim} className={`flex gap-3 ${className}`}>
      <span aria-hidden className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-[7px] bg-ink text-background-100">
        {avatar ?? <SparkleIcon className="h-3 w-3" />}
      </span>
      <div className="min-w-0 flex-1 text-[14px] leading-[1.6] text-gray-1000">{children}</div>
    </m.li>
  );
}

/**
 * The line that holds the place of an answer that has not started yet — "Acme
 * is reading the question". It is a live region, so the wait is announced, and
 * it breathes rather than spins.
 */
export function ChatThinking({ children, className = "" }: { children: ReactNode; className?: string }) {
  return (
    <li className={`flex items-center gap-2 text-[13px] text-quiet ${className}`} aria-live="polite">
      <span aria-hidden className="thinking-dot inline-block h-1.5 w-1.5 rounded-full bg-gray-700" />
      {children}
    </li>
  );
}
packages/ui/src/lib/motion.ts36 lines
/**
 * The motion presets the newer components share. The older ones (Dock,
 * DropMenu, HighlightList) carry their own copies of the same numbers, which is
 * deliberate: each of them documents why its curve is what it is, in place.
 */

/** One slide for anything that re-targets its position. */
export const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4 } as const;

/** Something arriving that should land with a small settle. */
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;

/** The disagreeing pair behind squash-and-stretch: see DropMenu. */
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;

/** Closing is quick and flat. A bounce on the way out reads as indecision. */
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;

/** Under reduced motion: a short plain fade, nothing that travels. */
export const FLAT = { duration: 0.15 } as const;

/** Expo-out, for anything that also drives a blur — it must not overshoot. */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;

/** The house curve: overshoots a touch, for transforms only. */
export const EASE_GEIST = [0.175, 0.885, 0.32, 1.1] as const;

/** A block entering a list: fade and 4px. Opacity never springs. */
export function enter(reduce: boolean | null) {
  return {
    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
    animate: { opacity: 1, y: 0 },
    transition: reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST },
  };
}