DEV Community

Navnit Rai
Navnit Rai

Posted on

How to Create a Modern Chatbot Component in Vue.js Using Vuetify and CSS

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

Enter fullscreen mode Exit fullscreen mode

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">

Enter fullscreen mode Exit fullscreen mode

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">

Enter fullscreen mode Exit fullscreen mode

User Message Bubble

<div v-if="item.actor === 'user'" class="user-message">

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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'" />

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)