The Vercel AI Chatbot uses a modular component architecture built with React, shadcn/ui, and Tailwind CSS. You can customize existing components or create new ones to match your application’s needs.
Component structure
Components are organized in three main directories:
components/ui/ - Base shadcn/ui components (Button, Card, Input, etc.)
components/ai-elements/ - AI-specific components (Message, Conversation, etc.)
components/ - Application-level components (Chat, Sidebar, etc.)
Message components
Message components form the core of the chat interface.
Basic message structure
components/ai-elements/message.tsx
import { cn } from "@/lib/utils";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
Message content
Message content is styled differently based on the sender:
components/ai-elements/message.tsx
export const MessageContent = ({ children, className, ...props }) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg",
"group-[.is-user]:bg-secondary group-[.is-user]:px-4",
"group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
User messages have a secondary background and are right-aligned, while assistant messages have no background and are left-aligned. Customize these styles by modifying the className utilities.
Message actions
Action buttons for copying, editing, or regenerating messages:
components/ai-elements/message.tsx
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
);
}
return button;
};
Customizing message appearance
Create custom message wrapper
Extend the base Message component with custom styling:components/custom-message.tsx
import { Message, MessageContent } from "@/components/ai-elements/message";
export function CustomMessage({ from, children, ...props }) {
return (
<Message from={from} {...props}>
<MessageContent className="group-[.is-user]:bg-blue-500 group-[.is-user]:text-white">
{children}
</MessageContent>
</Message>
);
}
Add custom message metadata
Display timestamps, user avatars, or other metadata:components/custom-message.tsx
export function MessageWithTimestamp({ from, timestamp, children }) {
return (
<Message from={from}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Avatar src={from === "user" ? userAvatar : assistantAvatar} />
<time>{new Date(timestamp).toLocaleTimeString()}</time>
</div>
<MessageContent>{children}</MessageContent>
</Message>
);
}
Use in chat interface
Replace the default message component in your chat interface:import { CustomMessage } from "@/components/custom-message";
export function Chat({ messages }) {
return (
<div className="flex flex-col gap-4">
{messages.map((message) => (
<CustomMessage
key={message.id}
from={message.role}
timestamp={message.createdAt}
>
{message.content}
</CustomMessage>
))}
</div>
);
}
Buttons use class-variance-authority (CVA) for type-safe variant management:
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md...",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
"icon-sm": "h-8 w-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2...",
{
variants: {
variant: {
// ... existing variants
success: "bg-green-600 text-white hover:bg-green-700",
warning: "bg-yellow-600 text-white hover:bg-yellow-700",
},
size: {
// ... existing sizes
xs: "h-6 px-2 text-xs",
},
},
}
);
Use custom variants:
<Button variant="success" size="xs">Save</Button>
<Button variant="warning" size="sm">Warning</Button>
Card components
Cards are used for displaying structured content:
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
ref={ref}
{...props}
/>
)
);
const CardHeader = ({ className, ...props }) => (
<div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
);
const CardTitle = ({ className, ...props }) => (
<div
className={cn("font-semibold text-2xl leading-none tracking-tight", className)}
{...props}
/>
);
const CardContent = ({ className, ...props }) => (
<div className={cn("p-6 pt-0", className)} {...props} />
);
Using cards
<Card>
<CardHeader>
<CardTitle>Weather in San Francisco</CardTitle>
<CardDescription>Current conditions</CardDescription>
</CardHeader>
<CardContent>
<p>Temperature: 72°F</p>
<p>Conditions: Sunny</p>
</CardContent>
<CardFooter>
<Button>Refresh</Button>
</CardFooter>
</Card>
Message attachments
Display file attachments in messages:
components/ai-elements/message.tsx
export function MessageAttachment({ data, className, onRemove }) {
const filename = data.filename || "";
const mediaType = data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image";
return (
<div className={cn("group relative size-24 overflow-hidden rounded-lg", className)}>
{isImage ? (
<>
<img
alt={filename || "attachment"}
className="size-full object-cover"
src={data.url}
/>
{onRemove && (
<Button
className="absolute top-2 right-2 size-6 rounded-full opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
variant="ghost"
>
<XIcon />
</Button>
)}
</>
) : (
<div className="flex size-full items-center justify-center bg-muted">
<PaperclipIcon className="size-4" />
</div>
)}
</div>
);
}
Branch navigation
Allow users to navigate between message variations:
components/ai-elements/message.tsx
export const MessageBranch = ({ defaultBranch = 0, onBranchChange, ...props }) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const goToPrevious = () => {
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToNext = () => {
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
return (
<MessageBranchContext.Provider value={{ currentBranch, goToPrevious, goToNext }}>
<div {...props} />
</MessageBranchContext.Provider>
);
};
Usage:
<MessageBranch>
<MessageBranchContent>
<MessageResponse>First variation</MessageResponse>
<MessageResponse>Second variation</MessageResponse>
<MessageResponse>Third variation</MessageResponse>
</MessageBranchContent>
<MessageBranchSelector from="assistant">
<MessageBranchPrevious />
<MessageBranchPage />
<MessageBranchNext />
</MessageBranchSelector>
</MessageBranch>
Create custom components to display tool results:
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function WeatherCard({ data }) {
return (
<Card>
<CardHeader>
<CardTitle>
Weather in {data.cityName || "Unknown Location"}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="text-4xl font-bold">
{data.current.temperature_2m}°C
</div>
<div className="text-muted-foreground">
<p>Sunrise: {data.daily.sunrise[0]}</p>
<p>Sunset: {data.daily.sunset[0]}</p>
</div>
</div>
</CardContent>
</Card>
);
}
Register custom UI in your chat interface:
function renderToolResult(toolName: string, result: any) {
switch (toolName) {
case "getWeather":
return <WeatherCard data={result} />;
case "getStockPrice":
return <StockPriceCard data={result} />;
default:
return <pre>{JSON.stringify(result, null, 2)}</pre>;
}
}
Markdown rendering
Messages support markdown rendering with Streamdown:
components/ai-elements/message.tsx
import { Streamdown } from "streamdown";
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
Streamdown automatically renders:
- Code blocks with syntax highlighting
- Tables
- Lists
- Links
- Emphasis and strong text
- Math equations (KaTeX)
Accessibility features
Screen reader support: Use <span className="sr-only"> for icon-only buttons to provide context for screen readers.
<Button variant="ghost" size="icon-sm">
<TrashIcon />
<span className="sr-only">Delete message</span>
</Button>
Keyboard navigation: All interactive components support keyboard navigation. Focus states are styled with focus-visible:ring-2.
ARIA labels: Use aria-label for dynamic or complex UI elements:
<button aria-label="Next branch" onClick={goToNext}>
<ChevronRightIcon />
</button>
Component composition patterns
Slot pattern for flexible composition
import { Slot as SlotPrimitive } from "radix-ui";
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Use asChild to render a button as a link:
<Button asChild>
<Link href="/chat/new">New Chat</Link>
</Button>
Context-based component communication
The message branch system uses context for state management:
const MessageBranchContext = createContext<MessageBranchContextType | null>(null);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error("MessageBranch components must be used within MessageBranch");
}
return context;
};
Use composition patterns to build flexible, reusable components that can be easily customized without modifying the source code.