shadimage
asanshay
componentchat-messagesource: ok · refreshed

Chat Message

A chat message component compatible with the Vercel AI SDK including markdown support, copying, etc.

npx shadcn add "https://ds.asanshay.com/r/chat-message.json"
free

Where it goes

chat

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ui-primitives@radix-ui/react-slot
iconslucide-react

Registry dependencies

accordion

Files5 · source cached 10/8/2026, 7:43:26 AM

registry/new-york/blocks/chat-message/chat-message.tsx
"use client";

import { cn } from "@/lib/utils";
import { VariantProps, cva } from "class-variance-authority";
import Markdown from "@/registry/new-york/blocks/markdown/markdown";
import React from "react";
import { ToolUIPart, UIMessage } from "ai";
import ChatReasoning from "@/registry/new-york/blocks/chat-reasoning/chat-reasoning";
import ChatTool from "@/registry/new-york/blocks/chat-tool/chat-tool";

const userMessageVariants = cva("flex flex-col gap-2", {
  variants: {
    variant: {
      raised:
        "bg-gradient-to-b from-primary to-primary/80 border border-primary text-primary-foreground shadow-lg shadow-shade max-w-2xs ml-auto rounded-xl px-4 py-2 font-medium",
      title: "font-semibold tracking-tight text-3xl mt-4 border-b border-primary/30 pb-1",
      default: "bg-primary/20 max-w-2xs ml-auto rounded-xl px-4 py-2",
    },
  },
});
const assistantMessageVariants = cva("flex flex-col gap-2", {
  variants: {
    variant: {
      raised:
        "bg-gradient-to-b from-muted to-muted/50 rounded-xl px-4 py-2 border shadow-md shadow-shade mr-8",
      paragraph: "",
      default: "bg-muted rounded-xl px-4 py-2 mr-8",
    },
  },
});

// Helper function to render a single message part
const renderMessagePart = (part: any, key: string | number) => {
  if (part.type.includes("tool")) {
    return (
      <ChatTool
        toolMessagePart={part as ToolUIPart}
        className="my-1 border-none px-0 py-0 shadow-none text-muted-foreground"
      />
    );
  } else if (part.type === "text") {
    return <Markdown key={key}>{part.text}</Markdown>;
  } else if (part.type === "reasoning") {
    return (
      <Markdown key={key} className="text-sm text-muted-foreground">
        {part.text}
      </Markdown>
    );
  }
  return <div key={key}>{part.type}</div>;
};

