Skip to content

Chat App ​

A complete AI chat interface composed from Chat Message, Chat Scroller and Prompt Input: a header, a welcome screen with suggestions, streaming messages that stay pinned to the bottom, and a composer whose send button turns into stop while generating.

New chat

vuelix assistant · demo

How can I help you today?

Enter to send · Shift + Enter for a new line

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/chat-app.json

The CLI also installs chat-message, chat-scroller and prompt-input.

Manual installation

Install the dependencies:

bash
npm install motion-v

Copy Chat Message, Chat Scroller and Prompt Input first, then these files into components/ui/chat-app/:

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ChatMessageFeedback } from '@/components/ui/chat-message'
import type { PromptAttachment } from '@/components/ui/prompt-input'
import { AnimatePresence, motion } from 'motion-v'
import { computed, nextTick, ref } from 'vue'
import { cn } from '@/lib/utils'
import { ChatMessage } from '@/components/ui/chat-message'
import { ChatScroller } from '@/components/ui/chat-scroller'
import { PromptInput } from '@/components/ui/prompt-input'

export interface ChatAppMessage {
  id: string
  from: 'user' | 'assistant'
  content: string
  /** The assistant is still writing this message. */
  streaming?: boolean
  timestamp?: string | Date
  feedback?: ChatMessageFeedback
}

const props = withDefaults(defineProps<{
  /** Conversation title shown in the header. */
  title?: string
  /** Small line under the title (model name, status…). */
  subtitle?: string
  /** A response is being generated (send turns into stop). */
  loading?: boolean
  /** Prompts offered on the empty state. */
  suggestions?: string[]
  /** Heading of the empty state. */
  greeting?: string
  placeholder?: string
  /** Assistant display name + avatar. */
  assistantName?: string
  assistantAvatar?: string
  allowAttachments?: boolean
  class?: HTMLAttributes['class']
}>(), {
  title: 'New chat',
  suggestions: () => [],
  greeting: 'How can I help you today?',
  placeholder: 'Send a message…',
  assistantName: 'Assistant',
  allowAttachments: true,
})

const messages = defineModel<ChatAppMessage[]>('messages', { default: () => [] })
const draft = defineModel<string>('draft', { default: '' })
const attachments = ref<PromptAttachment[]>([])

const emit = defineEmits<{
  /** The user sent a message. Append the assistant reply to `messages` yourself. */
  send: [text: string, attachments: PromptAttachment[]]
  stop: []
  /** Regenerate the given assistant message. */
  retry: [message: ChatAppMessage]
}>()

const scroller = ref<InstanceType<typeof ChatScroller>>()
const isEmpty = computed(() => messages.value.length === 0)

function send(text: string, files: PromptAttachment[] = []) {
  const value = text.trim()
  if (!value || props.loading)
    return
  emit('send', value, files)
  nextTick(() => scroller.value?.scrollToBottom())
}

function setFeedback(message: ChatAppMessage, value: ChatMessageFeedback) {
  messages.value = messages.value.map(m => (m.id === message.id ? { ...m, feedback: value } : m))
}

const spring = { type: 'spring', stiffness: 320, damping: 30 } as const
</script>

