What's the cleanest way to do v-model on a custom component now?
Chat Message
A chat message with avatar, streaming caret and copy, retry and feedback actions that fade in on hover.
vuelix assistant
vue
<script setup lang="ts">
import type { ChatMessageFeedback } from '@/components/ui/chat-message'
import { ChatMessage } from '@/components/ui/chat-message'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
type Block = { type: 'p', text: string } | { type: 'ul', items: string[] }
const answer: Block[] = [
{ type: 'p', text: 'Use `defineModel()` — it replaces the old `modelValue` prop + `update:modelValue` emit pair with a single ref:' },
{ type: 'ul', items: [
'Reading it gives you the current value from the parent.',
'Assigning to it emits the update for you.',
'Pass a name for extra models, like `defineModel(\'open\')`.',
] },
{ type: 'p', text: 'It needs **Vue 3.4+** and works with any `v-model` on the parent.' },
]
// Count words so we can reveal the answer progressively, like a token stream.
const words = (s: string) => s.split(' ').length
const total = answer.reduce((n, b) => n + (b.type === 'p' ? words(b.text) : b.items.reduce((m, i) => m + words(i), 0)), 0)
const shown = ref(0)
const streaming = ref(false)
const feedback = ref<ChatMessageFeedback>(null)
let timer: ReturnType<typeof setInterval> | undefined
function take(text: string, budget: { left: number }) {
const w = text.split(' ')
const n = Math.min(w.length, budget.left)
budget.left -= n
return w.slice(0, n).join(' ')
}
const visible = computed(() => {
const budget = { left: shown.value }
const out: Block[] = []
for (const b of answer) {
if (budget.left <= 0)
break
if (b.type === 'p')
out.push({ type: 'p', text: take(b.text, budget) })
else
out.push({ type: 'ul', items: b.items.map(i => take(i, budget)).filter(Boolean) })
}
return out
})
/** `code` and **bold** → segments (demo-only formatting). */
function segments(text: string) {
return text.split(/(`[^`]*`?|\*\*[^*]*\**)/).filter(Boolean).map(s =>
s.startsWith('`')
? { kind: 'code', text: s.replace(/`/g, '') }
: s.startsWith('**') ? { kind: 'strong', text: s.replace(/\*/g, '') } : { kind: 'text', text: s })
}
function stream() {
clearInterval(timer)
shown.value = 0
feedback.value = null
streaming.value = true
setTimeout(() => {
timer = setInterval(() => {
shown.value += 1 + Math.floor(Math.random() * 2)
if (shown.value >= total) {
shown.value = total
streaming.value = false
clearInterval(timer)
}
}, 70)
}, 900)
}
onMounted(stream)
onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div class="flex w-full max-w-xl flex-col gap-5">
<ChatMessage
from="user"
author="Maya"
initials="MR"
timestamp="10:42 AM"
content="What's the cleanest way to do v-model on a custom component now?"
/>
<ChatMessage
v-model:feedback="feedback"
author="vuelix assistant"
timestamp="10:42 AM"
:streaming="streaming"
@retry="stream"
>
<template v-if="shown > 0">
<template v-for="(block, i) in visible" :key="i">
<p v-if="block.type === 'p'">
<template v-for="(s, j) in segments(block.text)" :key="j">
<code v-if="s.kind === 'code'">{{ s.text }}</code>
<strong v-else-if="s.kind === 'strong'">{{ s.text }}</strong>
<template v-else>
{{ s.text }}
</template>
</template>
</p>
<ul v-else>
<li v-for="(item, j) in block.items" :key="j">
<template v-for="(s, k) in segments(item)" :key="k">
<code v-if="s.kind === 'code'">{{ s.text }}</code>
<template v-else>
{{ s.text }}
</template>
</template>
</li>
</ul>
</template>
</template>
</ChatMessage>
</div>
</template>Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/chat-message.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/chat-message/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { HTMLAttributes, VNode } from 'vue'
import type { ChatMessageFrom } from './ChatBubble.vue'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { Comment, computed, Fragment, onBeforeUnmount, onMounted, ref, Text, useId, useSlots } from 'vue'
import { cn } from '@/lib/utils'
import ChatBubble from './ChatBubble.vue'
export type ChatMessageAction = 'copy' | 'retry' | 'like' | 'dislike'
export type ChatMessageFeedback = 'like' | 'dislike' | null
const props = withDefaults(defineProps<{
from?: ChatMessageFrom
/** Display name. Defaults to "You" / "Assistant". */
author?: string
/** Avatar image URL. Without it the `avatar` slot, `initials` or a default glyph is shown. */
avatar?: string
initials?: string
/** A string is shown as-is; a `Date` is formatted as a local time after mount. */
timestamp?: string | Date
/** Plain-text content. Also what the copy action copies (falls back to the bubble's text). */
content?: string
/** Text is still arriving: shows a caret (or typing dots when empty) and hides the actions. */
streaming?: boolean
/** Hover actions. Defaults to copy/retry/like/dislike for the assistant and copy for the user. */
actions?: ChatMessageAction[]
/** Hide the avatar column (e.g. for consecutive messages from the same author). */
hideAvatar?: boolean
/** Play the entrance animation on mount. Turn off for history loaded in bulk. */
appear?: boolean
class?: HTMLAttributes['class']
}>(), {
from: 'assistant',
streaming: false,
hideAvatar: false,
appear: true,
})
const feedback = defineModel<ChatMessageFeedback>('feedback', { default: null })
const emit = defineEmits<{
copy: [text: string]
retry: []
}>()
const slots = useSlots()
const reduced = useReducedMotion()
const uid = useId()
const isUser = computed(() => props.from === 'user')
const name = computed(() => props.author ?? (isUser.value ? 'You' : 'Assistant'))
const actionList = computed(() => props.actions ?? (isUser.value ? ['copy'] : ['copy', 'retry', 'like', 'dislike']))
/** True when nothing renders yet — an absent slot, or one that only produces comments / whitespace. */
function hasContent(nodes: VNode[] | undefined): boolean {
return !!nodes?.some((n) => {
if (n.type === Comment)
return false
if (n.type === Text)
return String(n.children).trim() !== ''
if (n.type === Fragment)
return hasContent(n.children as VNode[])
return true
})
}
const isEmpty = () => !props.content && !hasContent(slots.default?.())
// Format Date timestamps only on the client, so SSR and hydration never disagree on time zones.
const mounted = ref(false)
onMounted(() => (mounted.value = true))
const time = computed(() => {
const ts = props.timestamp
if (!ts || typeof ts === 'string')
return ts
return mounted.value ? ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''
})
const iso = computed(() => (props.timestamp instanceof Date ? props.timestamp.toISOString() : undefined))
const bubble = ref<{ $el: HTMLElement }>()
const copied = ref(false)
let copiedTimer: ReturnType<typeof setTimeout> | undefined
async function copy() {
const text = props.content ?? bubble.value?.$el.textContent?.trim() ?? ''
try {
await navigator.clipboard?.writeText(text)
}
catch {}
emit('copy', text)
copied.value = true
clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => (copied.value = false), 1600)
}
onBeforeUnmount(() => clearTimeout(copiedTimer))
function vote(v: 'like' | 'dislike') {
feedback.value = feedback.value === v ? null : v
}
const spring = { type: 'spring', stiffness: 420, damping: 32, mass: 0.7 } as const
const enter = computed(() => {
if (!props.appear)
return false
return reduced.value
? { opacity: 0 }
: { opacity: 0, y: 12, x: isUser.value ? 10 : -10, scale: 0.97, filter: 'blur(4px)' }
})
const iconBtn = 'inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:text-foreground'
</script>
<template>
<motion.article
:aria-labelledby="`${uid}-author`"
:class="cn('group/message flex w-full gap-3', isUser && 'flex-row-reverse', props.class)"
:initial="enter"
:animate="{ opacity: 1, y: 0, x: 0, scale: 1, filter: 'blur(0px)' }"
:transition="spring"
:style="{ transformOrigin: isUser ? '100% 100%' : '0% 100%' }"
>
<div v-if="!props.hideAvatar" class="mt-0.5 shrink-0" aria-hidden="true">
<slot name="avatar">
<img v-if="props.avatar" :src="props.avatar" alt="" class="size-8 rounded-full bg-muted object-cover">
<span
v-else
:class="cn(
'grid size-8 place-items-center rounded-full text-xs font-semibold',
isUser ? 'bg-muted text-foreground' : 'bg-brand/15 text-brand',
)"
>
<template v-if="props.initials">{{ props.initials }}</template>
<svg v-else-if="isUser" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></svg>
<svg v-else class="size-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.5c.4 4.6 2.9 7.1 7.5 7.5-4.6.4-7.1 2.9-7.5 7.5-.4-4.6-2.9-7.1-7.5-7.5 4.6-.4 7.1-2.9 7.5-7.5Z" /><path d="M19 15.5c.2 2 1.3 3.1 3.3 3.3-2 .2-3.1 1.3-3.3 3.3-.2-2-1.3-3.1-3.3-3.3 2-.2 3.1-1.3 3.3-3.3Z" opacity=".6" /></svg>
</span>
</slot>
</div>
<div :class="cn('flex min-w-0 max-w-[85%] flex-col gap-1', isUser ? 'items-end' : 'items-start')">
<div :class="cn('flex items-baseline gap-2 px-1 text-xs', isUser && 'flex-row-reverse')">
<span :id="`${uid}-author`" class="font-medium text-foreground">{{ name }}</span>
<time v-if="time" :datetime="iso" class="text-muted-foreground tabular-nums">{{ time }}</time>
<slot name="meta" />
</div>
<ChatBubble ref="bubble" :from="props.from" :streaming="props.streaming && !isEmpty()">
<span v-if="props.streaming && isEmpty()" class="flex h-6 items-center gap-1" role="status">
<span class="sr-only">{{ name }} is typing</span>
<motion.span
v-for="i in 3"
:key="i"
aria-hidden="true"
class="size-1.5 rounded-full bg-current opacity-60"
:animate="reduced ? { opacity: 0.6 } : { y: [0, -4, 0], opacity: [0.35, 0.9, 0.35] }"
:transition="{ duration: 0.9, repeat: Infinity, ease: 'easeInOut', delay: (i - 1) * 0.15 }"
/>
</span>
<slot v-else>
<p class="whitespace-pre-wrap">
{{ props.content }}
</p>
</slot>
</ChatBubble>
<div
v-if="!props.streaming && actionList.length"
role="toolbar"
:aria-label="`Actions for ${name}'s message`"
:class="cn(
'flex h-7 items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/message:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100',
isUser && 'flex-row-reverse',
feedback && 'opacity-100',
)"
>
<button
v-if="actionList.includes('copy')"
type="button"
:class="iconBtn"
:aria-label="copied ? 'Copied' : 'Copy message'"
@click="copy"
>
<AnimatePresence mode="popLayout" :initial="false">
<motion.svg
v-if="copied"
key="check"
class="size-3.5 text-brand"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:initial="{ scale: 0.4, opacity: 0, rotate: -30 }"
:animate="{ scale: 1, opacity: 1, rotate: 0 }"
:exit="{ scale: 0.4, opacity: 0 }"
:transition="spring"
>
<path d="M5 12.5 10 17 19 7" />
</motion.svg>
<motion.svg
v-else
key="copy"
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:initial="{ scale: 0.4, opacity: 0 }"
:animate="{ scale: 1, opacity: 1 }"
:exit="{ scale: 0.4, opacity: 0 }"
:transition="spring"
>
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1" />
</motion.svg>
</AnimatePresence>
</button>
<button
v-if="actionList.includes('retry')"
type="button"
:class="iconBtn"
aria-label="Regenerate response"
@click="emit('retry')"
>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16" /><path d="M3 21v-5h5" /></svg>
</button>
<template v-for="v in (['like', 'dislike'] as const)" :key="v">
<motion.button
v-if="actionList.includes(v)"
type="button"
:class="iconBtn"
:aria-label="v === 'like' ? 'Good response' : 'Bad response'"
:aria-pressed="feedback === v"
:while-press="{ scale: 0.82 }"
:transition="spring"
@click="vote(v)"
>
<motion.svg
class="size-3.5"
:class="v === 'dislike' && '-scale-y-100'"
viewBox="0 0 24 24"
:fill="feedback === v ? 'currentColor' : 'none'"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:animate="{ scale: feedback === v && !reduced ? [1, 1.35, 1] : 1 }"
:transition="{ duration: 0.35 }"
>
<path d="M7 10v11" /><path d="M15 5.9 14 10h5.8a2 2 0 0 1 1.9 2.6l-2.3 7A2 2 0 0 1 17.5 21H4a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h2.8a2 2 0 0 0 1.8-1.1L12 3a3.1 3.1 0 0 1 3 2.9Z" />
</motion.svg>
</motion.button>
</template>
<slot name="actions" />
</div>
</div>
</motion.article>
</template>vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
export type ChatMessageFrom = 'user' | 'assistant'
const props = withDefaults(defineProps<{
/** Who wrote it — the user's bubble is filled, the assistant's is a quiet card. */
from?: ChatMessageFrom
/** Shows a blinking caret after the last element while text is still arriving. */
streaming?: boolean
class?: HTMLAttributes['class']
}>(), {
from: 'assistant',
streaming: false,
})
// Light "markdown-ish" typography for whatever HTML lands in the slot.
const prose = [
'min-w-0 max-w-full break-words text-sm leading-6',
'[&_p]:m-0 [&_p+p]:mt-3 [&_p+ul]:mt-2 [&_p+ol]:mt-2 [&_ul+p]:mt-2 [&_ol+p]:mt-2',
'[&_ul]:m-0 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:m-0 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5 [&_li]:pl-0.5',
'[&_strong]:font-semibold [&_em]:italic [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-2',
'[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold [&_h3:first-child]:mt-0',
'[&_code]:rounded-md [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]',
'[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:text-xs [&_pre]:leading-5 [&_pre_code]:bg-transparent [&_pre_code]:p-0',
'[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:opacity-80',
]
// Blinking caret at the end of the last block — or of the last list item when the content ends in a list.
const caret = [
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:ml-0.5',
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:inline-block',
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:h-[1.05em]',
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:w-[0.45em]',
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:translate-y-[0.18em]',
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:rounded-[2px]',
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:bg-current',
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:opacity-70',
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:animate-pulse',
'motion-reduce:[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:animate-none',
'[&>:last-child:not(ul,ol),&>:is(ul,ol):last-child>li:last-child]:after:content-[\'\']',
]
</script>
<template>
<div
:data-from="props.from"
:aria-busy="props.streaming || undefined"
:class="cn(
prose,
'rounded-2xl px-4 py-2.5',
props.from === 'user'
? 'rounded-br-md bg-primary text-primary-foreground [&_code]:bg-primary-foreground/15 [&_pre]:bg-primary-foreground/10'
: 'rounded-bl-md border bg-card text-card-foreground shadow-xs [&_code]:bg-muted [&_pre]:bg-muted',
props.streaming && caret,
props.class,
)"
>
<slot />
</div>
</template>ts
export { default as ChatBubble } from './ChatBubble.vue'
export type { ChatMessageFrom } from './ChatBubble.vue'
export { default as ChatMessage } from './ChatMessage.vue'
export type { ChatMessageAction, ChatMessageFeedback } from './ChatMessage.vue'Usage
ts
import { ChatBubble, ChatMessage } from '@/components/ui/chat-message'vue
<ChatMessage from="user" author="Maya" timestamp="10:42 AM" content="How do I use defineModel?" />
<ChatMessage
v-model:feedback="feedback"
:streaming="streaming"
timestamp="10:42 AM"
@retry="regenerate"
>
<div v-html="renderedMarkdown" />
</ChatMessage>The default slot accepts rendered Markdown (paragraphs, lists, code, pre, links, quotes are styled). While streaming is on, a caret blinks after the last element; with no content yet, typing dots show instead. ChatBubble is the bare bubble if you want to build your own layout.
Actions stay hidden until the message is hovered or focused (always visible on touch screens).
API
ChatMessage
| Prop | Type | Default | Description |
|---|---|---|---|
from | 'user' | 'assistant' | 'assistant' | Alignment and bubble style. |
author | string | 'You' / 'Assistant' | Display name. |
avatar | string | — | Avatar image URL. |
initials | string | — | Shown when there's no avatar image. |
timestamp | string | Date | — | Strings show as-is; dates are formatted as a local time after mount. |
content | string | — | Plain-text content (rendered when the slot is empty) and what Copy copies. |
streaming | boolean | false | Caret / typing dots, hides the actions. |
actions | ('copy' | 'retry' | 'like' | 'dislike')[] | all for assistant, ['copy'] for user | Which hover actions to show. Pass [] for none. |
hideAvatar | boolean | false | Hide the avatar column for grouped messages. |
appear | boolean | true | Play the entrance animation on mount. |
v-model:feedback | 'like' | 'dislike' | null | null | Thumbs state. |
| Event | Payload | Description |
|---|---|---|
copy | text: string | After copying to the clipboard. |
retry | — | The regenerate button was pressed. |
| Slot | Description |
|---|---|
| default | Message content. |
avatar | Custom avatar. |
meta | Extra content after the timestamp (e.g. a model badge). |
actions | Extra action buttons. |
ChatBubble
| Prop | Type | Default | Description |
|---|---|---|---|
from | 'user' | 'assistant' | 'assistant' | Bubble style. |
streaming | boolean | false | Blinking caret after the last element. |