export default function ChatMessage({
  message,
  className,
  userMessageVariant = "default",
  assistantMessageVariant = "default",
}: {
  message: UIMessage;
  className?: string;
  userMessageVariant?: VariantProps<typeof userMessageVariants>["variant"];
  assistantMessageVariant?: VariantProps<typeof assistantMessageVariants>["variant"];
}) {
  if (message.role === "user") {
    return (
      <div
        className={cn(userMessageVariants({ variant: userMessageVariant }), className)}
        role="user"
      >
        {message.parts.map((part) => part.type === "text" && part.text).join("")}
      </div>
    );
  }

  const firstTextIndex = message.parts.findIndex((part) => part.type === "text");
  const hasTextPart = firstTextIndex !== -1;

  const shouldShowAccordion = firstTextIndex !== 0;
  const accordionDefaultValue = !hasTextPart ? "reasoning" : null;
  const partsInAccordion = shouldShowAccordion ? message.parts.slice(0, firstTextIndex) : [];
  const partsAfter = hasTextPart ? message.parts.slice(firstTextIndex) : [];

  return (
    <div className={cn("flex items-start gap-3", className)}>
      <div
        className={cn(
          "flex flex-col gap-1 relative",
          assistantMessageVariants({ variant: assistantMessageVariant })
        )}
      >
        {shouldShowAccordion && (
          <ChatReasoning
            renderMessagePart={renderMessagePart}
            partsInAccordion={partsInAccordion}
            defaultValue={accordionDefaultValue ?? undefined}
          />
        )}
        {partsAfter.map((part, index) => renderMessagePart(part, firstTextIndex + index))}
      </div>
    </div>
  );
}
registry/new-york/blocks/button/button.tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-xl text-md font-medium disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive duration-200 hover:duration-75 transition-all",
  {
    variants: {
      variant: {
        default:
          "border border-primary bg-primary/70 bg-linear-to-t from-primary to-primary/30 px-4 py-2 font-medium text-primary-foreground shadow-md inset-shadow-[0px_1px_0px_1px] inset-shadow-white/30 transition-all duration-75 hover:bg-primary/60 active:translate-y-px active:inset-shadow-black/10 dark:bg-primary dark:from-primary dark:to-primary/80 dark:hover:bg-primary/90",
        fancy:
          "border border-primary bg-primary/30 bg-radial-[at_50%_25%] from-primary/40 to-primary text-primary-foreground shadow-md inset-shadow-sm inset-shadow-white/50 hover:bg-white hover:shadow-lg hover:inset-shadow-white active:bg-primary/30 active:shadow-none active:inset-shadow-primary/80  hover:-translate-y-px active:translate-y-px",
        destructive:
          "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "border shadow-lg hover:shadow-none shadow-shade bg-transparent text-primary hover:border-transparent hover:bg-primary/10 active:bg-primary/30",
        secondary:
          "border border-transparent bg-primary/10 text-primary hover:border-primary/50 hover:bg-transparent  active:border-transparent active:border-primary active:ring-1 active:scale-95",
        ghost:
          "bg-transparent text-primary hover:border-transparent hover:bg-primary/10 active:bg-primary/30",
        link: "text-foreground underline decoration-primary underline-offset-2 hover:underline-offset-2 active:decoration-4 hover:opacity-70 hover:decoration-2 active:text-foreground",
      },
      size: {
        default: "px-4 py-2",
        xs: "px-2 py-1.5 rounded-lg [&_svg:not([class*='size-'])]:size-3 text-xs",
        sm: "px-3 py-1.5 text-sm rounded-lg",
        lg: "px-5 py-2 text-lg",
        xl: "px-6 py-3 [&_svg:not([class*='size-'])]:size-5 text-xl",
        icon: "size-8 rounded-lg [&_svg:not([class*='size-'])]:size-4",
        iconSm: "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
        iconLg: "size-10 rounded-lg [&_svg:not([class*='size-'])]:size-5",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean;
  }) {
  const Comp = asChild ? Slot : "button";

  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  );
}

export { Button, buttonVariants };
registry/new-york/blocks/chat-reasoning/chat-reasoning.tsx
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
import { UIDataTypes, UIMessagePart, UITools } from "ai";
import React from "react";

