AI Message
One turn in a conversation, with role, avatar, actions and footer slots.
Installation
pnpm dlx @dowel-ui/cli add ai-messagenpm packages installed: class-variance-authority, radix-ui.
Accessibility
Every message carries a visually hidden label naming the speaker. Alignment and colour tell a sighted reader who is talking and tell a screen reader user nothing, so the role is always in text. Message actions fade in on hover but stay in the DOM and in the tab order — a control that only exists on hover is unreachable by keyboard and invisible on touch — and on devices that cannot hover they are always shown. MessageTimestamp is a real <time>.
Props
Message
| Prop | Type | Default |
|---|---|---|
fromWho is speaking. Named | MessageRole | "assistant" |
fromLabelOverrides the visually hidden speaker label. | string | — |
Plus every attribute of <li> except role.
MessageBody
| Prop | Type | Default |
|---|---|---|
fromWho is speaking. See the note on MessageProps for why not | MessageRole | "assistant" |
Plus every attribute of <div> except role.
MessageAvatar
| Prop | Type | Default |
|---|---|---|
asChild | boolean | — |
Plus every attribute of <div>.
MessageActions
Plus every attribute of <div>.
MessageTimestamp
| Prop | Type | Default |
|---|---|---|
dateTimeMachine-readable value for | string | — |
Plus every attribute of <time>.
MessageFooter
Plus every attribute of <div>.
Quality
8/8 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — does not apply
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Used in
Whole screens assembled from this component. Installing one brings this and everything else it needs with it.
Source
This is exactly what dowel add ai-message writes into your project, with imports rewritten to your own path alias.
"use client";
// Motion from SmoothUI AI Message (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils";
/**
* One turn in a conversation.
*
* The role is stated in text, not only in layout. Alignment and colour are how
* a sighted reader tells a question from an answer; a screen reader user gets
* nothing from either, so every message carries a visually hidden label naming
* who is speaking.
*/
const messageVariants = cva("group/message flex w-full gap-3", {
variants: {
role: {
user: "flex-row-reverse",
assistant: "flex-row",
system: "flex-row",
},
},
defaultVariants: {
role: "assistant",
},
});
const messageBodyVariants = cva("min-w-0 rounded-xl px-4 py-3 text-sm", {
variants: {
role: {
user: "max-w-[85%] bg-primary text-primary-foreground",
assistant: "w-full bg-transparent px-0 py-0 text-foreground",
system:
"w-full border border-dashed border-border bg-muted/40 text-xs text-muted-foreground",
},
},
defaultVariants: {
role: "assistant",
},
});
export type MessageRole = "user" | "assistant" | "system";
export interface MessageProps
extends ComponentPropsWithRef<"li">, Omit<VariantProps<typeof messageVariants>, "role"> {
/**
* Who is speaking.
*
* Named `from` rather than `role` on purpose. `role` is a global HTML
* attribute, and a component prop that shadows it cannot be told apart from
* the real thing by static analysis — every consumer's accessibility linter
* would flag ordinary usage of this component. The values are still the
* familiar conversation roles.
*/
from?: MessageRole;
/** Overrides the visually hidden speaker label. */
fromLabel?: string;
}
const DEFAULT_ROLE_LABELS: Record<MessageRole, string> = {
user: "You said",
assistant: "Assistant said",
system: "System",
};
export function Message({
className,
from = "assistant",
fromLabel,
children,
...props
}: MessageProps) {
return (
<li
data-slot="message"
data-from={from}
className={cn(messageVariants({ role: from }), className)}
{...props}
>
{/* Names the speaker for anyone who cannot see the layout. */}
<span className="sr-only">{fromLabel ?? DEFAULT_ROLE_LABELS[from]}:</span>
{children}
</li>
);
}
export interface MessageBodyProps
extends ComponentPropsWithRef<"div">, Omit<VariantProps<typeof messageBodyVariants>, "role"> {
/** Who is speaking. See the note on MessageProps for why not `role`. */
from?: MessageRole;
}
export function MessageBody({ className, from = "assistant", ...props }: MessageBodyProps) {
return (
<div
data-slot="message-body"
className={cn(messageBodyVariants({ role: from }), className)}
{...props}
/>
);
}
export interface MessageAvatarProps extends ComponentPropsWithRef<"div"> {
asChild?: boolean;
}
/** Decorative: the speaker is already named by the hidden label on Message. */
export function MessageAvatar({ className, asChild, ...props }: MessageAvatarProps) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="message-avatar"
aria-hidden="true"
className={cn(
"grid size-7 shrink-0 place-items-center rounded-full border border-border bg-muted",
"text-xs font-medium text-muted-foreground",
"[&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
/>
);
}
const PREFIX = "dowel-ai-message";
/*
* Only a private custom property lives here: which way the actions slide in.
* They come out of the bubble's own edge — the inline start for the assistant,
* the inline end for the user — and the sign flips in RTL. Everything visible
* is a layered utility on the container, so a consumer className still wins.
*/
const STYLES = `
[data-slot=message-actions]{--dowel-message-slide:-6px}
[data-from=user] [data-slot=message-actions]{--dowel-message-slide:6px}
[data-slot=message-actions]:dir(rtl){--dowel-message-slide:6px}
[data-from=user] [data-slot=message-actions]:dir(rtl){--dowel-message-slide:-6px}
`;
/**
* Per-message controls: copy, regenerate, feedback.
*
* Revealed on hover for pointer users but always present in the DOM and in the
* tab order — hiding controls behind hover makes them unreachable by keyboard
* and invisible on touch. On devices that cannot hover they are always shown.
*/
export function MessageActions({ className, ...props }: ComponentPropsWithRef<"div">) {
return (
<>
<style href={PREFIX} precedence="dowel">
{STYLES}
</style>
<div
data-slot="message-actions"
className={cn(
"mt-1.5 flex items-center gap-1",
"translate-x-(--dowel-message-slide) scale-90 opacity-0",
"transition-[opacity,translate,scale] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]",
"group-focus-within/message:translate-x-0 group-focus-within/message:scale-100 group-focus-within/message:opacity-100",
"group-hover/message:translate-x-0 group-hover/message:scale-100 group-hover/message:opacity-100",
"[@media(hover:none)]:translate-x-0 [@media(hover:none)]:scale-100 [@media(hover:none)]:opacity-100",
className,
)}
{...props}
/>
</>
);
}
export interface MessageTimestampProps extends ComponentPropsWithRef<"time"> {
/** Machine-readable value for `dateTime`. Children are the display text. */
dateTime?: string;
}
/**
* When the message was sent.
*
* A real `<time>`, so the display text can be relative ("2 min ago") while the
* machine-readable value stays exact.
*/
export function MessageTimestamp({ className, ...props }: MessageTimestampProps) {
return (
<time
data-slot="message-timestamp"
className={cn("text-xs text-muted-foreground tabular-nums", className)}
{...props}
/>
);
}
/** Attachments, citations and other content that hangs off a message. */
export function MessageFooter({ className, ...props }: ComponentPropsWithRef<"div">) {
return (
<div
data-slot="message-footer"
className={cn("mt-2 flex flex-wrap items-center gap-2", className)}
{...props}
/>
);
}
export { messageBodyVariants, messageVariants };