A comprehensive suite of components for displaying chat messages, including message rendering, branching, actions, and markdown responses.
The Message component suite provides a complete set of tools for building chat interfaces. It includes components for displaying messages from users and AI assistants, managing multiple response branches, adding action buttons, and rendering markdown content.
AI Elements Vue
shadcn-vue CLI
npx ai-elements-vue@latest add message
npx shadcn-vue@latest add https://registry.ai-elements-vue.com/message.json
Copy and paste the following code in the same folder.
Message.vue
MessageContent.vue
MessageActions.vue
MessageAction.vue
MessageBranch.vue
MessageBranchContent.vue
MessageBranchSelector.vue
MessageBranchPrevious.vue
MessageBranchNext.vue
MessageBranchPage.vue
MessageResponse.vue
MessageToolbar.vue
context.ts
index.ts
< script setup lang = "ts" >
import type { UIMessage } from 'ai'
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
interface Props {
from : UIMessage [ 'role' ]
class ?: HTMLAttributes [ 'class' ]
}
const props = defineProps < Props >()
</ script >
< template >
< div
:class = "
cn(
'group flex w-full max-w-[80%] gap-2',
props.from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
props.class,
)
"
v-bind = "$attrs"
>
< slot / >
</ div >
</ template >
< script setup lang = "ts" >
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
interface Props {
class ?: HTMLAttributes [ 'class' ]
}
const props = defineProps < Props >()
</ script >
< template >
< div
:class = "
cn(
'is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm',
'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',
props.class,
)
"
v-bind = "$attrs"
>
< slot / >
</ div >
</ template >
< script setup lang = "ts" >
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
interface Props {
class ?: HTMLAttributes [ 'class' ]
}
const props = defineProps < Props >()
</ script >
< template >
< div
:class = "cn('flex items-center gap-1', props.class)"
v-bind = "$attrs"
>
< slot / >
</ div >
</ template >
< script setup lang = "ts" >
import type { ButtonVariants } from '@repo/shadcn-vue/components/ui/button'
import { Button } from '@repo/shadcn-vue/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@repo/shadcn-vue/components/ui/tooltip'
interface Props {
tooltip ?: string
label ?: string
variant ?: ButtonVariants [ 'variant' ]
size ?: ButtonVariants [ 'size' ]
}
const props = withDefaults ( defineProps < Props >(), {
variant: 'ghost' ,
size: 'icon-sm' ,
})
const buttonProps = {
variant: props.variant,
size: props.size,
type: 'button' as const ,
}
</ script >
< template >
< TooltipProvider v-if = "props.tooltip" >
< Tooltip >
< TooltipTrigger as-child >
< Button v-bind = "{ ...buttonProps, ...$attrs }" >
< slot / >
< span class = "sr-only" >
{{ props.label || props.tooltip }}</ span >
</ Button >
</ TooltipTrigger >
< TooltipContent >
< p >{{ props.tooltip }}</ p >
</ TooltipContent >
</ Tooltip >
</ TooltipProvider >
< Button v-else v-bind = "{ ...buttonProps, ...$attrs }" >
< slot / >
< span class = "sr-only" >{{ props.label || props.tooltip }}</ span >
</ Button >
</ template >
< script setup lang = "ts" >
import type { HTMLAttributes, VNode } from 'vue'
import type { MessageBranchContextType } from './context'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { provide, readonly, ref } from 'vue'
import { MessageBranchKey } from './context'
interface Props {
defaultBranch ?: number
class ?: HTMLAttributes [ 'class' ]
}
const props = withDefaults ( defineProps < Props >(), {
defaultBranch: 0 ,
})
const emits = defineEmits <{
( e : 'branchChange' , branchIndex : number ) : void
}>()
const currentBranch = ref < number >(props.defaultBranch)
const branches = ref < VNode []>([])
const totalBranches = ref < number >( 0 )
function handleBranchChange ( index : number ) {
currentBranch.value = index
emits ( 'branchChange' , index)
}
function goToPrevious () {
if (totalBranches.value === 0 )
return
const next = currentBranch.value > 0 ? currentBranch.value - 1 : totalBranches.value - 1
handleBranchChange (next)
}
function goToNext () {
if (totalBranches.value === 0 )
return
const next = currentBranch.value < totalBranches.value - 1 ? currentBranch.value + 1 : 0
handleBranchChange (next)
}
function setBranches ( count : number ) {
totalBranches.value = count
}
const contextValue : MessageBranchContextType = {
currentBranch: readonly (currentBranch),
totalBranches: readonly (totalBranches),
goToPrevious,
goToNext,
branches,
setBranches,
}
provide (MessageBranchKey, contextValue)
</ script >
< template >
< div
:class = "cn('grid w-full gap-2 [&>div]:pb-0', props.class)"
v-bind = "$attrs"
>
< slot / >
</ div >
</ template >
< script setup lang = "ts" >
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { computed, Fragment, isVNode, onMounted, useSlots, watch } from 'vue'
import { useMessageBranchContext } from './context'
interface Props {
class ?: HTMLAttributes [ 'class' ]
}
const props = defineProps < Props >()
const slots = useSlots ()
const { currentBranch , setBranches } = useMessageBranchContext ()
const branchVNodes = computed (() => {
const nodes = slots. default ?.() ?? []
const extractChildren = ( node : any ) : any [] => {
if ( isVNode (node) && node.type === Fragment) {
return Array. isArray (node.children) ? node.children : []
}
return [node]
}
const allNodes = nodes. flatMap (extractChildren)
return allNodes. filter (( node ) => {
if ( ! isVNode (node))
return false
return node.type && typeof node.type === 'object'
})
})
const sync = () => setBranches (branchVNodes.value. length )
onMounted (sync)
watch (branchVNodes, sync)
const baseClasses = computed (() => cn ( 'grid gap-2 overflow-hidden [&>div]:pb-0' , props.class))
</ script >
< template >
< template v-for = " (node, index) in branchVNodes " : key = " (node.key as any ) ?? index " >
< div
:class = "cn(baseClasses, index === currentBranch ? 'block' : 'hidden')"
v-bind = "$attrs"
>
< component :is = "node" />
</ div >
</ template >
</ template >
< script setup lang = "ts" >
import type { UIMessage } from 'ai'
import { ButtonGroup } from '@repo/shadcn-vue/components/ui/button-group'
import { useMessageBranchContext } from './context'
interface Props {
from : UIMessage [ 'role' ]
}
defineProps < Props >()
const { totalBranches } = useMessageBranchContext ()
</ script >
< template >
< ButtonGroup
v-if = "totalBranches > 1"
class = "[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation = "horizontal"
v-bind = "$attrs"
>
< slot / >
</ ButtonGroup >
</ template >
< script setup lang = "ts" >
import { Button } from '@repo/shadcn-vue/components/ui/button'
import { ChevronLeftIcon } from 'lucide-vue-next'
import { useMessageBranchContext } from './context'
const { goToPrevious , totalBranches } = useMessageBranchContext ()
</ script >
< template >
< Button
aria-label = "Previous branch"
:disabled = "totalBranches <= 1"
size = "icon-sm"
type = "button"
variant = "ghost"
v-bind = "$attrs"
@click = "goToPrevious"
>
< slot >
< ChevronLeftIcon :size = "14" />
</ slot >
</ Button >
</ template >
< script setup lang = "ts" >
import { Button } from '@repo/shadcn-vue/components/ui/button'
import { ChevronRightIcon } from 'lucide-vue-next'
import { useMessageBranchContext } from './context'
const { goToNext , totalBranches } = useMessageBranchContext ()
</ script >
< template >
< Button
aria-label = "Next branch"
:disabled = "totalBranches <= 1"
size = "icon-sm"
type = "button"
variant = "ghost"
v-bind = "$attrs"
@click = "goToNext"
>
< slot >
< ChevronRightIcon :size = "14" />
</ slot >
</ Button >
</ template >
< script setup lang = "ts" >
import type { HTMLAttributes } from 'vue'
import { ButtonGroupText } from '@repo/shadcn-vue/components/ui/button-group'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { useMessageBranchContext } from './context'
interface Props {
class ?: HTMLAttributes [ 'class' ]
}
const props = defineProps < Props >()
const { currentBranch , totalBranches } = useMessageBranchContext ()
</ script >
< template >
< ButtonGroupText
:class = "
cn(
'border-none bg-transparent text-muted-foreground shadow-none',
props.class,
)
"
v-bind = "$attrs"
>
{{ currentBranch + 1 }} of {{ totalBranches }}
</ ButtonGroupText >
</ template >
< script setup lang = "ts" >
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { computed, useSlots } from 'vue'
import { Markdown } from 'vue-stream-markdown'
import 'vue-stream-markdown/index.css'
interface Props {
content ?: string
class ?: HTMLAttributes [ 'class' ]
}
const props = defineProps < Props >()
const slots = useSlots ()
const slotContent = computed < string | undefined >(() => {
const nodes = slots. default ?.()
if ( ! Array. isArray (nodes)) {
return undefined
}
let text = ''
for ( const node of nodes) {
if ( typeof node.children === 'string' )
text += node.children
}
return text || undefined
})
const md = computed (() => (slotContent.value ?? props.content ?? '' ) as string )
</ script >
< template >
< Markdown
:content = "md"
:class = "
cn(
'size-full [&>*:first-child]:mt-0! [&>*:last-child]:mb-0!',
props.class,
)
"
v-bind = "$attrs"
/>
</ template >
< script setup lang = "ts" >
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
interface Props {
class ?: HTMLAttributes [ 'class' ]
}
const props = defineProps < Props >()
</ script >
< template >
< div
:class = "
cn(
'mt-4 flex w-full items-center justify-between gap-4',
props.class,
)
"
v-bind = "$attrs"
>
< slot / >
</ div >
</ template >
import type { InjectionKey, Ref, VNode } from 'vue'
import { inject } from 'vue'
export interface MessageBranchContextType < T = VNode []> {
currentBranch : Readonly < Ref < number >>
totalBranches : Readonly < Ref < number >>
goToPrevious : () => void
goToNext : () => void
branches : Ref < T >
setBranches : ( count : number ) => void
}
export const MessageBranchKey : InjectionKey < MessageBranchContextType >
= Symbol ( 'MessageBranch' )
export function useMessageBranchContext () : MessageBranchContextType {
const ctx = inject (MessageBranchKey)
if ( ! ctx) {
throw new Error ( 'Message Branch components must be used within Message Branch' )
}
return ctx
}
export { default as Message } from './Message.vue'
export { default as MessageAction } from './MessageAction.vue'
export { default as MessageActions } from './MessageActions.vue'
export { default as MessageAvatar } from './MessageAvatar.vue'
export { default as MessageBranch } from './MessageBranch.vue'
export { default as MessageBranchContent } from './MessageBranchContent.vue'
export { default as MessageBranchNext } from './MessageBranchNext.vue'
export { default as MessageBranchPage } from './MessageBranchPage.vue'
export { default as MessageBranchPrevious } from './MessageBranchPrevious.vue'
export { default as MessageBranchSelector } from './MessageBranchSelector.vue'
export { default as MessageContent } from './MessageContent.vue'
export { default as MessageResponse } from './MessageResponse.vue'
export { default as MessageToolbar } from './MessageToolbar.vue'
Build a simple chat UI where the user can copy or regenerate the most recent message.
Add the following component to your frontend:
pages/index.vue
< script setup lang = "ts" >
import { useChat } from '@ai-sdk/vue'
import { CopyIcon, RefreshCcwIcon } from 'lucide-vue-next'
import { ref } from 'vue'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import {
Message,
MessageAction,
MessageActions,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
import {
PromptInput,
PromptInputSubmit,
PromptInputTextarea,
} from '@/components/ai-elements/prompt-input'
const input = ref ( '' )
const { messages , sendMessage , status , regenerate } = useChat ()
function handleSubmit () {
if (input.value. trim ()) {
sendMessage ({ text: input.value })
input.value = ''
}
}
</ script >
< template >
< div
class = "max-w-4xl mx-auto p-6 relative size-full rounded-lg border h-[600px]"
>
< div class = "flex flex-col h-full" >
< Conversation >
< ConversationContent >
< template
v-for = " (message, messageIndex) in messages "
: key = " message.id "
>
< template v-for = " (part, i) in message.parts " : key = "`${ message . id }-${ i }`" >
< template v-if = " part.type === 'text'" >
< Message :from = "message.role" >
< MessageContent >
< MessageResponse :content = "part.text" />
</ MessageContent >
</ Message >
< MessageActions
v-if = "
message.role === 'assistant'
&& messageIndex === messages.length - 1
"
>
< MessageAction label = "Retry" @click = "regenerate()" >
< RefreshCcwIcon class = "size-3" />
</ MessageAction >
< MessageAction
label = "Copy"
@click = "navigator.clipboard.writeText(part.text)"
>
< CopyIcon class = "size-3" />
</ MessageAction >
</ MessageActions >
</ template >
</ template >
</ template >
</ ConversationContent >
< ConversationScrollButton />
</ Conversation >
< PromptInput
class = "mt-4 w-full max-w-2xl mx-auto relative"
@submit.prevent = "handleSubmit"
>
< PromptInputTextarea
v-model = "input"
placeholder = "Say something..."
class = "pr-12"
/>
< PromptInputSubmit
:status = "status === 'streaming' ? 'streaming' : 'ready'"
:disabled = "!input.trim()"
class = "absolute bottom-1 right-1"
/>
</ PromptInput >
</ div >
</ div >
</ template >
Displays messages from both user and AI assistant with distinct styling and automatic alignment Minimalist flat design with user messages in secondary background and assistant messages full-width Response branching with navigation controls to switch between multiple AI response versionsMarkdown rendering with GFM support (tables, task lists, strikethrough), math equations, and smart streamingAction buttons for common operations (retry, like, dislike, copy, share) with tooltips and state managementFile attachments display with support for images and generic files with preview and remove functionalityCode blocks with syntax highlighting and copy-to-clipboard functionality Keyboard accessible with proper ARIA labels Responsive design that adapts to different screen sizes Seamless light/dark theme integration Branching is an advanced use case you can implement to suit your needs. While the AI SDK does not provide built-in branching support, you have full flexibility to design and manage multiple response paths.
Build a simple chat UI where the user can copy or regenerate the most recent message.
Add the following component to your frontend:
pages/index.vue
< script setup lang = "ts" >
import { useChat } from '@ai-sdk/vue'
import { CopyIcon, RefreshCcwIcon } from 'lucide-vue-next'
import { ref } from 'vue'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import { Message, MessageAction, MessageActions, MessageContent, MessageResponse } from '@/components/ai-elements/message'
import {
PromptInput,
PromptInputSubmit,
PromptInputTextarea,
} from '@/components/ai-elements/prompt-input'
const { messages , append , status , reload } = useChat ()
const input = ref ( '' )
function handleSubmit () {
if (input.value. trim ()) {
append ({ role: 'user' , content: input.value })
input.value = ''
}
}
function copyToClipboard ( text : string ) {
navigator.clipboard. writeText (text)
}
</ script >
< template >
< div class = "max-w-4xl mx-auto p-6 relative size-full rounded-lg border h-[600px]" >
< div class = "flex flex-col h-full" >
< Conversation >
< ConversationContent >
< template v-for = " (message, messageIndex) in messages " : key = " message.id " >
< template v-if = " message.parts " >
< template v-for = " (part, i) in message.parts " : key = "`${ message . id }-${ i }`" >
< template v-if = " part.type === 'text'" >
< Message :from = "message.role" >
< MessageContent >
< MessageResponse >{{ part.text }}</ MessageResponse >
</ MessageContent >
</ Message >
< MessageActions
v-if = "message.role === 'assistant' && messageIndex === messages.length - 1"
>
< MessageAction label = "Retry" @click = "reload()" >
< RefreshCcwIcon class = "size-3" />
</ MessageAction >
< MessageAction label = "Copy" @click = "copyToClipboard(part.text)" >
< CopyIcon class = "size-3" />
</ MessageAction >
</ MessageActions >
</ template >
</ template >
</ template >
< template v-else >
< Message :from = "message.role" >
< MessageContent >
< MessageResponse >{{ message.content }}</ MessageResponse >
</ MessageContent >
</ Message >
< MessageActions
v-if = "message.role === 'assistant' && messageIndex === messages.length - 1"
>
< MessageAction label = "Retry" @click = "reload()" >
< RefreshCcwIcon class = "size-3" />
</ MessageAction >
< MessageAction label = "Copy" @click = "copyToClipboard(message.content)" >
< CopyIcon class = "size-3" />
</ MessageAction >
</ MessageActions >
</ template >
</ template >
</ ConversationContent >
< ConversationScrollButton />
</ Conversation >
< PromptInput
class = "mt-4 w-full max-w-2xl mx-auto relative bg-transparent border-0"
@submit = "handleSubmit"
>
< PromptInputTextarea
v-model = "input"
placeholder = "Say something..."
class = "pr-12"
/>
< PromptInputSubmit
:status = "status === 'streaming' ? 'streaming' : 'ready'"
:disabled = "!input.trim()"
class = "absolute bottom-1 right-1"
/>
</ PromptInput >
</ div >
</ div >
</ template >
Expand
from required UIMessage['role']
The role of the message sender ("user", "assistant", or "system"). Additional classes applied to the component. Additional classes applied to the component. Additional classes applied to the component. Optional tooltip text shown on hover. Accessible label for screen readers. Also used as fallback if tooltip is not provided. variant ButtonVariants['variant']
'ghost'
Optional button variant. size ButtonVariants['size']
'icon-sm'
Optional button size. The index of the branch to show by default. Additional classes applied to the component. Additional classes applied to the component. from required UIMessage['role']
The role of the message sender ("user", "assistant", or "system"). Additional classes applied to the component. The content of the message. Additional classes applied to the component. Additional classes applied to the component. Attachment components have been moved to a separate module. See the Attachments component documentation for details on <Attachments />, <Attachment />, <AttachmentPreview />, and <AttachmentInfo />.
Emitted when the branch changes.