AI Message

One turn in a conversation, with role, avatar, actions and footer slots.

  1. Assistant said:
    Give the scrolling wrapper tabindex="0" and an accessible name. Without it, columns past the edge are unreachable by keyboard.

Installation

Terminal
pnpm dlx @dowel-ui/cli add ai-message

npm 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

PropTypeDefault
from

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.

MessageRole"assistant"
fromLabel

Overrides the visually hidden speaker label.

string

Plus every attribute of <li> except role.

MessageBody

PropTypeDefault
from

Who is speaking. See the note on MessageProps for why not role.

MessageRole"assistant"

Plus every attribute of <div> except role.

MessageAvatar

PropTypeDefault
asChildboolean

Plus every attribute of <div>.

MessageActions

Plus every attribute of <div>.

MessageTimestamp

PropTypeDefault
dateTime

Machine-readable value for dateTime. Children are the display text.

string

Plus every attribute of <time>.

MessageFooter

Plus every attribute of <div>.

Quality

8/8 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

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.

ui/ai-message.tsx
"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 };