For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Message

The Message component from this design system, built on Base UI.

Installation

$ pnpm dlx shadcn@latest add @cobalt/message

Source

"use client";

import {
  RiCheckLine,
  RiFileCopyLine,
  RiVolumeUpLine,
  RiVolumeMuteLine,
} from "@remixicon/react";
import { cn } from "cn";
import { motion } from "motion/react";
import * as React from "react";

import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import { Button } from "@/components/ui/button";

function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="message-group"
      className={cn("flex min-w-0 flex-col gap-2", className)}
      {...props}
    />
  );
}

function Message({
  className,
  align = "start",
  ...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
  return (
    <div
      data-slot="message"
      data-align={align}
      className={cn(
        "group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
        className
      )}
      {...props}
    />
  );
}

function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="message-avatar"
      className={cn(
        "flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
        className
      )}
      {...props}
    />
  );
}

function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="message-content"
      className={cn(
        "flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
        className
      )}
      {...props}
    />
  );
}

function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="message-header"
      className={cn(
        "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
        className
      )}
      {...props}
    />
  );
}

function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="message-footer"
      className={cn(
        "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
        className
      )}
      {...props}
    />
  );
}

export {
  MessageGroup,
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageHeader,
};

const lineHidden = { opacity: 0, filter: "blur(5px)", y: 4 };
const lineShown = { opacity: 1, filter: "blur(0px)", y: 0 };
const lineTransition = { duration: 0.42, ease: [0.22, 0.61, 0.36, 1] } as const;
const lineInstant = { duration: 0 } as const;

const MessageLine = React.memo(function MessageLine({
  text,
  reduce,
}: {
  text: string;
  reduce: boolean;
}) {
  return (
    <motion.p
      initial={lineHidden}
      animate={lineShown}
      transition={reduce ? lineInstant : lineTransition}
      className="text-sm leading-5 font-normal break-words text-[color:var(--foreground-strong,var(--foreground))] motion-reduce:transform-none! motion-reduce:opacity-100! motion-reduce:filter-none!"
    >
      {text}
    </motion.p>
  );
});

function StreamedText({
  text,
  paragraphs = false,
}: {
  text: string;
  paragraphs?: boolean;
}) {
  const reduce = useReducedMotion();
  const lines = React.useMemo(
    () =>
      text
        .split(paragraphs ? /\n\s*\n/ : "\n")
        .filter((line) => line.trim() !== ""),
    [text, paragraphs]
  );
  return (
    <div className="flex flex-col gap-3">
      {lines.map((line, index) => (
        <MessageLine key={index} text={line} reduce={reduce} />
      ))}
    </div>
  );
}

interface AgentMessageProps {
  role: string;
  text: string;
  streaming?: boolean;
  at?: number;
}

function AgentMessage({
  role,
  text,
  streaming = false,
  at,
}: AgentMessageProps) {
  if (!text) {
    return null;
  }
  if (role === "user") {
    return (
      <p className="ms-auto flex w-fit max-w-[75%] flex-col rounded-[16px] bg-[var(--surface-card,var(--background))] px-3 py-[11px] text-start text-sm leading-5 font-normal break-words whitespace-pre-wrap text-[color:var(--foreground-strong,var(--foreground))] shadow-[shadow:var(--shadow-card,0_1px_1px_0_rgb(0_0_0/0.05))]">
        {text}
      </p>
    );
  }
  return (
    <div className="group/message flex flex-col gap-1 px-1">
      <StreamedText text={text} />
      <MessageActions text={text} at={at} hidden={streaming} />
    </div>
  );
}

function MessageActions({
  text,
  at,
  hidden,
}: {
  text: string;
  at?: number;
  hidden: boolean;
}) {
  const [copied, setCopied] = React.useState(false);
  const [speaking, setSpeaking] = React.useState(false);
  React.useEffect(() => {
    if (!copied) {
      return;
    }
    const timer = setTimeout(() => setCopied(false), 1600);
    return () => clearTimeout(timer);
  }, [copied]);
  React.useEffect(
    () => () => {
      window.speechSynthesis?.cancel();
    },
    []
  );
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
    } catch {
      /* Clipboard access may be denied. */
    }
  };
  const toggleSpeech = () => {
    const synth = window.speechSynthesis;
    if (!synth) {
      return;
    }
    if (speaking) {
      synth.cancel();
      setSpeaking(false);
      return;
    }
    const utterance = new SpeechSynthesisUtterance(text);
    utterance.addEventListener("end", () => setSpeaking(false));
    utterance.addEventListener("error", () => setSpeaking(false));
    synth.cancel();
    synth.speak(utterance);
    setSpeaking(true);
  };
  return (
    <div
      className={cn(
        "flex items-center gap-1 transition-opacity duration-150 motion-reduce:transition-none",
        hidden
          ? "pointer-events-none opacity-0"
          : "opacity-0 group-hover/message:opacity-100 focus-within:opacity-100"
      )}
    >
      <MessageAction label={copied ? "Copied" : "Copy message"} onClick={copy}>
        {copied ? <RiCheckLine aria-hidden /> : <RiFileCopyLine aria-hidden />}
      </MessageAction>
      <MessageAction
        label={speaking ? "Stop reading aloud" : "Read aloud"}
        onClick={toggleSpeech}
      >
        {speaking ? (
          <RiVolumeMuteLine aria-hidden />
        ) : (
          <RiVolumeUpLine aria-hidden />
        )}
      </MessageAction>
      {at ? (
        <time
          dateTime={new Date(at).toISOString()}
          className="ms-1 text-xs leading-4 font-normal tracking-[0.15px] text-[color:var(--foreground-tertiary,var(--muted-foreground))]"
        >
          {formatAgo(at)}
        </time>
      ) : null}
    </div>
  );
}

function MessageAction({
  label,
  onClick,
  children,
}: {
  label: string;
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <Button
      type="button"
      variant="ghost-muted"
      size="icon-xs"
      onClick={onClick}
      tooltip={{ children: label, side: "top" }}
    >
      {children}
    </Button>
  );
}

function formatAgo(at: number) {
  const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
  if (seconds < 45) {
    return "just now";
  }
  const minutes = Math.round(seconds / 60);
  if (minutes < 60) {
    return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
  }
  const hours = Math.round(minutes / 60);
  if (hours < 24) {
    return `${hours} hour${hours === 1 ? "" : "s"} ago`;
  }
  const days = Math.round(hours / 24);
  return `${days} day${days === 1 ? "" : "s"} ago`;
}

export { AgentMessage, StreamedText };
export type { AgentMessageProps };