One turn of a conversation, with its whole life designed.
Every shadcn style · one file
v2.0 · Updated Oct 13pnpm dlx shadcn@latest add https://notchset.dev/r/message.jsonpnpm dlx shadcn@latest add https://notchset.dev/r/message.jsonFree, no account or key. One command for every shadcn style. With the @notchset registry added, @notchset/<name> works too.
Works with React 18.3 or 19 on Tailwind CSS v4, in any shadcn project. What's tested
pnpm add lucide-react@^1The CLI adds the styles to the CSS file named in components.json (tailwind.css). Check that it points at your global stylesheet, that the project uses Tailwind CSS v4, and that the file is imported in your root layout. Installed by hand: paste styles.css into that file.
Import it and pass your content. The props that matter most are listed next.
"use client";
import * as React from "react";
import { Message, type ReplyStatus } from "@/components/ui/notchset/message";
import type { UIMessage } from "@/lib/notchset/ui-message";
const ASK: UIMessage = { id: "u1", role: "user", parts: [{ type: "text", text: "Give me three subject lines for the launch email." }] };
const ANSWERS = [
"Three options, from plain to playful:\n\n1. **Setup now takes 9 minutes**\n2. Bring your team in first, sort billing later\n3. The 12-step checklist is gone\n\nThe first one tested best with admins in the beta.",
"Here are three more, shorter:\n\n1. **Setup, in 9 minutes**\n2. Invite first, bill later\n3. Goodbye, checklist",
];
export default function MessageDemo() {
/* each answer is a version of the reply, kept; Try again adds one */
const [versions, setVersions] = React.useState<{ words: number; status: ReplyStatus; ms?: number }[]>([{ words: 999, status: "done" }]);
const [at, setAt] = React.useState(0);
const [started, setStarted] = React.useState(() => Date.now());
const v = versions[at]!;
const all = ANSWERS[at % ANSWERS.length]!.match(/\s*\S+/g) ?? [];
/* the sample streams a word every 60 ms; Stop (or Esc) ends it where it is */
React.useEffect(() => {
if (v.status !== "submitted" && v.status !== "streaming") return;
const t = setTimeout(() => {
setVersions((vs) => vs.map((x, i) => (i !== at ? x : x.words + 1 >= all.length ? { words: all.length, status: "done" } : { words: x.words + 1, status: "streaming" })));
}, v.status === "submitted" ? 700 : 60);
return () => clearTimeout(t);
}, [v, at, all.length]);
const stop = React.useCallback(() => {
setVersions((vs) => vs.map((x, i) => (i === at && (x.status === "streaming" || x.status === "submitted") ? { ...x, status: "stopped", ms: Date.now() - started } : x)));
}, [at, started]);
React.useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && !e.isComposing && stop();
addEventListener("keydown", key);
return () => removeEventListener("keydown", key);
}, [stop]);
const retry = () => {
setVersions((vs) => [...vs, { words: 0, status: "submitted" }]);
setAt(versions.length);
setStarted(Date.now());
};
const reply: UIMessage = { id: `a${at}`, role: "assistant", parts: [{ type: "text", text: all.slice(0, v.words).join("") }] };
return (
<div className="flex max-w-[640px] flex-col gap-5">
<Message message={ASK} />
<Message
message={reply}
status={v.status}
startedAt={started}
stoppedAfterMs={v.ms}
model={{ purpose: "Balanced", name: "Sample reply", thoughtForMs: at ? undefined : 2000 }}
versions={{ index: at, count: versions.length, list: versions.map((_, i) => ({ id: String(i), label: i === 0 ? "Three options, from plain…" : "Here are three more…", time: i === 0 ? "4:02 pm" : "4:05 pm" })), onPick: setAt }}
onStop={stop}
onContinue={() => setVersions((vs) => vs.map((x, i) => (i === at ? { ...x, status: "streaming" } : x)))}
onRetry={retry}
/>
</div>
);
}components/ui/notchset/message.tsx
message–
UIMessageRequired.
status"done"
ReplyStatusan assistant reply's life; a message of yours is always done
model–
ModelLinewhat answered: every assistant reply has one
startedAt–
numberwhen the reply began (ms), for "Still working" and "Stopped after"
stoppedAfterMs–
numberhow long it ran before it was stopped
error–
stringwhy it failed, in words; what you asked is kept
versions–
{ index: number; count: number; list?: Version[] | undefined; onPick: (index: number) => void }memory–
{ count: number; onOpen?: (() => void) | undefined }"Used 2 things it remembers", opening them
receipt–
React.ReactNodea receipt (blocks/receipt) in place of the model line and memory words: what stands behind the answer
onStop–
() => voidonContinue–
() => voidonRetry–
() => voidonEdit–
() => voidonCopied–
() => voidcalled after the text was copied (the piece copies it)
actions–
React.ReactNodea finished reply's own verbs after Copy and Try again (thumbs, Branch to new chat)
cite–
(n: number) => React.ReactNodedraws a citation marker "[n]" in the text (Sources' Citation); without it markers are left as written
name"Assistant"
stringthe assistant's name, read by screen readers ("Assistant")
Give your coding assistant the Markdown guide instead of screenshots.
https://notchset.dev/api/mcp (setup)v2.0.0 · 2026-10-13
Released with the Notchset system.
The same file, at work in a finished app. Open it live and find it on its pages.
Agent UI. An assistant people trust: ask, pick a model, watch the answer stream in with its receipt, and approve what it does.
4 free · 6 ProSee the Agent UI kit