<template>
  <section
    :aria-label="props.title"
    :class="cn('flex h-[560px] w-full flex-col overflow-hidden rounded-2xl border bg-background text-foreground shadow-sm', props.class)"
  >
    <header class="flex shrink-0 items-center gap-3 border-b px-4 py-3">
      <slot name="header">
        <span class="grid size-8 place-items-center rounded-full bg-brand/15 text-brand" aria-hidden="true">
          <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 4.6L18.5 9.5l-4.6 1.9L12 16l-1.9-4.6L5.5 9.5l4.6-1.9z" /><path d="M19 15l.8 1.9 1.9.8-1.9.8L19 20.4l-.8-1.9-1.9-.8 1.9-.8z" /></svg>
        </span>
        <div class="min-w-0 flex-1">
          <AnimatePresence mode="popLayout" :initial="false">
            <motion.p
              :key="props.title"
              class="m-0 truncate text-sm font-semibold"
              :initial="{ opacity: 0, y: 6 }"
              :animate="{ opacity: 1, y: 0 }"
              :exit="{ opacity: 0, y: -6 }"
              :transition="spring"
            >
              {{ props.title }}
            </motion.p>
          </AnimatePresence>
          <p v-if="props.subtitle" class="m-0 truncate text-xs text-muted-foreground">
            {{ props.subtitle }}
          </p>
        </div>
        <slot name="header-actions" />
      </slot>
    </header>

    <div class="relative min-h-0 flex-1">
      <AnimatePresence mode="wait" :initial="false">
        <motion.div
          v-if="isEmpty"
          key="empty"
          class="absolute inset-0 flex flex-col items-center justify-center gap-6 p-6 text-center"
          :initial="{ opacity: 0, scale: 0.98 }"
          :animate="{ opacity: 1, scale: 1 }"
          :exit="{ opacity: 0, scale: 0.98, filter: 'blur(4px)', transition: { duration: 0.18 } }"
        >
          <slot name="empty" :send="send">
            <p class="m-0 text-xl font-semibold tracking-tight">
              {{ props.greeting }}
            </p>
            <ul v-if="props.suggestions.length" class="m-0 grid w-full max-w-lg list-none gap-2 p-0 sm:grid-cols-2">
              <motion.li
                v-for="(s, i) in props.suggestions"
                :key="s"
                :initial="{ opacity: 0, y: 10 }"
                :animate="{ opacity: 1, y: 0, transition: { ...spring, delay: 0.05 * i } }"
              >
                <button
                  type="button"
                  class="w-full rounded-xl border bg-card px-3.5 py-2.5 text-left text-sm text-card-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50"
                  @click="send(s)"
                >
                  {{ s }}
                </button>
              </motion.li>
            </ul>
          </slot>
        </motion.div>

        <ChatScroller
          v-else
          key="log"
          ref="scroller"
          :label="`${props.title} messages`"
          class="absolute inset-0"
          content-class="mx-auto w-full max-w-2xl"
        >
          <ChatMessage
            v-for="m in messages"
            :key="m.id"
            :from="m.from"
            :author="m.from === 'assistant' ? props.assistantName : undefined"
            :avatar="m.from === 'assistant' ? props.assistantAvatar : undefined"
            :timestamp="m.timestamp"
            :content="m.content"
            :streaming="m.streaming"
            :feedback="m.feedback ?? null"
            @update:feedback="setFeedback(m, $event)"
            @retry="emit('retry', m)"
          />
        </ChatScroller>
      </AnimatePresence>
    </div>

    <div class="shrink-0 px-4 pb-4 pt-2">
      <PromptInput
        v-model="draft"
        v-model:attachments="attachments"
        class="mx-auto max-w-2xl"
        :placeholder="props.placeholder"
        :loading="props.loading"
        :allow-attachments="props.allowAttachments"
        clear-on-submit
        @submit="send"
        @stop="emit('stop')"
      />
    </div>
  </section>
</template>
ts
export { default as ChatApp } from './ChatApp.vue'
export type { ChatAppMessage } from './ChatApp.vue'

Usage ​

ts
import { ChatApp, type ChatAppMessage } from '@/components/ui/chat-app'
vue
<script setup lang="ts">
const messages = ref<ChatAppMessage[]>([])
const loading = ref(false)

async function onSend(text: string) {
  messages.value.push({ id: crypto.randomUUID(), from: 'user', content: text })
  const reply = { id: crypto.randomUUID(), from: 'assistant' as const, content: '', streaming: true }
  messages.value.push(reply)
  loading.value = true
  for await (const chunk of streamFromYourApi(text)) // your backend
    reply.content += chunk
  reply.streaming = false
  loading.value = false
}
</script>

<template>
  <ChatApp
    v-model:messages="messages"
    :loading="loading"
    :suggestions="['Summarise this page', 'Write a haiku']"
    @send="onSend"
  />
</template>

API ​

PropTypeDefaultDescription
v-model:messagesChatAppMessage[][]{ id, from: 'user' | 'assistant', content, streaming?, timestamp?, feedback? }
v-model:draftstring''Composer text.
loadingbooleanfalseA reply is being generated (send becomes stop).
title / subtitlestring'New chat'Header text (the title cross-fades when it changes).
suggestionsstring[][]Prompts shown on the empty state; clicking one sends it.
greetingstring'How can I help you today?'Empty-state heading.
placeholderstring'Send a message…'Composer placeholder.
assistantName / assistantAvatarstring'Assistant'Assistant identity on messages.
allowAttachmentsbooleantrueAttach / drop / paste files in the composer.
EventPayloadDescription
send(text, attachments)The user sent a message: append it and the assistant reply to messages.
stop—The stop button was pressed.
retry(message)Regenerate an assistant message.

Slots: header, header-actions, empty ({ send }).

Designed by Kuldeep Pisda · Released under the MIT License