LLM index: /llms.txt

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

Install the following dependencies:

npx shadcn@latest add button avatar tooltip kbd && npm install streamdown @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid radix-ui @radix-ui/react-slot @hugeicons/react @hugeicons/core-free-icons hast
pnpm dlx shadcn@latest add button avatar tooltip kbd && pnpm add streamdown @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid radix-ui @radix-ui/react-slot @hugeicons/react @hugeicons/core-free-icons hast
yarn dlx shadcn@latest add button avatar tooltip kbd && yarn add streamdown @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid radix-ui @radix-ui/react-slot @hugeicons/react @hugeicons/core-free-icons hast
bunx shadcn@latest add button avatar tooltip kbd && bun add streamdown @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid radix-ui @radix-ui/react-slot @hugeicons/react @hugeicons/core-free-icons hast

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 {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";

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

const messageMarkdownProseClasses = [
  "prose max-w-none text-primary font-normal text-sm leading-6.5",
  // 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-[550]",
  // lists
  "prose-li:my-[-0.5px] prose-li:marker:text-muted-foreground/50 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;
};

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

  return (
    <MessageContext.Provider value={{ from }}>
      <div
        ref={ref}
        data-slot="message"
        role="article"
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledBy}
        className={cn(
          "group/message 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(
        "rounded-[20px] text-sm leading-6.5 text-primary",
        from === "user"
          ? "w-fit bg-secondary px-4 py-2"
          : "mb-1 w-full bg-transparent px-2",
        className,
      )}
      {...props}
    />
  );
}

type MessageMarkdownProps = React.ComponentProps<typeof Streamdown>;

function MessageMarkdown({
  className,
  components,
  ...props
}: MessageMarkdownProps) {
  const mergedComponents = React.useMemo(
    () => {
      const defaultComponents = {
        code: CodeBlock,
        inlineCode: ({
          children,
          className,
          ...props
        }: React.HTMLAttributes<HTMLElement>) => (
          <code
            className={cn(
              "rounded-md border-none bg-muted px-1.5 py-0.5 font-mono text-xs font-[450]",
              className,
            )}
            data-slot="message-markdown-inline-code"
            {...props}
          >
            {children}
          </code>
        ),
        table: (props: React.HTMLAttributes<HTMLTableElement>) => (
          <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: React.ThHTMLAttributes<HTMLTableCellElement>) => (
          <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: React.TdHTMLAttributes<HTMLTableCellElement>) => (
          <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}
          />
        ),
      };

      return {
        ...(defaultComponents as object),
        ...((components ?? {}) as object),
      };
    },
    [components],
  );

  return (
    <Streamdown
      data-slot="message-markdown"
      className={cn(
        ...messageMarkdownProseClasses,
        "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
        className,
      )}
      components={mergedComponents as MessageMarkdownProps["components"]}
      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 items-center gap-1", className)}
      {...props}
    />
  );
}

type MessageActionProps = React.HTMLAttributes<HTMLDivElement> & {
  asChild?: boolean;
  tooltip?:
    | string
    | {
        content?: string;
        side?: "top" | "right" | "bottom" | "left";
        shortcut?: string;
      };
};

function MessageAction({
  asChild = false,
  tooltip,
  ...props
}: MessageActionProps) {
  const Comp = asChild ? Slot : "div";
  const { content, side, shortcut } =
    typeof tooltip === "string" ? { content: tooltip } : tooltip ?? {};

  if (!content) {
    return <Comp data-slot="message-action" {...props} />;
  }

  return (
    <TooltipProvider delayDuration={200}>
      <Tooltip>
        <TooltipTrigger asChild>
          <Comp data-slot="message-action" {...props} />
        </TooltipTrigger>
        <TooltipContent className="rounded-full" side={side}>
          {content}
          {shortcut ? <Kbd className="rounded-md!">{shortcut}</Kbd> : null}
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}

export type MessageAvatarProps = {
  src: string;
  alt?: string;
  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);

  useEffect(() => {
    callbackRef.current = onCopy;
  }, [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(() => {
    codePlugin.highlight(
      {
        code,
        language: language as BundledLanguage,
        themes: shikiTheme,
      },
      (highlighted) => setResult(highlighted),
    );
  }, [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 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 supports built-in tooltips via tooltip as either a string or object (content, optional side, optional shortcut).

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 tooltip={{ content: "Edit", shortcut: "E" }}>
                <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 tooltip="Copy">
                <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 tooltip="Copy response">
                <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
                tooltip={{ content: "Like", side: "top", shortcut: "L" }}
              >
                <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 tooltip="Dislike">
                <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 tooltip={{ content: "Regenerate", side: "right" }}>
                <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 @source entries for streamdown and @streamdown/* are missing, markdown may render unstyled and code or diagram blocks 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 and optional built-in tooltip rendering.

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

View as markdown Edit on GitHub