npm install react-aria-components
"use client";
import type { ReactNode } from "react";
import { Button, type ButtonProps } from "react-aria-components";
const join = (...classes: (string | undefined | false)[]) => classes.filter(Boolean).join(" ");
const styles = {
root: "group/message flex w-full gap-(--tui-message-gap) font-(family-name:--tui-font) text-(length:--tui-message-font-size) text-(--tui-message-fg)",
avatar: "flex size-(--tui-message-avatar-size) shrink-0 items-center justify-center overflow-hidden rounded-(--tui-message-avatar-radius) bg-(--tui-message-avatar-bg) text-(--tui-message-avatar-fg)",
header: "flex items-baseline gap-2 leading-none",
name: "text-(length:--tui-message-font-size) font-(--tui-message-name-weight)",
meta: "text-(length:--tui-message-meta-size) text-(--tui-message-muted)",
// What a reply is made of: paragraphs, lists and code, spaced as prose.
body: "min-w-0 leading-relaxed [&_code]:rounded-[4px] [&_code]:bg-(--tui-message-code-bg) [&_code]:px-[0.3em] [&_code]:py-[0.1em] [&_code]:text-[0.92em] [&_li]:my-1 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:m-0 [&_p+p]:mt-3 [&_strong]:font-semibold [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
caret: "after:ml-0.5 after:inline-block after:h-[1.05em] after:w-[0.5em] after:translate-y-[0.18em] after:rounded-[1px] after:bg-(--tui-message-caret) after:[animation:tui-blink_1s_steps(2,start)_infinite]",
bubble: "rounded-(--tui-message-radius) bg-(--tui-message-user-bg) px-(--tui-message-padding-x) py-(--tui-message-padding-y) text-(--tui-message-user-fg)",
error: "rounded-(--tui-message-radius) border-solid border-(length:--tui-message-error-border-width) border-(--tui-message-error) px-(--tui-message-padding-x) py-(--tui-message-padding-y)",
actions: "flex items-center gap-0.5",
quiet: "opacity-0 transition-opacity duration-(--tui-duration) group-focus-within/message:opacity-100 group-hover/message:opacity-100",
action:
"flex size-(--tui-message-action-size) cursor-default items-center justify-center rounded-(--tui-message-action-radius) border-none bg-transparent text-(--tui-message-muted) outline-none transition-colors duration-(--tui-duration) data-focus-visible:[outline:2px_solid_var(--tui-ring)] data-hovered:bg-(--tui-message-action-bg-hover) data-hovered:text-(--tui-message-fg) data-pressed:bg-(--tui-message-action-bg-hover) [&_svg]:size-[55%]",
};
/** The mark a reply carries when it is given no avatar of its own. */
function Spark() {
return (
<svg aria-hidden viewBox="0 0 16 16" fill="currentColor" className="size-[55%]">
<path d="M8 1.5c.3 2.9 1.6 4.2 4.5 4.5v1C9.6 7.3 8.3 8.6 8 11.500H7C6.700 8.600 5.400 7.300 2.500 7V6C5.400 5.700 6.700 4.400 7 1.500z" />
<path d="M12.500 10.500c.150 1.300.700 1.850 2 2v.5c-1.300.150-1.850.700-2 2H12c-.150-1.300-.700-1.850-2-2v-.5c1.300-.150 1.850-.700 2-2z" />
</svg>
);
}
export interface MessageProps {
role?: "user" | "assistant";
/** Who is speaking. Shown for replies. */
name?: ReactNode;
/** A note beside the name: the model, the time. */
meta?: ReactNode;
/** An avatar. A reply without one gets a mark. */
avatar?: ReactNode;
/** Files sent with the message. Use Attachment. */
attachments?: ReactNode;
/** Controls under the message. Use MessageAction. */
actions?: ReactNode;
/** Keep the actions on show, not only on hover and focus. */
showActions?: boolean;
/** The reply is still arriving: a caret follows the last word. */
isStreaming?: boolean;
/** Something went wrong producing it. */
isError?: boolean;
children: ReactNode;
className?: string;
}
/**
* One turn in a conversation. A person's sits on the right in a bubble. A
* reply runs the width on the left under who wrote it, as prose, with its
* actions underneath.
*/
export function Message({ role = "assistant", name, meta, avatar, attachments, actions, showActions, isStreaming, isError, children, className }: MessageProps) {
const bar = actions && <div className={join(styles.actions, !showActions && !isStreaming && styles.quiet)}>{actions}</div>;
if (role === "user") {
return (
<div data-role="user" className={join(styles.root, "flex-col items-end gap-1.5!", className)}>
{attachments && <div className="flex max-w-(--tui-message-max-width) flex-wrap justify-end gap-2">{attachments}</div>}
<div data-slot="message" className={join("max-w-(--tui-message-max-width) leading-relaxed", styles.bubble)}>
{children}
</div>
{bar}
</div>
);
}
return (
<div data-role="assistant" aria-busy={isStreaming || undefined} className={join(styles.root, className)}>
<div className={styles.avatar}>{avatar ?? <Spark />}</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{(name || meta) && (
<div className={styles.header}>
{name && <span className={styles.name}>{name}</span>}
{meta && <span className={styles.meta}>{meta}</span>}
</div>
)}
{attachments && <div className="flex flex-wrap gap-2">{attachments}</div>}
<div data-slot="message" role={isError ? "alert" : undefined} className={join(styles.body, isStreaming && styles.caret, isError && styles.error)}>
{children}
</div>
{bar}
</div>
</div>
);
}
/** A small button under a message: copy, retry, rate. It needs an aria-label. */
export function MessageAction({ className, ...props }: Omit<ButtonProps, "className"> & { className?: string }) {
return <Button {...props} className={join(styles.action, className)} />;
}:root {
color-scheme: light;
--tui-bg: #ffffff;
--tui-fg: #0a0a0a;
--tui-muted: #f5f5f5;
--tui-muted-fg: #737373;
--tui-border: #e5e5e5;
--tui-primary: #171717;
--tui-primary-fg: #fafafa;
--tui-ring: #a3a3a3;
--tui-success: #16a34a;
--tui-warning: #d97706;
--tui-danger: #dc2626;
--tui-radius: 8px;
--tui-border-width: 1px;
--tui-space: 4px;
--tui-shadow: none;
--tui-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--tui-text: 14px;
--tui-duration: 150ms;
/* Message */
--tui-message-gap: calc(var(--tui-space) * 3);
--tui-message-avatar-size: calc(var(--tui-space) * 7);
--tui-message-action-size: calc(var(--tui-space) * 7);
--tui-message-max-width: 85%;
--tui-message-padding-x: calc(var(--tui-space) * 3.5);
--tui-message-padding-y: calc(var(--tui-space) * 2.5);
--tui-message-radius: calc(var(--tui-radius) * 2);
--tui-message-avatar-radius: calc(var(--tui-radius) * 0.9);
--tui-message-action-radius: calc(var(--tui-radius) * 0.75);
--tui-message-font-size: var(--tui-text);
--tui-message-meta-size: 12px;
--tui-message-name-weight: 600;
--tui-message-fg: var(--tui-fg);
--tui-message-muted: var(--tui-muted-fg);
--tui-message-avatar-bg: var(--tui-primary);
--tui-message-avatar-fg: var(--tui-primary-fg);
--tui-message-user-bg: var(--tui-muted);
--tui-message-user-fg: var(--tui-fg);
--tui-message-code-bg: var(--tui-muted);
--tui-message-caret: var(--tui-fg);
--tui-message-action-bg-hover: var(--tui-muted);
--tui-message-error: color-mix(in oklab, var(--tui-danger) 45%, var(--tui-bg));
--tui-message-error-border-width: var(--tui-border-width);
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
--tui-bg: #0a0a0a;
--tui-fg: #fafafa;
--tui-muted: #1c1c1c;
--tui-muted-fg: #a3a3a3;
--tui-border: #2a2a2a;
--tui-primary: #fafafa;
--tui-primary-fg: #171717;
--tui-ring: #737373;
--tui-success: #22c55e;
--tui-warning: #f59e0b;
--tui-danger: #ef4444;
}
@keyframes tui-blink {
to { visibility: hidden; }
}Needs Tailwind CSS v4. Import the stylesheet once, after Tailwind.