Display rich text with headings, lists, links, and code blocks.
Markdown Rendering
This is a paragraph with bold text, italic text, and links.
- First item
- Second item
- Third item
This is a blockquote with some quoted text.
| Name | Value |
|---|---|
| Alpha | 100 |
| Beta | 200 |
Markdown support is already included by default in the Thread component.
Enabling markdown support
Add markdown-text
npx shadcn@latest add @assistant-ui/markdown-textThe @assistant-uinamespace resolves the Radix or Base UI flavor from your project's style through the style-aware registry entry in components.json. Without that entry, add by direct URL instead:
npx shadcn@latest add https://r.assistant-ui.com/base/markdown-text.jsonMain Component
npm install @assistant-ui/react-markdown remark-gfm"use client";import "@assistant-ui/react-markdown/styles/dot.css";import { type CodeHeaderProps, MarkdownTextPrimitive, unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, useIsMarkdownCodeBlock,} from "@assistant-ui/react-markdown";import remarkGfm from "remark-gfm";import { type FC, memo, useState } from "react";import { CheckIcon, CopyIcon } from "lucide-react";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";import { cn } from "@/lib/utils";const MarkdownTextImpl = () => { return ( <MarkdownTextPrimitive remarkPlugins={[remarkGfm]} className="aui-md" components={defaultComponents} defer /> );};export const MarkdownText = memo(MarkdownTextImpl);const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => { const { isCopied, copyToClipboard } = useCopyToClipboard(); const onCopy = () => { if (!code || isCopied) return; copyToClipboard(code); }; return ( <div className="aui-code-header-root border-border/50 bg-muted/50 mt-3 flex items-center justify-between rounded-t-xl border border-b-0 px-3.5 py-1.5 text-xs"> <span className="aui-code-header-language text-muted-foreground font-medium lowercase"> {language} </span> <TooltipIconButton tooltip="Copy" onClick={onCopy}> {!isCopied && ( <CopyIcon className="animate-in zoom-in-75 fade-in duration-150" /> )} {isCopied && ( <CheckIcon className="animate-in zoom-in-50 fade-in duration-200 ease-out" /> )} </TooltipIconButton> </div> );};const useCopyToClipboard = ({ copiedDuration = 3000,}: { copiedDuration?: number;} = {}) => { const [isCopied, setIsCopied] = useState<boolean>(false); const copyToClipboard = (value: string) => { if (!value || typeof navigator === "undefined" || !navigator.clipboard) { return; } navigator.clipboard.writeText(value).then( () => { setIsCopied(true); setTimeout(() => setIsCopied(false), copiedDuration); }, () => {}, ); }; return { isCopied, copyToClipboard };};const defaultComponents = memoizeMarkdownComponents({ h1: ({ className, ...props }) => ( <h1 className={cn( "aui-md-h1 mt-5 mb-2 scroll-m-20 text-xl font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h2: ({ className, ...props }) => ( <h2 className={cn( "aui-md-h2 mt-5 mb-2 scroll-m-20 text-lg font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h3: ({ className, ...props }) => ( <h3 className={cn( "aui-md-h3 mt-4 mb-1.5 scroll-m-20 text-base font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h4: ({ className, ...props }) => ( <h4 className={cn( "aui-md-h4 mt-3.5 mb-1 scroll-m-20 text-base font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), h5: ({ className, ...props }) => ( <h5 className={cn( "aui-md-h5 mt-3 mb-1 text-sm font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h6: ({ className, ...props }) => ( <h6 className={cn( "aui-md-h6 mt-3 mb-1 text-sm font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), p: ({ className, ...props }) => ( <p className={cn( "aui-md-p my-3 leading-relaxed first:mt-0 last:mb-0", className, )} {...props} /> ), a: ({ className, ...props }) => ( <a className={cn( "aui-md-a text-primary hover:text-primary/80 underline underline-offset-2", className, )} {...props} /> ), blockquote: ({ className, ...props }) => ( <blockquote className={cn( "aui-md-blockquote border-muted-foreground/30 text-muted-foreground my-3 border-s-2 ps-4", className, )} {...props} /> ), ul: ({ className, ...props }) => ( <ul className={cn( "aui-md-ul marker:text-muted-foreground my-3 ms-5 list-disc [&>li]:mt-1", className, )} {...props} /> ), ol: ({ className, ...props }) => ( <ol className={cn( "aui-md-ol marker:text-muted-foreground my-3 ms-5 list-decimal [&>li]:mt-1", className, )} {...props} /> ), hr: ({ className, ...props }) => ( <hr className={cn("aui-md-hr border-muted-foreground/20 my-3", className)} {...props} /> ), table: ({ className, ...props }) => ( <table className={cn( "aui-md-table my-3 w-full border-separate border-spacing-0 overflow-y-auto", className, )} {...props} /> ), th: ({ className, ...props }) => ( <th className={cn( "aui-md-th bg-muted px-3 py-1.5 text-start font-medium first:rounded-ss-lg last:rounded-se-lg [[align=center]]:text-center [[align=right]]:text-right", className, )} {...props} /> ), td: ({ className, ...props }) => ( <td className={cn( "aui-md-td border-muted-foreground/20 border-s border-b px-3 py-1.5 text-start last:border-e [[align=center]]:text-center [[align=right]]:text-right", className, )} {...props} /> ), tr: ({ className, ...props }) => ( <tr className={cn( "aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg", className, )} {...props} /> ), li: ({ className, ...props }) => ( <li className={cn("aui-md-li leading-relaxed", className)} {...props} /> ), strong: ({ className, ...props }) => ( <strong className={cn("aui-md-strong font-semibold", className)} {...props} /> ), sup: ({ className, ...props }) => ( <sup className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)} {...props} /> ), pre: ({ className, ...props }) => ( <pre className={cn( "aui-md-pre border-border/50 bg-muted/30 overflow-x-auto rounded-t-none rounded-b-xl border border-t-0 p-3.5 text-[13px] leading-relaxed", className, )} {...props} /> ), code: function Code({ className, ...props }) { const isCodeBlock = useIsMarkdownCodeBlock(); return ( <code className={cn( !isCodeBlock && "aui-md-inline-code bg-muted rounded-md px-1.5 py-0.5 font-mono text-[0.85em]", className, )} {...props} /> ); }, CodeHeader,});assistant-ui dependencies
"use client";import { type ComponentPropsWithRef, forwardRef } from "react";import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/components/ui/tooltip";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & { tooltip: string; side?: "top" | "bottom" | "left" | "right";};export const TooltipIconButton = forwardRef< HTMLButtonElement, TooltipIconButtonProps>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { return ( <TooltipProvider> <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon" {...rest} className={cn( "aui-button-icon size-6 p-1 active:scale-90", className, )} ref={ref} /> } > {children} <span className="aui-sr-only sr-only">{tooltip}</span> </TooltipTrigger> <TooltipContent side={side}>{tooltip}</TooltipContent> </Tooltip> </TooltipProvider> );});TooltipIconButton.displayName = "TooltipIconButton";shadcn/ui dependencies
npm install @base-ui/react"use client";import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";import { cn } from "@/lib/utils";function TooltipProvider({ delay = 0, ...props}: TooltipPrimitive.Provider.Props) { return ( <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} /> );}function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;}function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;}function TooltipContent({ className, side = "top", sideOffset = 4, align = "center", alignOffset = 0, children, ...props}: TooltipPrimitive.Popup.Props & Pick< TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >) { return ( <TooltipPrimitive.Portal> <TooltipPrimitive.Positioner align={align} alignOffset={alignOffset} side={side} sideOffset={sideOffset} className="isolate z-50" > <TooltipPrimitive.Popup data-slot="tooltip-content" className={cn( "bg-foreground text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm", className, )} {...props} > {children} <TooltipPrimitive.Arrow data-slot="tooltip-arrow" className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" /> </TooltipPrimitive.Popup> </TooltipPrimitive.Positioner> </TooltipPrimitive.Portal> );}export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };import { Button as ButtonPrimitive } from "@base-ui/react/button";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";const buttonVariants = cva( "group/button focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground aria-expanded:bg-secondary aria-expanded:text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", icon: "size-8", "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "icon-lg": "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, },);function Button({ className, variant = "default", size = "default", ...props}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { return ( <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> );}export { Button, buttonVariants };This adds a /components/assistant-ui/markdown-text.tsx file to your project, which you can adjust as needed.
Use it in your application
Pass the MarkdownText component to the MessagePrimitive.Parts component
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root className="...">
<div className="...">
<MessagePrimitive.Parts>
{({ part }) => part.type === "text" ? <MarkdownText /> : null}
</MessagePrimitive.Parts>
</div>
<AssistantActionBar />
<BranchPicker className="..." />
</MessagePrimitive.Root>
);
};Syntax highlighting
Syntax Highlighting is not included by default, see Syntax Highlighting to learn how to add it.
Related Components
- Syntax Highlighting - Add code highlighting to markdown
- Mermaid - Render diagrams in markdown code blocks