export default function ChatReasoning({
  partsInAccordion,
  defaultValue,
  renderMessagePart,
  className,
}: {
  partsInAccordion: UIMessagePart<UIDataTypes, UITools>[];
  defaultValue?: string;
  renderMessagePart: (
    part: UIMessagePart<UIDataTypes, UITools>,
    key: string | number
  ) => React.ReactNode;
  className?: string;
}) {
  const [value, setValue] = React.useState<string | undefined>(defaultValue);

  React.useEffect(() => {
    setValue(defaultValue);
  }, [defaultValue]);

  return (
    <Accordion
      type="single"
      collapsible
      value={value}
      onValueChange={setValue}
      className={cn("w-full", className)}
    >
      <AccordionItem value="reasoning" className="w-full">
        <AccordionTrigger className="text-md text-muted-foreground hover:no-underline hover:opacity-70 py-2 w-full">
          {defaultValue === "reasoning" ? "Reasoning..." : `Done reasoning.`}
        </AccordionTrigger>
        <AccordionContent className="p-0 -mt-1">
          <div className="flex flex-col gap-0">
            {partsInAccordion.map(
              (part, index) =>
                part.type !== "step-start" && (
                  <div key={index} className="flex gap-2 pl-2">
                    <div className="flex flex-col items-center gap-1 pt-2 -mb-1">
                      <div className="w-2 h-2 bg-muted-foreground/50 rounded-full" />
                      <div
                        className={cn(
                          "w-0.5 min-h-0 flex-1 bg-border rounded-full",
                          index === partsInAccordion.length - 1 &&
                            "bg-gradient-to-b from-border to-transparent"
                        )}
                      />
                    </div>
                    <div className="flex-1">{renderMessagePart(part, `accordion-${index}`)}</div>
                  </div>
                )
            )}
          </div>
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  );
}
registry/new-york/blocks/markdown/markdown.tsx
import ReactMarkdown, { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
import { CodeBlock } from "@/registry/new-york/blocks/code-block/code-block";
import { Button } from "@/registry/new-york/blocks/button/button";

const components: Components = {
  // Text components
  p: ({ children }) => <p className="text-base mb-3 last:mb-0">{children}</p>,
  h1: ({ children }) => (
    <h1 className="text-4xl font-semibold tracking-tight mb-4 mt-2">{children}</h1>
  ),
  h2: ({ children }) => (
    <h2 className="text-3xl font-semibold tracking-tight mt-4 mb-2 border-b border-border pb-2">
      {children}
    </h2>
  ),
  h3: ({ children }) => (
    <h3 className="text-2xl font-semibold tracking-tight mt-4 mb-2">{children}</h3>
  ),
  h4: ({ children }) => (
    <h4 className="text-xl font-semibold tracking-tight mt-3 mb-2">{children}</h4>
  ),
  h5: ({ children }) => (
    <h5 className="text-lg font-semibold tracking-tight mt-2 mb-1">{children}</h5>
  ),
  h6: ({ children }) => (
    <h6 className="text-base font-semibold tracking-tight mt-2 mb-1">{children}</h6>
  ),

  // Lists
  ul: ({ children }) => (
    <ul className="text-base my-2 pl-6 list-disc marker:text-primary/70">{children}</ul>
  ),
  ol: ({ children }) => <ol className="text-base my-2 pl-6 list-decimal">{children}</ol>,
  li: ({ children }) => <li className="my-0.5">{children}</li>,

  // Inline formatting
  strong: ({ children }) => <strong className="font-bold">{children}</strong>,
  em: ({ children }) => <em className="italic">{children}</em>,
  del: ({ children }) => <del className="line-through">{children}</del>,

  // Block elements
  blockquote: ({ children }) => (
    <blockquote className="pl-4 border-l-4 border-primary/20 my-2 italic text-primary/70">
      {children}
    </blockquote>
  ),
  hr: () => <hr className="my-4 border-t border-border" />,

  // Tables
  table: ({ children }) => (
    <div className="overflow-x-auto my-4">
      <table className="min-w-full divide-y divide-border border border-border">{children}</table>
    </div>
  ),
  thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
  tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
  tr: ({ children }) => <tr>{children}</tr>,
  th: ({ children }) => (
    <th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
      {children}
    </th>
  ),
  td: ({ children }) => <td className="px-3 py-2 whitespace-nowrap text-sm">{children}</td>,

  // Links and images
  a: ({ children, href, title }) => (
    <Button
      variant="link"
      size="sm"
      className="text-primary hover:underline p-0 m-0"
      title={title}
      onClick={() => {
        if (href) {
          window.open(href, "_blank", "noopener noreferrer");
        }
      }}
      disabled={!href}
      asChild
    >
      {children}
    </Button>
  ),
  img: ({ src, alt, title }) => (
    <div className="flex flex-col items-center w-fit">
      <img
        src={src || ""}
        alt={alt || ""}
        title={title || alt || ""}
        className="max-w-sm h-auto my-2 rounded-xl"
      />
      <p className="text-sm text-center text-muted-foreground">{alt}</p>
    </div>
  ),

  // Code blocks and inline code
  code: ({ className, children }) => {
    const match = /language-(\w+)/.exec(className || "");
    const isInline = !match;
    return !isInline && match ? (
      <CodeBlock code={String(children).replace(/\n$/, "")} language={match[1]} />
    ) : (
      <code className="bg-muted px-1.5 py-0.5 rounded-md text-sm text-primary font-mono">
        {children}
      </code>
    );
  },
  pre: ({ children }) => <>{children}</>,
};

export default function Markdown({
  children,
  className,
}: {
  children: string;
  className?: string;
}) {
  return (
    <div className={cn("w-full", className)}>
      <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
        {children}
      </ReactMarkdown>
    </div>
  );
}
registry/new-york/blocks/code-block/code-block.tsx
"use client";

import { cn } from "@/lib/utils";
import React, { useEffect, useState } from "react";
import { Button } from "@/registry/new-york/blocks/button/button";
import { Check, Copy } from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight, a11yDark as oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { cva } from "class-variance-authority";
import { useTheme } from "next-themes";

const customSyntaxTheme = {
  ...oneLight,
  'pre[class*="language-"]': {
    ...oneLight['pre[class*="language-"]'],
    background: "hsl(var(--muted))",
    borderRadius: "0.5rem",
    fontFamily: "Geist Mono, monospace",
    textShadow: "none",
    padding: 0,
    margin: 0,
  },
  'code[class*="language-"]': {
    ...oneLight['code[class*="language-"]'],
    background: "none",
    fontFamily: "Geist Mono, monospace",
    textShadow: "none",
    fontSize: "13px",
    padding: 0,
  },
};

const customDarkSyntaxTheme = {
  ...oneDark,
  'pre[class*="language-"]': {
    ...oneDark['pre[class*="language-"]'],
    background: "hsl(var(--muted))",
    borderRadius: "0.5rem",
    fontFamily: "Geist Mono, monospace",
    textShadow: "none",
    padding: 0,
    margin: 0,
  },
  'code[class*="language-"]': {
    ...oneDark['code[class*="language-"]'],
    background: "none",
    fontFamily: "Geist Mono, monospace",
    textShadow: "none",
    fontSize: "13px",
    padding: 0,
  },
};

const codeBlockVariants = cva("text-card-foreground rounded-xl p-4 flex flex-col gap-2", {
  variants: {
    variant: {
      flat: "bg-muted",
      default: "border border-border bg-card",
    },
  },
  defaultVariants: {
    variant: "default",
  },
});

export type CodeBlockProps = {
  code: string;
  language?: string;
  theme?: string;
  className?: string;
  title?: string;
  children?: React.ReactNode;
  variant?: "default" | "flat";
} & React.HTMLProps<HTMLDivElement>;

export function CodeBlock({
  code,
  language = "tsx",
  theme = "github-light",
  className,
  title,
  children,
  variant = "default",
  ...props
}: CodeBlockProps) {
  const { theme: themeName } = useTheme();
  const codeClassNames = cn("w-full overflow-x-auto text-[13px] font-mono");
  const [copied, setCopied] = useState(false);
  return (
    <div className={cn("relative group", codeBlockVariants({ variant }), className)} {...props}>
      <Button
        onClick={() => {
          navigator.clipboard.writeText(String(code));
          setCopied(true);
          setTimeout(() => {
            setCopied(false);
          }, 1000);
        }}
        variant="ghost"
        size="icon"
        className="absolute right-2 top-2 focus-visible:ring-0 text-muted-foreground hover:bg-muted active:bg-muted"
        aria-label={copied ? "Copied" : "Copy to clipboard"}
      >
        <span className="sr-only">{copied ? "Copied" : "Copy"}</span>
        <Copy
          className={`size-4 transition-all duration-300 ${copied ? "scale-0" : "scale-100"}`}
        />
        <Check
          className={`absolute inset-0 m-auto size-4 transition-all duration-300 text-success ${
            copied ? "scale-100" : "scale-0"
          }`}
        />
      </Button>
      {title && <h3 className="text-sm text-muted-foreground tracking-tight font-mono">{title}</h3>}
      <SyntaxHighlighter
        language={language}
        style={themeName === "dark" ? customDarkSyntaxTheme : customSyntaxTheme}
        PreTag="div"
        className={codeClassNames}
      >
        {code}
      </SyntaxHighlighter>
    </div>
  );
}

More from asanshay

Browse all components in this library →