Building a chatbot UI is no longer just a “nice to have” feature — it’s becoming a core part of modern web applications. Whether you’re creating a customer support bot, appointment booking assistant, or AI chat interface, Vue.js combined with Vuetify makes the job clean, scalable, and visually polished.
In this post, you’ll learn how to create a fully functional chatbot component in Vue.js using Vuetify and custom CSS, inspired by popular messaging apps like WhatsApp.
What You’ll Build
By the end of this tutorial, you’ll have a chatbot that includes:
- WhatsApp-style chat UI
- AI & user message bubbles
- Typing indicator animation
- Quick reply buttons
- Emoji picker
- API integration using Axios
- Auto-scrolling chat history
- Modular widget actions (Booking / Ticket widgets)
Tech Stack Used
- Vue.js (Options API)
- Vuetify (Material UI for Vue)
- Axios (API calls)
- CSS Animations
- REST API (Chat backend)
Chatbot Component Structure
- At a high level, the chatbot UI is divided into three main sections:
- Header – Bot name, avatar, online status
- Messages Area – Chat history, typing animation, quick replies
- Input Area – Message input, emoji picker, send button
ChatbotCard
├── Header
├── MessageContainer
│ ├── AI Messages
│ ├── User Messages
│ ├── Typing Indicator
│ └── Widgets
└── Input Area
1. Creating the Chatbot Layout (Vuetify Card)
We use a v-card as the chatbot container to give it a clean, app-like feel.
<v-card class="chatbot-container d-flex flex-column" height="90vh" rounded="xl">
This makes the chatbot:
- Responsive
- Scrollable
- Mobile-friendly
2. Chat Header (Custom Toolbar)
Instead of using v-toolbar, a custom header gives you full control over design:
Features:
- Back button
- Bot avatar
- Online status
- Menu icon
This is perfect for embedded chatbots or mobile layouts.
3. Rendering Chat Messages (AI vs User)
Messages are rendered dynamically using v-for from chat_history.
AI Message Bubble
<div v-if="item.actor === 'ai'" class="ai-message">
User Message Bubble
<div v-if="item.actor === 'user'" class="user-message">
Each message includes:
- Text
- Timestamp
- Smooth fade-in animation
- Responsive max width
4. Quick Reply Buttons
Quick replies improve UX and reduce typing friction.
<v-btn
v-for="button in item.button"
@click="handleButtonClick(button)"
>
{{ button.title }}
</v-btn>
Use cases:
- Appointment booking
- FAQs
- Menu-based navigation
- AI actions
5. Action-Based Widgets (Advanced Feature)
**
Your chatbot supports dynamic UI widgets, such as:**
<BookingWidget v-if="currentAction === 'SHOW_BOOKING_WIDGET'" />
<TicketWidget v-if="currentAction === 'SHOW_TICKET_WIDGET'" />
This allows:
- Appointment booking inside chat
- Forms without navigation
- Conversational workflows
<template>
<v-card
class="chatbot-container d-flex flex-column"
height="90vh"
elevation="0"
rounded="xl"
>
<!-- Custom Header (no v-toolbar) -->
<div class="chatbot-header">
<div class="d-flex align-center pa-3">
<v-btn
icon
variant="text"
size="small"
@click="$emit('close')"
class="mr-2"
>
<v-icon color="white">mdi-arrow-left</v-icon>
</v-btn>
<v-avatar size="40" class="mr-3 bot-avatar">
<v-icon color="white">mdi-robot-happy</v-icon>
</v-avatar>
<div class="flex-grow-1">
<div class="text-white font-weight-medium">QR Buddy Bot</div>
<div class="text-caption" style="color: rgba(255,255,255,0.8);">
<v-icon size="8" color="success" class="mr-1">mdi-circle</v-icon>
Online
</div>
</div>
<v-btn
icon
variant="text"
size="small"
>
<v-icon color="white">mdi-dots-vertical</v-icon>
</v-btn>
</div>
</div>
<!-- Chat messages area -->
<v-card-text class="flex-grow-1 overflow-y-auto pa-3 message-container" ref="messageContainer">
<div v-if="isLoading" class="d-flex justify-center align-center fill-height">
<div class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="48"
width="4"
></v-progress-circular>
<p class="mt-3 text-grey">Loading chat...</p>
</div>
</div>
<div v-else>
<transition-group name="message" tag="div">
<div v-for="(item, index) in chat_history" :key="index" class="mb-2 message-item">
<!-- AI Message -->
<div v-if="item.actor === 'ai'" class="d-flex align-start message-fade-in">
<v-avatar class="mr-2 flex-shrink-0" size="32" color="primary">
<v-icon color="white" size="18">mdi-robot-happy</v-icon>
</v-avatar>
<div class="flex-grow-1" style="max-width: calc(100% - 48px);">
<div class="ai-message">
<p class="text-body-2 mb-0 message-text">{{ item.message }}</p>
<div class="d-flex align-center justify-end mt-1">
<span class="message-time">{{ formatTime(item.timestamp) }}</span>
</div>
</div>
<!-- Quick Reply Buttons -->
<div v-if="item.button && item.button.length" class="d-flex flex-wrap mt-2 gap-2">
<v-btn
v-for="(button, i) in item.button"
:key="i"
class="quick-reply-btn"
variant="outlined"
size="small"
rounded="xl"
@click="handleButtonClick(button)"
:color="button.color || 'primary'"
>
<v-icon v-if="button.icon" size="16" class="mr-1">{{ button.icon }}</v-icon>
{{ button.title }}
</v-btn>
</div>
</div>
</div>
<!-- User Message -->
<div v-if="item.actor === 'user'" class="d-flex justify-end message-fade-in">
<div class="user-message">
<p class="text-body-2 text-white mb-0 message-text">
{{ item.display_text || item.message }}
</p>
<div class="d-flex align-center justify-end mt-1">
<span class="message-time-user">{{ formatTime(item.timestamp) }}</span>
<v-icon size="14" color="white" class="ml-1">mdi-check-all</v-icon>
</div>
</div>
</div>
</div>
</transition-group>
<!-- Typing indicator -->
<div v-if="isTyping" class="d-flex align-start mb-2 message-fade-in">
<v-avatar class="mr-2" size="32" color="primary">
<v-icon color="white" size="18">mdi-robot-happy</v-icon>
</v-avatar>
<div class="ai-message typing-bubble">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<!-- Widgets -->
<BookingWidget v-if="currentAction === 'SHOW_BOOKING_WIDGET'" />
<TicketWidget v-if="currentAction === 'SHOW_TICKET_WIDGET'" />
</v-card-text>
<!-- WhatsApp-style Input area -->
<div class="input-area">
<div class="input-container">
<!-- Emoji Picker -->
<v-menu
location="top"
:close-on-content-click="false"
offset="8"
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
size="small"
class="emoji-trigger"
>
<v-icon color="grey-darken-1">mdi-emoticon-happy-outline</v-icon>
</v-btn>
</template>
<v-card class="emoji-picker" rounded="xl" elevation="8">
<v-card-text class="pa-3">
<div class="text-caption text-grey mb-2 font-weight-medium">Frequently Used</div>
<div class="emoji-grid">
<button
v-for="emoji in quickEmojis"
:key="emoji"
class="emoji-btn"
@click="newMessage += emoji"
>
{{ emoji }}
</button>
</div>
</v-card-text>
</v-card>
</v-menu>
<!-- Message Input Field -->
<div class="message-input-wrapper">
<input
v-model="newMessage"
type="text"
placeholder="Type a message"
class="message-input"
@keyup.enter="sendMessage"
/>
</div>
<!-- Attachment Button (optional) -->
<v-btn
v-if="!newMessage.trim()"
icon
variant="text"
size="small"
class="attachment-btn"
>
<v-icon color="grey-darken-1">mdi-paperclip</v-icon>
</v-btn>
<!-- Camera Button (optional) -->
<v-btn
v-if="!newMessage.trim()"
icon
variant="text"
size="small"
class="camera-btn"
>
<v-icon color="grey-darken-1">mdi-camera</v-icon>
</v-btn>
<!-- Send/Voice Button -->
<v-btn
icon
size="small"
class="send-button"
:class="{ 'has-text': newMessage.trim() }"
@click="sendMessage"
elevation="0"
>
<v-icon color="white" size="20">
{{ newMessage.trim() ? 'mdi-send' : 'mdi-microphone' }}
</v-icon>
</v-btn>
</div>
</div>
</v-card>
</template>
<script>
import BookingWidget from './BookingWidget.vue';
import TicketWidget from './TicketWidget.vue';
import axios from 'axios';
export default {
components: {
BookingWidget,
TicketWidget
},
data() {
return {
newMessage: '',
isLoading: false,
isTyping: false,
user_id: 'user_1138',
chat_history: [
{
actor: 'user',
message: 'hi how are you',
timestamp: '2025-07-25T05:21:41.636Z'
},
{
actor: 'ai',
type: 'text',
message: 'I am doing well, thank you for asking! How are you?',
timestamp: '2025-07-25T05:21:42.696Z'
},
{
actor: 'user',
message: 'i need a help',
timestamp: '2025-07-25T05:21:52.965Z'
},
{
actor: 'ai',
type: 'text',
message: "Okay, I'm here to help! Please tell me what you need help with. The more details you can provide, the better I can assist you.",
timestamp: '2025-07-25T05:21:55.172Z'
},
{
actor: 'user',
message: 'i want to book appointment',
timestamp: '2025-07-25T05:22:05.565Z'
},
{
actor: 'ai',
type: 'text',
message: 'Great! I can help you book an appointment. To get started, I need some information from you. What type of appointment would you like to book?',
timestamp: '2025-07-25T05:22:07.368Z',
action: 'SHOW_BOOKING_WIDGET',
button: [
{ title: 'Doctor Appointment', payload: 'book_doctor', icon: 'mdi-hospital-box', color: 'primary' },
{ title: 'Salon Booking', payload: 'book_salon', icon: 'mdi-content-cut', color: 'purple' },
{ title: 'Service Appointment', payload: 'book_service', icon: 'mdi-wrench', color: 'orange' }
]
},
{
actor: 'user',
message: 'ok thanks for provided me guidence',
timestamp: '2025-07-25T05:22:28.668Z'
},
{
actor: 'ai',
type: 'text',
message: "You're welcome! Let me know if you have any other questions or need further assistance with anything else.",
timestamp: '2025-07-25T05:22:30.356Z'
}
],
currentAction: null,
quickEmojis: ['😊', '😂', '❤️', '👍', '🙏', '🎉', '👏', '🔥', '✨', '💯', '🚀', '💪', '😍', '🤔', '👌', '🙌']
};
},
async created() {
await this.loadData();
},
mounted() {
this.scrollToBottom();
},
updated() {
this.scrollToBottom();
},
methods: {
async loadData() {
this.isLoading = true;
try {
const response = await axios.get(`http://localhost:3005/chat/history`, {
params: { user_id: this.user_id }
});
this.chat_history = response.data;
} catch (error) {
console.error('Error loading chat history:', error);
} finally {
this.isLoading = false;
}
},
async sendMessage() {
const messageText = this.newMessage.trim();
if (!messageText) return;
this.chat_history.push({
actor: 'user',
message: messageText,
timestamp: new Date().toISOString()
});
this.newMessage = '';
this.isTyping = true;
await this.postChatUpdate();
this.isTyping = false;
},
async handleButtonClick(button) {
this.chat_history.push({
actor: 'user',
type: 'payload',
message: button.payload,
display_text: button.title,
timestamp: new Date().toISOString()
});
this.isTyping = true;
await this.postChatUpdate();
this.isTyping = false;
},
async postChatUpdate() {
try {
const response = await axios.post('http://localhost:3005/chat', {
user_id: 1,
chat_history: this.chat_history
});
this.chat_history = response.data.chat_history;
const lastAiMessage = [...this.chat_history].reverse().find((m) => m.actor === 'ai' && m.action);
this.currentAction = lastAiMessage ? lastAiMessage.action : null;
} catch (error) {
console.error('Error sending message:', error);
this.chat_history.push({
actor: 'ai',
type: 'text',
message: "Sorry, I couldn't connect to the server. Please try again later.",
timestamp: new Date().toISOString()
});
}
},
formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messageContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
}
};
</script>
<style scoped>
* {
box-sizing: border-box;
}
.chatbot-container {
background: #f0f2f5;
position: relative;
max-width: 100%;
margin: 0 auto;
}
/* Header Styling */
.chatbot-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.bot-avatar {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px);
}
/* Message Container */
.message-container {
background: #e5ddd5;
background-image:
url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23d9d9d9' fill-opacity='0.1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
position: relative;
}
.message-container::-webkit-scrollbar {
width: 6px;
}
.message-container::-webkit-scrollbar-track {
background: transparent;
}
.message-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
}
/* AI Message Bubbles */
.ai-message {
background: white;
padding: 8px 12px;
border-radius: 8px;
border-top-left-radius: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
display: inline-block;
max-width: 100%;
word-wrap: break-word;
}
/* User Message Bubbles */
.user-message {
background: #a5a9eb;
padding: 8px 12px;
border-radius: 8px;
border-top-right-radius: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
display: inline-block;
max-width: 75%;
word-wrap: break-word;
margin-left: auto;
}
.message-text {
line-height: 1.4;
color: #303030;
font-size: 14px;
}
.message-time {
font-size: 11px;
color: #0b0000;
margin-top: 2px;
}
.message-time-user {
font-size: 11px;
color: rgb(246, 243, 243);
margin-top: 2px;
}
/* Quick Reply Buttons */
.quick-reply-btn {
text-transform: none !important;
font-weight: 500;
font-size: 13px;
height: 32px !important;
border-width: 1.5px !important;
transition: all 0.2s ease;
}
.quick-reply-btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.gap-2 {
gap: 8px;
}
/* Typing Indicator */
.typing-bubble {
padding: 12px 16px;
}
.typing-indicator {
display: flex;
align-items: center;
gap: 4px;
}
.typing-indicator span {
height: 8px;
width: 8px;
background-color: #90949c;
border-radius: 50%;
display: inline-block;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) {
animation-delay: 0s;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.5;
}
30% {
transform: translateY(-8px);
opacity: 1;
}
}
/* WhatsApp-style Input Area */
.input-area {
background: #f0f2f5;
padding: 8px 12px 8px 8px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.input-container {
display: flex;
align-items: center;
gap: 6px;
background: white;
border-radius: 24px;
padding: 4px 4px 4px 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.emoji-trigger, .attachment-btn, .camera-btn {
min-width: 36px !important;
width: 36px !important;
height: 36px !important;
}
.message-input-wrapper {
flex: 1;
position: relative;
}
.message-input {
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: 15px;
padding: 8px 12px;
color: #303030;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.message-input::placeholder {
color: #667781;
}
.send-button {
min-width: 40px !important;
width: 40px !important;
height: 40px !important;
background: #b0b3b8 !important;
border-radius: 50% !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.send-button.has-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
transform: scale(1);
}
.send-button:hover {
transform: scale(1.05);
}
.send-button:active {
transform: scale(0.95);
}
/* Emoji Picker */
.emoji-picker {
max-width: 300px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
}
.emoji-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 20px;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.emoji-btn:hover {
background: #f0f2f5;
transform: scale(1.2);
}
.emoji-btn:active {
transform: scale(0.95);
}
/* Message Animations */
.message-fade-in {
animation: fadeInUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-enter-active {
animation: fadeInUp 0.3s ease-out;
}
.message-leave-active {
animation: fadeInUp 0.2s ease-in reverse;
}
/* Mobile Optimizations */
@media (max-width: 600px) {
.chatbot-container {
border-radius: 10 !important;
}
.user-message {
max-width: 80%;
}
.emoji-grid {
grid-template-columns: repeat(6, 1fr);
}
}
/* Prevent text selection on buttons */
.v-btn {
user-select: none;
-webkit-user-select: none;
}
/* Focus states */
.message-input:focus {
outline: none;
}
/* Smooth scrolling */
.message-container {
scroll-behavior: smooth;
}
</style>

Top comments (0)