Learn how to use AI to automatically generate catchy, three-word chat titles based on the first user message in your Nuxt app. Add polish and personality with a smooth typewriter animation for an engaging user experience.
Get notified when we release new tutorials, lessons, and other expert Nuxt content.
This article is based on a lesson from Mastering Nuxt: Full Stack Unleashed.
When you build a chat application, the little details matter.
One of those details is how you name each conversation. Instead of asking users to type a title, why not let AI do the work? And while we're at it, let's make the result pop with a fun animation.
Let's walk through how you can use an AI-powered endpoint to generate chat titles based on the user's first message. We'll also add a typewriter animation to make those titles stand out.
Here's what we'll do:
Let's dive in.
The first step is to make sure our AI generates concise and relevant titles. We want each title to capture the essence of the first message, but keep it short: three words or less.
Here's how we update the AI service:
// layers/chat/server/services/ai-service.ts
export async function generateChatTitle(
model: LanguageModelV1,
firstMessage: string
): Promise<string> {
const response = await generateText({
model,
messages: [
{
role: 'system',
content:
'You are a helpful assistant that generates concise, descriptive titles for chat conversations. Generate a title that captures the essence of the first message in 3 short words or less.',
},
{
role: 'user',
content: firstMessage,
},
],
})
return response.text.trim()
}
The generateChatTitle
function takes two arguments: the AI model and the user's first message. We pass a system prompt that tells the AI to act as an assistant that creates short, descriptive titles. Then, we include the user's message as the user prompt.
By asking for "three short words or less," we get crisp, relevant titles.
Now, we want our frontend to use this AI endpoint whenever a new chat starts. This means updating the composables that handle chat creation and messaging.
useChats
ComposablePreviously, new chats were created locally with a generic title like "Chat 1." Now, we want to create chats via the server, so the backend can assign a title.
Here's the updated method:
// layers/chat/app/composables/useChats.ts
async function createChat(
options: { projectId?: string; title?: string } = {}
) {
const newChat = await $fetch<Chat>('/api/chats', {
method: 'POST',
body: {
title: options.title,
projectId: options.projectId,
},
})
chats.value.push(newChat)
return newChat
}
Instead of generating the chat locally, we make a POST request to /api/chats
. This lets the server handle things like assigning an ID and (eventually) updating the title.
The createChatAndNavigate
method can then use this to create a chat and navigate to it.
useChat
ComposableNext, let's add a method that can request an AI-generated title for a chat. We'll call this when the user sends their first message.
Here's the new method:
// layers/chat/app/composables/useChat.ts
async function generateChatTitle(message: string) {
if (!chat.value) return
const updatedChat = await $fetch<Chat>(
`/api/chats/${chatId}/title`,
{
method: 'POST',
body: {
message,
},
}
)
chat.value.title = updatedChat.title
}
This function sends the user's first message to the server, which responds with an updated chat object containing the new title. We then update the local chat's title.
Now, let's make sure we call this function at the right time: when the user sends the very first message in a chat.
Here's how we update the sendMessage
method:
// layers/chat/app/composables/useChat.ts
async function sendMessage(message: string) {
if (!chat.value) return
if (messages.value.length === 0) {
generateChatTitle(message)
}
const newMessage = await $fetch<ChatMessage>(
`/api/chats/${chatId}/messages`,
{
method: 'POST',
body: {
content: message,
role: 'user',
},
}
)
messages.value.push(newMessage)
const aiResponse = await $fetch<ChatMessage>(
`/api/chats/${chatId}/messages/generate`,
{
method: 'POST',
}
)
messages.value.push(aiResponse)
chat.value.updatedAt = new Date()
}
Notice that we call generateChatTitle(message)
only if this is the first message (messages.value.length === 0
). We don't use await
here, so the title generation runs in parallel with sending the message and getting the AI's response. This way, the UI doesn't wait for the title to be ready before showing the message.
A static title is fine, but a little animation can make your app feel more alive. Let's add a typewriter effect to the chat title.
Here's the full component:
<!-- layers/base/app/components/TypewriterText.vue -->
<template>
<span ref="textEl" class="typewriter-text">{{
text
}}</span>
</template>
<script setup lang="ts">
import { watch, ref } from 'vue'
const props = defineProps<{
text: string
}>()
const textEl = ref<HTMLElement | null>(null)
const animate = (el: HTMLElement) => {
const chars = el.textContent?.length || 0
el.animate(
[
{ clipPath: 'inset(0 100% 0 0)' },
{ clipPath: 'inset(0 0 0 0)' },
],
{
duration: Math.min(chars * 50, 1000), // Cap at 1 second
easing: 'steps(' + chars + ', end)',
}
)
}
// Watch for text changes and trigger animation
watch(
() => props.text,
() => {
if (textEl.value) {
animate(textEl.value)
}
}
)
</script>
<style scoped>
.typewriter-text {
display: inline-block;
position: relative;
white-space: nowrap;
}
</style>
This component animates the text using a CSS clip-path and the Web Animations API. When the text
prop changes, it triggers the animation.
Let's break down how this works.
The animation begins with clip-path: inset(0 100% 0 0)
, which initially hides all text by creating a clipping region that's 0% from the top, 100% from the right, 0% from the bottom, and 0% from the left - essentially making the visible area zero width.
Then the animation transitions to clip-path: inset(0 0 0 0)
, which reveals the full text by removing the clipping mask entirely.
The magic happens with the steps()
easing function.
This is what creates the typewriter effect. It divides the animation into discrete steps (one per character) rather than a smooth transition, making each character appear individually one after another.
We calculate the animation duration based on the number of characters (50ms per character), but cap it at 1 second to ensure longer texts don't animate too slowly. This gives us a consistent and pleasant experience regardless of title length.
Finally, the .typewriter-text
styles ensure the text doesn't wrap and maintains proper positioning during the animation, keeping everything clean and aligned.
Now, let's update the chat window to use this animated title.
<!-- layers/chat/app/components/ChatWindow.vue -->
<template>
<div ref="scrollContainer" class="scroll-container">
<UContainer class="chat-container">
<!-- ... -->
<template v-else>
<div class="chat-header">
<h1 class="title">
<TypewriterText
:text="chat.title || 'Untitled Chat'"
/>
</h1>
</div>
<!-- ... -->
</template>
</UContainer>
</div>
</template>
Instead of displaying the title as plain text, we wrap it in the TypewriterText
component. Now, whenever the title changes (for example, when the AI generates a new one), it animates in with a typewriter effect.
When a user starts a new chat and sends their first message, the following happens:
This makes the chat experience feel smarter and more lively. Users get a relevant, catchy title for each conversation, and the animation draws attention to the new title.
By letting AI generate chat titles and adding a bit of animation, you make your chat app more helpful and fun to use. Users don't have to think up titles, and the animated effect gives your interface a little extra personality.
You can take this idea further!
Maybe add more animations, or let users edit the AI-generated title if they want. But even this simple improvement goes a long way toward making your app feel polished and engaging.