ChatMessage
A conversation where the user speaks in bubbles and the answer runs open beside its mark.
- Why did churn jump in March?
- 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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| ChatThreadcomponent | component | The ordered list around the turns, and the LazyMotion boundary for them. |
| from"user" | "assistant" | "user" | "assistant" | Which side is speaking. |
| avatarReactNode | ReactNode | The assistant's mark. Defaults to a spark on an ink square. |
| attachmentsstring[] | string[] | File names, shown as chips under a user's turn. |
| ChatThinkingcomponent | component | The 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.
"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>
);
}
/**
* 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 },
};
}