Message

Composable user and assistant chat turns via Message, MessageContent, MessageMarkdown (Streamdown), MessageAvatar, MessageActions, and Attachments.

What is the capital of France?

The capital of France is Paris.

"use client";

import {
  Message,
  MessageContent,
  MessageMarkdown,
  MessageStack,
} from "@/components/nexus-ui/message";

const MessageDefault = () => {
  return (
    <div className="flex w-full flex-col items-center justify-center gap-6">
      <Message from="user">
        <MessageStack>
          <MessageContent>
            <MessageMarkdown>What is the capital of France?</MessageMarkdown>
          </MessageContent>
        </MessageStack>
      </Message>

      <Message from="assistant">
        <MessageStack>
          <MessageContent>
            <MessageMarkdown>The capital of France is Paris.</MessageMarkdown>
          </MessageContent>
        </MessageStack>
      </Message>
    </div>
  );
};

export default MessageDefault;

Installation

npx shadcn@latest add @nexus-ui/message
pnpm dlx shadcn@latest add @nexus-ui/message
yarn dlx shadcn@latest add @nexus-ui/message
bunx shadcn@latest add @nexus-ui/message

Copy and paste the following code into your project.

components/nexus-ui/message.tsx
"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { Streamdown } from "streamdown";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CodeBlock } from "@/components/nexus-ui/codeblock";
import { cn } from "@/lib/utils";

const streamdownPlugins = { cjk, code, math, mermaid } as const;

/** Typography (prose) classes for MessageMarkdown. **/
const messageMarkdownProseClasses = [
  "prose max-w-none text-primary font-normal text-sm leading-6",
  // headings
  "prose-headings:font-[450] prose-headings:leading-5.5 prose-h2:tracking-[-0.45px] prose-headings:mb-4 prose-headings:mt-6 prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-h3:leading-4.5 prose-h3:tracking-[-0.4px] prose-h4:text-sm prose-h5:text-xs prose-h6:text-xs",
  // heading links
  "prose-headings:[&_a]:no-underline prose-headings:[&_a]:shadow-none prose-headings:[&_a]:text-inherit",
  // body text
  "prose-p:mb-1 prose-p:mt-4",
  // lead
  "prose-lead:text-primary",
  // links
  "[&_[data-streamdown=link]]:text-foreground [&_[data-streamdown=link]]:font-normal [&_[data-streamdown=link]]:underline [&_[data-streamdown=link]]:underline-offset-2",
  // strong
  "[&_[data-streamdown=strong]]:text-foreground [&_[data-streamdown=strong]]:font-normal [&_[data-streamdown=strong]]:font-[550]",
  // lists
  "prose-li:my-[-0.5px] prose-li:marker:text-border prose-ul:my-0 prose-ol:my-0 prose-ol:pl-3",
] as const;

type MessageFrom = "user" | "assistant";

type MessageContextValue = {
  from: MessageFrom;
};

const MessageContext = React.createContext<MessageContextValue | null>(null);

function useMessageContext() {
  return React.useContext(MessageContext);
}

type MessageProps = React.HTMLAttributes<HTMLDivElement> & {
  from: MessageFrom;
};

