Conversation

Wraps messages and automatically scrolls to the bottom. Also includes a scroll button that appears when not at the bottom.

The Conversation component wraps messages and automatically scrolls to the bottom. Also includes a scroll button that appears when not at the bottom.

Install using CLI

AI Elements Vue
shadcn-vue CLI
npx ai-elements-vue@latest add conversation

Install Manually

Install the Markdown dependencies and copy the shared hook listed under Message.

Copy and paste the following code in the same folder.

Conversation.vue
ConversationContent.vue
ConversationEmptyState.vue
ConversationScrollButton.vue
index.ts
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { reactiveOmit } from '@vueuse/core'
import { StickToBottom } from 'vue-stick-to-bottom'
import { MarkdownProvider } from 'vue-stream-markdown'
import { useMarkdownExtensions } from '../message/useMarkdownExtensions'

interface Props {
  ariaLabel?: string
  class?: HTMLAttributes['class']
  initial?: boolean | 'instant' | { damping?: number, stiffness?: number, mass?: number }
  resize?: 'instant' | { damping?: number, stiffness?: number, mass?: number }
  damping?: number
  stiffness?: number
  mass?: number
  anchor?: 'auto' | 'none'
}

const props = withDefaults(defineProps<Props>(), {
  ariaLabel: 'Conversation',
  initial: true,
  damping: 0.7,
  stiffness: 0.05,
  mass: 1.25,
  anchor: 'none',
})
const delegatedProps = reactiveOmit(props, 'class')
const { provider, extensions } = useMarkdownExtensions()
</script>

<template>
  <StickToBottom
    v-bind="delegatedProps"
    :class="cn('relative flex-1 overflow-y-hidden', props.class)"
    role="log"
  >
    <MarkdownProvider v-if="!provider" :extensions="extensions">
      <slot />
    </MarkdownProvider>
    <slot v-else />
  </StickToBottom>
</template>

Usage with AI SDK

Build a simple conversational UI with Conversation and PromptInput:

Add the following component to your frontend:

pages/index.vue
<script setup lang="ts">
import { useChat } from '@ai-sdk/vue'
import { ref } from 'vue'
import {
  Conversation,
  ConversationContent,
  ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import { Message, MessageContent } from '@/components/ai-elements/message'
import {
  PromptInput,
  PromptInputSubmit,
  PromptInputTextarea,
} from '@/components/ai-elements/prompt-input'
import { Response } from '@/components/ai-elements/response'

const input = ref('')
const { messages, sendMessage, status } = useChat()

function handleSubmit(e: Event) {
  e.preventDefault()
  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>
          <Message v-for="m in messages" :key="m.id" :from="m.role">
            <MessageContent>
              <Response v-for="(p, i) in m.parts" :key="i">
                {{ p.type === 'text' ? p.text : '' }}
              </Response>
            </MessageContent>
          </Message>
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>

      <PromptInput class="mt-4 w-full max-w-2xl mx-auto relative" @submit="handleSubmit">
        <PromptInputTextarea
          :value="input"
          placeholder="Say something..."
          class="pr-12"
          @input="(e: any) => (input = e?.target?.value ?? '')"
        />
        <PromptInputSubmit
          :status="status === 'streaming' ? 'streaming' : 'ready'"
          :disabled="!input.trim()"
          class="absolute bottom-1 right-1"
        />
      </PromptInput>
    </div>
  </div>
</template>

Add the following route to your backend:

server/api/chat/route.ts
import { convertToModelMessages, streamText, UIMessage } from 'ai'

// Allow streaming responses up to 30 seconds
export const maxDuration = 30

export default defineEventHandler(async (event) => {
  const { messages }: { messages: UIMessage[] } = await readBody(event)

  const result = streamText({
    model: 'openai/gpt-4o',
    messages: convertToModelMessages(messages)
  })

  return result.toAIStreamResponse(event)
})

Features

  • Automatic scrolling to the bottom when new messages are added
  • Smooth scrolling behavior with configurable animation
  • Scroll button that appears when not at the bottom
  • Responsive design with customizable padding and spacing
  • Flexible content layout with consistent message spacing
  • Accessible with proper ARIA roles for screen readers
  • Customizable styling through class prop
  • Support for any number of child message components

Props

<Conversation />

ariaLabelstring
'Conversation'
Accessible label for the conversation container.
classstring
Additional classes applied to the container.
initialboolean | 'instant' | { damping?: number; stiffness?: number; mass?: number }
true
Controls initial stick-to-bottom behavior and spring options.
resize'instant' | { damping?: number; stiffness?: number; mass?: number }
Behavior when content resizes.
dampingnumber
0.7
Spring damping when scrolling to bottom.
stiffnessnumber
0.05
Spring stiffness when scrolling to bottom.
massnumber
1.25
Spring mass when scrolling to bottom.
anchor'auto' | 'none'
'none'
Anchoring strategy for stick-to-bottom.

<ConversationContent />

classstring
Additional classes applied to the content wrapper.

<ConversationEmptyState />

titlestring
'No messages yet'
The title text to display.
descriptionstring
'Start a conversation to see messages here'
The description text to display.
classstring
Additional classes applied to the empty state wrapper.

<ConversationScrollButton />

classstring
Additional classes applied to the scroll button.