function Message({
  className,
  from,
  children,
  "aria-label": ariaLabelProp,
  "aria-labelledby": ariaLabelledBy,
  ...props
}: MessageProps) {
  const ariaLabel =
    ariaLabelProp ??
    (ariaLabelledBy == null
      ? from === "user"
        ? "User message"
        : "Assistant message"
      : undefined);

  return (
    <MessageContext.Provider value={{ from }}>
      <div
        data-slot="message"
        role="article"
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledBy}
        className={cn(
          "flex w-full max-w-[90%] items-start gap-2",
          from === "user" ? "ms-auto" : "me-auto",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </MessageContext.Provider>
  );
}

type MessageStackProps = React.HTMLAttributes<HTMLDivElement>;

function MessageStack({ className, ...props }: MessageStackProps) {
  const ctx = useMessageContext();
  const from = ctx?.from ?? "assistant";

  return (
    <div
      data-slot="message-stack"
      className={cn(
        "flex w-full flex-col gap-2",
        from === "user"
          ? "items-end"
          : "items-start",
        className,
      )}
      {...props}
    />
  );
}

type MessageContentProps = React.HTMLAttributes<HTMLDivElement>;

function MessageContent({ className, ...props }: MessageContentProps) {
  const ctx = useMessageContext();
  const from = ctx?.from ?? "assistant";

  return (
    <div
      data-slot="message-content"
      className={cn(
        "min-h-10 rounded-[20px] text-sm leading-6 text-primary",
        from === "user"
          ? "w-fit bg-muted px-4 py-2"
          : "w-full bg-transparent px-2",
        className,
      )}
      {...props}
    />
  );
}

type MessageMarkdownProps = React.ComponentProps<typeof Streamdown>;

function MessageMarkdown({ className, ...props }: MessageMarkdownProps) {
  return (
    <Streamdown
      data-slot="message-markdown"
      className={cn(
        ...messageMarkdownProseClasses,
        "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
        className,
      )}
      components={{
        code: CodeBlock,
        inlineCode: ({ children, className, ...props }) => (
          <code
            className={cn(
              "rounded bg-muted px-1.5 py-0.5 font-mono text-[13px]",
              className,
            )}
            data-slot="message-markdown-inline-code"
            {...props}
          >
            {children}
          </code>
        ),
        table: (props) => (
          <div
            data-slot="message-markdown-table-wrap"
            className={[
              "my-6 prose-no-margin overflow-hidden rounded-2xl border border-border bg-muted dark:border-accent dark:bg-background",
              "[&_tbody_tr:first-child_td:first-child]:rounded-ss-xl",
              "[&_tbody_tr:first-child_td:last-child]:rounded-se-xl",
              "[&_tbody_tr:last-child_td:first-child]:rounded-es-xl",
              "[&_tbody_tr:last-child_td:last-child]:rounded-ee-xl",
            ].join(" ")}
          >
            <table
              data-slot="message-markdown-table"
              className="w-full border-separate border-spacing-0 border-none bg-muted text-sm dark:bg-background"
              {...props}
            />
          </div>
        ),
        th: (props) => (
          <th
            data-slot="message-markdown-th"
            className="border-none px-5 py-2 text-start text-[13px] font-normal! text-muted-foreground! dark:bg-background"
            {...props}
          />
        ),
        td: (props) => (
          <td
            data-slot="message-markdown-td"
            className="border-0 border-accent bg-card px-5 py-3 text-[13px] text-primary dark:bg-card [tr:not(:first-child)_&]:border-t"
            {...props}
          />
        ),
      }}
      shikiTheme={["github-light", "github-dark"]}
      plugins={streamdownPlugins}
      {...props}
    />
  );
}

type MessageActionsProps = React.HTMLAttributes<HTMLDivElement>;

function MessageActions({ className, ...props }: MessageActionsProps) {
  const ctx = useMessageContext();
  const from = ctx?.from ?? "assistant";

  return (
    <div
      data-slot="message-actions"
      className={cn(
        "flex w-full",
        from === "user" ? "justify-end" : "justify-start",
        className,
      )}
      {...props}
    />
  );
}

type MessageActionGroupProps = React.HTMLAttributes<HTMLDivElement>;

function MessageActionGroup({ className, ...props }: MessageActionGroupProps) {
  return (
    <div
      data-slot="message-action-group"
      className={cn("flex gap-1", className)}
      {...props}
    />
  );
}

type MessageActionProps = React.HTMLAttributes<HTMLDivElement> & {
  asChild?: boolean;
};

function MessageAction({ asChild = false, ...props }: MessageActionProps) {
  const Comp = asChild ? Slot : "div";

  return <Comp data-slot="message-action" {...props} />;
}

export type MessageAvatarProps = {
  src: string;
  alt?: string;
  /** Shown while the image loads and when it fails to load. */
  fallback?: React.ReactNode;
  delayMs?: React.ComponentProps<typeof AvatarFallback>["delayMs"];
  size?: React.ComponentProps<typeof Avatar>["size"];
  className?: string;
};

function MessageAvatar({
  src,
  alt = "",
  fallback,
  delayMs,
  size,
  className,
}: MessageAvatarProps) {
  return (
    <Avatar
      data-slot="message-avatar"
      size={size}
      className={cn("size-7 shrink-0", className)}
    >
      <AvatarImage
        data-slot="message-avatar-image"
        src={src}
        alt={alt}
        className="my-0!"
      />
      <AvatarFallback
        data-slot="message-avatar-fallback"
        delayMs={delayMs}
        className="my-0! shrink-0"
      >
        {fallback}
      </AvatarFallback>
    </Avatar>
  );
}

export {
  Message,
  MessageStack,
  MessageContent,
  MessageMarkdown,
  MessageActions,
  MessageActionGroup,
  MessageAction,
  MessageAvatar,
};
components/nexus-ui/codeblock.tsx
"use client";

/**
 * Streamdown `components.code` for fenced blocks (Shiki via {@link @streamdown/code}).
 * Installed together with **Message** (`@nexus-ui/message`): same registry item as `message.tsx`, not a separate add.
 *
 * Nexus chrome: title row (optional via **`showTitleRow`**), copy, bordered card, scroll viewport.
 * Set `components.inlineCode` per
 * [Streamdown](https://streamdown.ai/docs/components#inline-code).
 */

import { HugeiconsIcon } from "@hugeicons/react";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { code as codeHighlighter } from "@streamdown/code";
import type { Element as HastElement } from "hast";
import {
  type ComponentProps,
  type CSSProperties,
  type DetailedHTMLProps,
  type HTMLAttributes,
  type MouseEventHandler,
  type ReactNode,
  isValidElement,
  memo,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import type {
  BundledLanguage,
  CodeHighlighterPlugin,
  ExtraProps,
} from "streamdown";
import { StreamdownContext, useIsCodeFenceIncomplete } from "streamdown";
import { cn } from "@/lib/utils";

// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------

type MarkdownCodeElementProps = DetailedHTMLProps<
  HTMLAttributes<HTMLElement>,
  HTMLElement
> &
  ExtraProps;

/** Streamdown `components.code` props: markdown element props plus Nexus chrome overrides. */
export type CodeBlockProps = MarkdownCodeElementProps & {
  /** Language label + copy in a header row. When false, copy floats top-right. Defaults to true when omitted. */
  showTitleRow?: boolean;
};

type HighlightResult = NonNullable<
  ReturnType<CodeHighlighterPlugin["highlight"]>
>;

type CodeBlockPreProps = Omit<ComponentProps<"pre">, "children"> & {
  result: HighlightResult;
  language: string;
  lineNumbers?: boolean;
  /** 1-based first line (`startLine=`); uses `app/global.css` `code .line`. */
  lineNumbersStart?: number;
};

type CodeBlockFencedViewProps = {
  code: string;
  language: string;
  className?: string;
  isIncomplete?: boolean;
  startLine?: number;
  lineNumbers?: boolean;
  codePlugin?: CodeHighlighterPlugin;
  showTitleRow?: boolean;
};

// -----------------------------------------------------------------------------
// Constants
// -----------------------------------------------------------------------------

const LANGUAGE_REGEX = /language-([^\s]+)/;
const START_LINE_PATTERN = /startLine=(\d+)/;
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;

// -----------------------------------------------------------------------------
// Utilities (pure)
// -----------------------------------------------------------------------------

function sameNodePosition(prev?: HastElement, next?: HastElement): boolean {
  if (!(prev?.position || next?.position)) return true;
  if (!(prev?.position && next?.position)) return false;
  const ps = prev.position.start;
  const ns = next.position.start;
  const pe = prev.position.end;
  const ne = next.position.end;
  return (
    ps?.line === ns?.line &&
    ps?.column === ns?.column &&
    pe?.line === ne?.line &&
    pe?.column === ne?.column
  );
}

function extractCodeString(children: ReactNode): string {
  if (
    isValidElement(children) &&
    children.props &&
    typeof children.props === "object" &&
    "children" in children.props &&
    typeof (children.props as { children?: unknown }).children === "string"
  ) {
    return (children.props as { children: string }).children;
  }
  if (typeof children === "string") return children;
  return "";
}

function getMetastring(node?: HastElement): string | undefined {
  const raw = node?.properties?.metastring;
  return typeof raw === "string" ? raw : undefined;
}

function trimTrailingNewlines(str: string): string {
  let end = str.length;
  while (end > 0 && str[end - 1] === "\n") end--;
  return str.slice(0, end);
}

function buildRawHighlightResult(trimmed: string): HighlightResult {
  return {
    bg: "transparent",
    fg: "inherit",
    tokens: trimmed.split("\n").map((line) => [
      {
        content: line,
        color: "inherit",
        bgColor: "transparent",
        htmlStyle: {},
        offset: 0,
      },
    ]),
  };
}

function parseRootStyle(rootStyle: string): Record<string, string> {
  const style: Record<string, string> = {};
  for (const decl of rootStyle.split(";")) {
    const idx = decl.indexOf(":");
    if (idx > 0) {
      const prop = decl.slice(0, idx).trim();
      const val = decl.slice(idx + 1).trim();
      if (prop && val) style[prop] = val;
    }
  }
  return style;
}

// -----------------------------------------------------------------------------
// Primitives: copy control
// -----------------------------------------------------------------------------

const COPIED_RESET_MS = 1500;

function useCopyButton(
  onCopy: () => void | Promise<void>,
): [checked: boolean, onClick: MouseEventHandler] {
  const [checked, setChecked] = useState(false);
  const callbackRef = useRef(onCopy);
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  callbackRef.current = onCopy;

  const onClick = useCallback<MouseEventHandler>(() => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    void Promise.resolve(callbackRef.current()).then(() => {
      setChecked(true);
      timeoutRef.current = setTimeout(() => {
        setChecked(false);
      }, COPIED_RESET_MS);
    });
  }, []);

  useEffect(() => {
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, []);

  return [checked, onClick];
}

function CodeBlockCopyButton({
  text,
  showGlow = false,
  className,
}: {
  text: string;
  showGlow?: boolean;
  className?: string;
}) {
  const [checked, onClick] = useCopyButton(() => {
    void navigator.clipboard.writeText(text);
  });

  return (
    <div className="relative">
      {showGlow ? (
        <div
          className={cn(
            "pointer-events-none absolute top-1/2 left-1/2 z-0 size-13.5 -translate-x-1/2 -translate-y-1/2 rounded-l-full rounded-tr-full bg-linear-to-l",
            "from-card from-70% to-card/0",
          )}
        />
      ) : null}
      <button
        type="button"
        data-checked={checked || undefined}
        className={cn(
          "relative flex size-7 cursor-pointer items-center justify-center rounded-lg text-ring hover:text-primary",
          className,
        )}
        aria-label={checked ? "Copied" : "Copy code"}
        onClick={onClick}
      >
        <span className="flex size-5 items-center justify-center">
          {checked ? (
            <HugeiconsIcon
              icon={Tick02Icon}
              strokeWidth={1.75}
              className="size-4.5"
            />
          ) : (
            <HugeiconsIcon
              icon={Copy01Icon}
              strokeWidth={1.75}
              className="size-4"
            />
          )}
        </span>
      </button>
    </div>
  );
}

// -----------------------------------------------------------------------------
// Primitives: title row
// -----------------------------------------------------------------------------

function CodeBlockTitleRow({
  title,
  copyText,
}: {
  title: string;
  copyText: string;
}) {
  return (
    <div className="flex h-9.5 items-center gap-2 px-4 text-muted-foreground">
      <figcaption className="flex-1 truncate text-[13px] lowercase">
        {title}
      </figcaption>
      <div className="-me-2 flex shrink-0 items-center">
        <CodeBlockCopyButton showGlow={false} text={copyText} />
      </div>
    </div>
  );
}

// -----------------------------------------------------------------------------
// Primitives: scroll viewport
// -----------------------------------------------------------------------------

function CodeBlockViewport({
  /** `top` when a title row sits above; `all` when the viewport is the only block body. */
  rounding = "top",
  children,
}: {
  rounding?: "top" | "all";
  children: React.ReactNode;
}) {
  return (
    <div
      className={cn(
        "no-scrollbar overflow-auto overscroll-x-none px-4 py-3.5 text-sm leading-6",
        rounding === "top" ? "rounded-t-xl" : "rounded-xl",
        "bg-card",
      )}
    >
      {children}
    </div>
  );
}

// -----------------------------------------------------------------------------
// Primitives: figure chrome (outer shell + inner card)
// -----------------------------------------------------------------------------

function CodeBlockFigureChrome({
  className,
  language,
  isIncomplete,
  showTitleRow,
  title,
  copyText,
  children,
}: {
  className?: string;
  language: string;
  isIncomplete?: boolean;
  showTitleRow: boolean;
  title: string;
  copyText: string;
  children: React.ReactNode;
}) {
  return (
    <figure
      className={cn(
        "my-4 rounded-xl border border-border dark:border-accent",
        showTitleRow ? "bg-muted dark:bg-background" : "dark:bg-card",
        "not-prose relative w-full overflow-hidden text-[13px] font-[450]",
        className,
      )}
      data-incomplete={isIncomplete || undefined}
      data-language={language}
      data-slot="nexus-code-block"
      dir="ltr"
      tabIndex={-1}
      style={{ contentVisibility: "auto", containIntrinsicSize: "auto 200px" }}
    >
      {showTitleRow ? (
        <CodeBlockTitleRow copyText={copyText} title={title} />
      ) : (
        <div className="absolute top-3 right-3 z-20">
          <CodeBlockCopyButton showGlow text={copyText} />
        </div>
      )}

      <CodeBlockViewport rounding={showTitleRow ? "top" : "all"}>
        {children}
      </CodeBlockViewport>
    </figure>
  );
}

// -----------------------------------------------------------------------------
// Primitives: highlighted token lines → pre/code
// -----------------------------------------------------------------------------

function CodeBlockTokenSpan({
  token,
}: {
  token: HighlightResult["tokens"][number][number];
}) {
  const tokenStyle: Record<string, string> = {};
  let hasBg = Boolean(token.bgColor);
  if (token.color) tokenStyle["--sdm-c"] = token.color;
  if (token.bgColor) tokenStyle["--sdm-tbg"] = token.bgColor;
  if (token.htmlStyle) {
    for (const [key, value] of Object.entries(token.htmlStyle)) {
      if (value == null) continue;
      if (key === "color") {
        tokenStyle["--sdm-c"] = String(value);
      } else if (key === "background-color") {
        tokenStyle["--sdm-tbg"] = String(value);
        hasBg = true;
      } else {
        tokenStyle[key] = String(value);
      }
    }
  }
  const htmlAttrs = (
    token as { htmlAttrs?: Record<string, string | undefined> }
  ).htmlAttrs;
  return (
    <span
      className={cn(
        "text-(--sdm-c,inherit)",
        "dark:text-(--shiki-dark,var(--sdm-c,inherit))",
        hasBg && "bg-(--sdm-tbg)",
        hasBg && "dark:bg-(--shiki-dark-bg,var(--sdm-tbg))",
      )}
      style={tokenStyle as CSSProperties}
      {...htmlAttrs}
    >
      {token.content}
    </span>
  );
}

const CodeBlockPre = memo(
  function CodeBlockPre({
    result,
    language,
    className,
    lineNumbers = true,
    lineNumbersStart = 1,
    ...rest
  }: CodeBlockPreProps) {
    const preStyle = useMemo(() => {
      const style: Record<string, string> = {};
      if (result.bg) style["--sdm-bg"] = result.bg;
      if (result.fg) style["--sdm-fg"] = result.fg;
      if (result.rootStyle && typeof result.rootStyle === "string") {
        Object.assign(style, parseRootStyle(result.rootStyle));
      }
      return style as CSSProperties;
    }, [result.bg, result.fg, result.rootStyle]);

    return (
      <pre
        className={cn(
          "w-max min-w-full bg-(--sdm-bg,inherit) *:flex *:flex-col dark:bg-(--shiki-dark-bg,var(--sdm-bg,inherit))",
          className,
        )}
        data-language={language}
        data-slot="nexus-code-block-body"
        style={preStyle}
        {...rest}
      >
        <code
          style={
            lineNumbers
              ? ({
                  counterSet: `line ${Number(lineNumbersStart) - 1}`,
                } satisfies CSSProperties)
              : undefined
          }
        >
          {result.tokens.map((row, rowIndex) => (
            <span
              key={rowIndex}
              className={lineNumbers ? "line block" : "block"}
            >
              {row.length === 0 || (row.length === 1 && row[0].content === "")
                ? "\n"
                : row.map((token, tokenIndex) => (
                    <CodeBlockTokenSpan key={tokenIndex} token={token} />
                  ))}
            </span>
          ))}
        </code>
      </pre>
    );
  },
  (prev, next) =>
    prev.result === next.result &&
    prev.language === next.language &&
    prev.className === next.className &&
    prev.lineNumbers === next.lineNumbers &&
    prev.lineNumbersStart === next.lineNumbersStart,
);
CodeBlockPre.displayName = "CodeBlockPre";

// -----------------------------------------------------------------------------
// Primitives: async Shiki highlight + pre
// -----------------------------------------------------------------------------

function CodeBlockShikiPre({
  code,
  language,
  raw,
  className,
  lineNumbers,
  lineNumbersStart,
  codePlugin,
}: {
  code: string;
  language: string;
  raw: HighlightResult;
  className?: string;
  lineNumbers?: boolean;
  lineNumbersStart?: number;
  codePlugin: CodeHighlighterPlugin;
}) {
  const { shikiTheme } = useContext(StreamdownContext);
  const [result, setResult] = useState<HighlightResult>(raw);

  useEffect(() => {
    const sync = codePlugin.highlight(
      {
        code,
        language: language as BundledLanguage,
        themes: shikiTheme,
      },
      (highlighted) => setResult(highlighted),
    );
    if (sync) setResult(sync);
  }, [code, language, shikiTheme, codePlugin, raw]);

  return (
    <CodeBlockPre
      className={className}
      language={language}
      lineNumbers={lineNumbers}
      lineNumbersStart={lineNumbersStart}
      result={result}
    />
  );
}

// -----------------------------------------------------------------------------
// Composed: CodeBlockFencedView (string + meta → chrome + Shiki)
// -----------------------------------------------------------------------------

function CodeBlockFencedView({
  code,
  language,
  className,
  isIncomplete,
  startLine,
  lineNumbers = true,
  codePlugin = codeHighlighter,
  showTitleRow: showTitleRowProp,
}: CodeBlockFencedViewProps) {
  const showTitleRow = showTitleRowProp ?? true;
  const trimmed = useMemo(() => trimTrailingNewlines(code), [code]);
  const raw = useMemo(() => buildRawHighlightResult(trimmed), [trimmed]);
  const title = (language || "code").toLowerCase();

  return (
    <CodeBlockFigureChrome
      className={className}
      copyText={trimmed}
      isIncomplete={isIncomplete}
      language={language}
      showTitleRow={showTitleRow}
      title={title}
    >
      <CodeBlockShikiPre
        code={trimmed}
        codePlugin={codePlugin}
        language={language}
        lineNumbers={lineNumbers}
        lineNumbersStart={startLine ?? 1}
        raw={raw}
      />
    </CodeBlockFigureChrome>
  );
}

// -----------------------------------------------------------------------------
// Export: Streamdown `components.code`
// -----------------------------------------------------------------------------

export const CodeBlock = memo(
  function CodeBlock({
    node,
    className,
    children,
    showTitleRow,
  }: CodeBlockProps) {
    const { lineNumbers: contextLineNumbers } = useContext(StreamdownContext);
    const isIncompleteFence = useIsCodeFenceIncomplete();

    const match = className?.match(LANGUAGE_REGEX);
    const language = match?.[1] ?? "";

    const metastring = getMetastring(node);
    const startLineMatch = metastring?.match(START_LINE_PATTERN);
    const parsedStart = startLineMatch
      ? Number.parseInt(startLineMatch[1], 10)
      : undefined;
    const startLine =
      parsedStart !== undefined && parsedStart >= 1 ? parsedStart : undefined;
    const metaNoLineNumbers = metastring
      ? NO_LINE_NUMBERS_PATTERN.test(metastring)
      : false;
    const showLineNumbers = !metaNoLineNumbers && contextLineNumbers !== false;

    const codeText = extractCodeString(children);

    return (
      <CodeBlockFencedView
        className={className}
        code={codeText}
        codePlugin={codeHighlighter}
        isIncomplete={isIncompleteFence}
        language={language}
        lineNumbers={showLineNumbers}
        showTitleRow={showTitleRow}
        startLine={startLine}
      />
    );
  },
  (p, n) =>
    p.className === n.className &&
    sameNodePosition(p.node, n.node) &&
    p.showTitleRow === n.showTitleRow,
);
CodeBlock.displayName = "CodeBlock";

Update the import paths to match your project setup.

Usage

import {
  Message,
  MessageStack,
  MessageContent,
  MessageMarkdown,
  MessageActions,
  MessageActionGroup,
  MessageAction,
  MessageAvatar,
} from "@/components/nexus-ui/message";
<Message from="user">
  <MessageStack>
    <MessageContent>
      <MessageMarkdown>Hello</MessageMarkdown>
    </MessageContent>
  </MessageStack>
  <MessageAvatar src="/avatar.png" alt="You" fallback="Y" />
</Message>

Examples

With Actions

Use MessageActions and MessageActionGroup for a row of controls. MessageAction is a thin Slot for individual action buttons.

Tell me about Nexus UI.

Nexus UI is an open-source React component library aimed at AI-powered UIs—chat, streaming, and multimodal flows—built with Tailwind CSS v4 and Radix UI.

"use client";

import { HugeiconsIcon } from "@hugeicons/react";
import {
  Copy01Icon,
  ThumbsUpIcon,
  ThumbsDownIcon,
  RepeatIcon,
  Edit04Icon,
} from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import {
  Message,
  MessageAction,
  MessageActionGroup,
  MessageActions,
  MessageContent,
  MessageMarkdown,
  MessageStack,
} from "@/components/nexus-ui/message";

const MessageWithActions = () => {
  return (
    <div className="flex w-full flex-col items-center justify-center gap-6">
      <Message from="user">
        <MessageStack>
          <MessageContent>
            <MessageMarkdown>Tell me about Nexus UI.</MessageMarkdown>
          </MessageContent>
          <MessageActions>
            <MessageActionGroup>
              <MessageAction asChild>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
                >
                  <HugeiconsIcon
                    icon={Edit04Icon}
                    strokeWidth={2.0}
                    className="size-4"
                  />
                </Button>
              </MessageAction>
              <MessageAction asChild>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
                >
                  <HugeiconsIcon
                    icon={Copy01Icon}
                    strokeWidth={2.0}
                    className="size-4"
                  />
                </Button>
              </MessageAction>
            </MessageActionGroup>
          </MessageActions>
        </MessageStack>
      </Message>

      <Message from="assistant">
        <MessageStack>
          <MessageContent>
            <MessageMarkdown>
              Nexus UI is an open-source React component library aimed at AI-powered UIs—chat, streaming, and multimodal flows—built with Tailwind CSS v4 and Radix UI.
            </MessageMarkdown>
          </MessageContent>
          <MessageActions>
            <MessageActionGroup>
              <MessageAction asChild>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
                >
                  <HugeiconsIcon
                    icon={Copy01Icon}
                    strokeWidth={2.0}
                    className="size-4"
                  />
                </Button>
              </MessageAction>
              <MessageAction asChild>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
                >
                  <HugeiconsIcon
                    icon={ThumbsUpIcon}
                    strokeWidth={2.0}
                    className="size-4"
                  />
                </Button>
              </MessageAction>
              <MessageAction asChild>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
                >
                  <HugeiconsIcon
                    icon={ThumbsDownIcon}
                    strokeWidth={2.0}
                    className="size-4"
                  />
                </Button>
              </MessageAction>
              <MessageAction asChild>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
                >
                  <HugeiconsIcon
                    icon={RepeatIcon}
                    strokeWidth={2.0}
                    className="size-4"
                  />
                </Button>
              </MessageAction>
            </MessageActionGroup>
          </MessageActions>
        </MessageStack>
      </Message>
    </div>
  );
};

export default MessageWithActions;

With Avatar

MessageAvatar composes shadcn Avatar / AvatarImage / AvatarFallback — pass src, alt, and optional fallback (and delayMs if needed). Place the avatar after MessageStack for from="user", and before MessageStack for from="assistant".

Hello — can you help me draft an email?

U
A

Of course. What tone do you want: formal or friendly?

"use client";

import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageMarkdown,
  MessageStack,
} from "@/components/nexus-ui/message";

const imgUser = "/assets/user-avatar.avif";
const imgAssistant = "/assets/nexus-avatar.png";

const MessageWithAvatar = () => {
  return (
    <div className="flex w-full flex-col items-center justify-center gap-6">
      <Message from="user">
        <MessageStack>
          <MessageContent>
            <MessageMarkdown>Hello — can you help me draft an email?</MessageMarkdown>
          </MessageContent>
        </MessageStack>
        <MessageAvatar src={imgUser} alt="" fallback="U" className="border border-accent" />
      </Message>

      <Message from="assistant">
        <MessageAvatar src={imgAssistant} alt="" fallback="A" className="border border-accent" />
        <MessageStack>
          <MessageContent>
            <MessageMarkdown>
              Of course. What tone do you want: formal or friendly?
            </MessageMarkdown>
          </MessageContent>
        </MessageStack>
      </Message>
    </div>
  );
};

export default MessageWithAvatar;

With Attachments

Render AttachmentList / Attachment inside MessageStack above MessageContent. Use readOnly on Attachment when showing files that were already sent (no remove control or progress). See Attachments for AttachmentMeta and variants.

What do you see in these files?

I can see an image attachment and a text file named notes.txt.

"use client";

import { Attachment, AttachmentList, type AttachmentMeta } from "@/components/nexus-ui/attachments";
import {
  Message,
  MessageContent,
  MessageMarkdown,
  MessageStack,
} from "@/components/nexus-ui/message";

const previewUrl =
  "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=400&fit=crop";

const items: AttachmentMeta[] = [
  {
    type: "image",
    name: "landscape.jpg",
    url: previewUrl,
    mimeType: "image/jpeg",
  },
  { type: "file", name: "notes.txt", mimeType: "text/plain" },
];

const MessageWithAttachments = () => {
  return (
    <div className="flex w-full flex-col items-center justify-center gap-6">
      <Message from="user">
        <MessageStack>
          <AttachmentList className="justify-end p-0 gap-2">
            {items.map((item) => (
              <Attachment
                key={`${item.name}-${item.type}-${item.mimeType}`}
                variant="compact"
                attachment={item}
                readOnly
                className="size-25 rounded-[10px]"
              />
            ))}
          </AttachmentList>
          <MessageContent>
            <MessageMarkdown>What do you see in these files?</MessageMarkdown>
          </MessageContent>
        </MessageStack>
      </Message>

      <Message from="assistant">
        <MessageStack>
          <MessageContent>
            <MessageMarkdown>
              I can see an image attachment and a text file named notes.txt.
            </MessageMarkdown>
          </MessageContent>
        </MessageStack>
      </Message>
    </div>
  );
};

export default MessageWithAttachments;

Rich Text (Markdown)

MessageMarkdown renders markdown with Streamdown and shared prose-style classes. Pass a string as children (or a template literal) for headings, lists, code blocks, and links.

Show me markdown features.

Quick tips

You can use bold, italic, inline code, and .

Lists and structure

Unordered:

  • Bullet one
  • Bullet two

Ordered:

  1. First step
  2. Second step
  3. Third step

Comparison table

FeatureNotes
TablesGFM-style pipes
CodeFenced blocks and inline
typescript
const answer = 42;
function greet(name: string) {  return `Hello, ${name}`;}
"use client";

import {
  Message,
  MessageContent,
  MessageMarkdown,
  MessageStack,
} from "@/components/nexus-ui/message";

const markdown = `## Quick tips

You can use **bold**, *italic*, inline \`code\`, and [links](https://example.com).

### Lists and structure

Unordered:
- Bullet one
- Bullet two

Ordered:
1. First step
2. Second step
3. Third step

### Comparison table

| Feature | Notes |
| ------- | ----- |
| Tables | GFM-style pipes |
| Code | Fenced blocks and \`inline\` |

\`\`\`typescript
const answer = 42;

function greet(name: string) {
  return \`Hello, \${name}\`;
}
\`\`\`
`;

const MessageRichText = () => {
  return (
    <div className="flex w-full flex-col items-center justify-center gap-6">
      <Message from="user">
        <MessageStack>
          <MessageContent>
            <MessageMarkdown>Show me markdown features.</MessageMarkdown>
          </MessageContent>
        </MessageStack>
      </Message>

      <Message from="assistant">
        <MessageStack>
          <MessageContent>
            <MessageMarkdown >{markdown}</MessageMarkdown>
          </MessageContent>
        </MessageStack>
      </Message>
    </div>
  );
};

export default MessageRichText;

Message and Streamdown

How MessageMarkdown uses Streamdown

It is a thin wrapper around Streamdown with the same component props, so you can pass children, className, and other Streamdown options as needed.

Nexus applies shared prose-style className tokens for headings, body text, links, lists, and inline code. Shiki highlights fenced code with github-light and github-dark.

Plugins from @streamdown/code, @streamdown/math, @streamdown/mermaid, and @streamdown/cjk enable code blocks, math, diagrams, and CJK-friendly typography. A few MDX components are overridden—especially tables and codeblocks.

Streamdown parses markdown into HTML that relies on utility classes shipped inside streamdown and those plugin packages. That model works well for streaming assistant output so you do not hand-build the markup.

Fenced Code

MessageMarkdown uses CodeBlock for components.code (@/components/nexus-ui/codeblock). @streamdown/code still handles highlighting—you are only swapping the UI.

Use CodeBlock showTitleRow to show or hide the fenced-block title row (pass it on your components.code renderer). Turn line gutters on or off with Streamdown lineNumbers, which MessageMarkdown forwards like any other Streamdown prop.

@nexus-ui/message ships message.tsx and codeblock.tsx in one install—there is no separate codeblock package.

Tailwind @source after install

Installing Message with the shadcn CLI should merge Tailwind @source lines into the CSS file from components.json so those classes are not purged. With a manual install, that merge does not run by default.

After either path, open your global CSS and confirm the @source entries for streamdown and @streamdown/* are present, and that ../node_modules still resolves to the project root from that file’s folder. If anything is missing or the depth is wrong, markdown can look unstyled or code and diagrams can break.

Vercel AI SDK Integration

Render useChat messages with Message by reading each UIMessage parts array. Join text parts for MessageMarkdown (streaming updates apply as the SDK appends or grows TextUIPart content).

See Prompt Input for a minimal POST /api/chat route with streamText and toUIMessageStreamResponse. For user turns that include uploads, map file parts to Attachments (or your own preview) in addition to text—Attachments covers sendMessage with files.

Install the AI SDK

npm install ai @ai-sdk/react @ai-sdk/openai

Create your chat API route

Use the same handler as in the Prompt Input docs: messages: await convertToModelMessages(messages) and return result.toUIMessageStreamResponse().

Map messages to Message

Use isTextUIPart from ai so you only aggregate type: "text" segments. Skip system turns unless you surface them deliberately. Assistant messages can also include reasoning, tool, source, and other part types—extend this loop when you need those in the UI.

"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
import {
  Message,
  MessageStack,
  MessageContent,
  MessageMarkdown,
} from "@/components/nexus-ui/message";

function textFromMessage(message: UIMessage) {
  return message.parts.filter(isTextUIPart).map((p) => p.text).join("");
}

export default function ChatThread() {
  const { messages } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });

  return (
    <div className="flex flex-col gap-4">
      {messages
        .filter((m) => m.role !== "system")
        .map((m) => (
          <Message key={m.id} from={m.role === "user" ? "user" : "assistant"}>
            <MessageStack>
              <MessageContent>
                <MessageMarkdown>{textFromMessage(m)}</MessageMarkdown>
              </MessageContent>
            </MessageStack>
          </Message>
        ))}
    </div>
  );
}

API Reference

Message

Root of one chat turn: row for stack, avatar, and siblings; from sets alignment and is provided in context to MessageStack, MessageContent, and MessageActions.

Prop

Type

MessageStack

Stacks bubble, attachments, and actions in a column; cross-axis alignment follows from on Message (user vs assistant).

Prop

Type

MessageContent

Wraps the message body (e.g. markdown inside). User turns get a filled bubble; assistant turns stay visually light on the thread—both follow from on Message.

Prop

Type

MessageMarkdown

Renders markdown with Streamdown. Props are forwarded to Streamdown; values you pass replace the same keys on the underlying component (e.g. a new plugins object replaces the default bundle).

Commonly used options:

Prop

Type

For remend, remarkPlugins, rehypePlugins, linkSafety, animated, caret, and the full prop list, see the Streamdown configuration docs.

MessageActions

A flex container for action buttons. Default justify-end (user) or justify-start (assistant); override with className (e.g. justify-between) for multiple groups.

Prop

Type

MessageActionGroup

Groups related action buttons together with a horizontal layout.

Prop

Type

MessageAction

A wrapper for individual action buttons. Supports polymorphism via asChild.

Prop

Type

MessageAvatar

shadcn Avatar with src / alt / optional fallback. Sibling to MessageStack in Message: after the stack for user, before for assistant.

Prop

Type