Chat
A conversation as bubbles that keeps itself scrolled to the newest message, with a box to write in that sends on Enter.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add chatpnpm dlx ermineui add chatyarn dlx ermineui add chatbunx ermineui add chatThat copies 4 files into src/components/ui/chat/. Files you already have are kept.
Use
import { ChatThread, ChatMessage, ChatComposer } from '@/components/ui/chat'<ChatThread label="Chat with support" :typing="typing">
<ChatMessage v-for="message in messages" :key="message.id" :mine="message.mine" author="Support" :time="message.time">{{ message.text }}</ChatMessage>
</ChatThread>
<ChatComposer @send="send" />
<CommentThread :comments="comments" @submit="add" @reply="(text, parent) => reply(text, parent)" @delete="remove" />
<NotificationCenter v-model:items="notifications" @select="open" />In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
ChatComposer.vue | src/components/ui/chat/ChatComposer.vue |
ChatMessage.vue | src/components/ui/chat/ChatMessage.vue |
ChatThread.vue | src/components/ui/chat/ChatThread.vue |
index.ts | src/components/ui/chat/index.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
ChatComposer.vue 158 lines
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
/** Accessible name of the box, e.g. "Message to support". */
label?: string
placeholder?: string
sendLabel?: string
disabled?: boolean
/** How many lines the box grows to before it scrolls. */
maxRows?: number
/** Enter sends and Shift+Enter starts a new line. Turn off for long messages, where Enter should be a new line and only the button sends. */
enterSends?: boolean
}>(),
{ label: 'Message', placeholder: 'Write a message', sendLabel: 'Send', maxRows: 6, enterSends: true },
)
const emit = defineEmits<{
/** A message was sent. The box is emptied; add the message to your list. */
send: [text: string]
}>()
/** What is in the box. */
const text = defineModel<string>({ default: '' })
const box = ref<HTMLTextAreaElement | null>(null)
const canSend = computed(() => !props.disabled && text.value.trim().length > 0)
function grow() {
const el = box.value
if (!el) return
el.style.height = 'auto'
const style = getComputedStyle(el)
const most = (Number.parseFloat(style.lineHeight) || 22) * props.maxRows + Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom)
el.style.height = `${Math.min(el.scrollHeight, most)}px`
}
watch(text, () => void nextTick(grow), { flush: 'post' })
function send() {
if (!canSend.value) return
emit('send', text.value.trim())
text.value = ''
// Focus stays in the box, so the next message can be typed straight away.
box.value?.focus()
}
function onKeydown(event: KeyboardEvent) {
// While a language's input method is putting characters together, Enter confirms a character; it must not send.
if (event.key !== 'Enter' || event.shiftKey || event.isComposing || !props.enterSends) return
event.preventDefault()
send()
}
</script>
<template>
<form class="er-chat-composer" :data-disabled="disabled ? '' : undefined" @submit.prevent="send">
<!-- Buttons before the box: attach a file, open an emoji picker. -->
<div v-if="$slots.start" class="er-chat-composer-actions"><slot name="start" /></div>
<textarea ref="box" v-model="text" rows="1" :aria-label="label" :placeholder="placeholder" :disabled="disabled" enterkeyhint="send" @keydown="onKeydown" />
<button type="submit" class="er-chat-composer-send" :aria-label="sendLabel" :disabled="!canSend">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6" /></svg>
</button>
</form>
</template>
<style>
@layer components {
.er-chat-composer {
display: flex;
align-items: flex-end;
gap: 0.375rem;
width: 100%;
padding: 0.375rem;
border: 1px solid var(--er-input);
border-radius: 1.375rem;
background: var(--er-input-background);
transition:
border-color var(--er-duration),
outline-color var(--er-duration);
}
.er-chat-composer:has(textarea:focus-visible) {
border-color: var(--er-ring);
outline: 2px solid color-mix(in oklab, var(--er-ring) 45%, transparent);
outline-offset: 0;
}
.er-chat-composer[data-disabled] {
opacity: 0.5;
}
.er-chat-composer-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.125rem;
min-height: 2rem;
}
.er-chat-composer textarea {
flex: 1;
min-width: 0;
padding: 0.3125rem 0.5rem;
border: 0;
background: transparent;
color: var(--er-foreground);
font: inherit;
/* 16px stops iOS from zooming the page when the field is focused. */
font-size: 1rem;
line-height: 1.375rem;
outline: none;
resize: none;
}
@media (min-width: 48rem) {
.er-chat-composer textarea {
font-size: 0.9375rem;
}
}
.er-chat-composer textarea::placeholder {
color: var(--er-muted-foreground);
}
.er-chat-composer-send {
display: grid;
flex-shrink: 0;
place-items: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--er-primary);
color: var(--er-primary-foreground);
cursor: pointer;
transition: opacity var(--er-duration);
}
.er-chat-composer-send:disabled {
opacity: 0.35;
cursor: default;
}
.er-chat-composer-send:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-chat-composer-send svg {
width: 1rem;
height: 1rem;
}
}
</style>ChatMessage.vue 145 lines
<script setup lang="ts">
withDefaults(
defineProps<{
/** Who wrote it. Shown above the bubble, and read out before the message. */
author?: string
/** The reader's own message: on the far side, in the primary colour. */
mine?: boolean
/** When it was sent, already written out the way it should read: "14:20", "Yesterday". */
time?: string
/** For machines: the same moment as an ISO date. */
datetime?: string
/** How far the reader's own message has got. */
status?: 'sending' | 'sent' | 'read' | 'failed'
/** Keep the author's name for screen readers only, as when several messages in a row are from the same person. */
hideAuthor?: boolean
}>(),
{},
)
defineEmits<{
/** "Try again" was pressed on a message that failed to send. */
retry: []
}>()
const statusWords = { sending: 'Sending…', sent: 'Sent', read: 'Read', failed: 'Not sent' }
</script>
<template>
<li class="er-chat-message" :data-mine="mine ? '' : undefined" :data-status="status">
<!-- The author's picture. -->
<div v-if="$slots.avatar" class="er-chat-message-avatar"><slot name="avatar" /></div>
<div class="er-chat-message-body">
<span v-if="author || mine" class="er-chat-message-author" :class="{ 'er-chat-hidden': hideAuthor || (mine && !author) }">{{ author ?? 'You' }}</span>
<div class="er-chat-message-bubble"><slot /></div>
<div v-if="time || status" class="er-chat-message-meta">
<time v-if="time" :datetime="datetime">{{ time }}</time>
<span v-if="status">{{ statusWords[status] }}</span>
<button v-if="status === 'failed'" type="button" @click="$emit('retry')">Try again</button>
</div>
</div>
</li>
</template>
<style>
@layer components {
.er-chat-message {
display: flex;
align-items: flex-end;
gap: 0.5rem;
max-width: min(85%, 34rem);
}
.er-chat-message[data-mine] {
flex-direction: row-reverse;
justify-self: end;
}
.er-chat-message-avatar {
flex-shrink: 0;
}
.er-chat-message-body {
display: grid;
gap: 0.1875rem;
min-width: 0;
justify-items: start;
}
.er-chat-message[data-mine] .er-chat-message-body {
justify-items: end;
}
.er-chat-message-author,
.er-chat-message-meta {
padding-inline: 0.5rem;
color: var(--er-muted-foreground);
font-size: 0.75rem;
line-height: 1rem;
}
.er-chat-message-author {
font-weight: var(--er-weight-medium);
}
.er-chat-message-bubble {
padding: 0.5rem 0.875rem;
border-radius: 1.125rem;
border-end-start-radius: 0.25rem;
background: var(--er-muted);
color: var(--er-foreground);
font-size: 0.9375rem;
line-height: 1.375rem;
overflow-wrap: anywhere;
/* Line breaks in a message are kept; the spaces a template leaves around it are not. */
white-space: pre-line;
}
.er-chat-message[data-mine] .er-chat-message-bubble {
border-end-start-radius: 1.125rem;
border-end-end-radius: 0.25rem;
background: var(--er-primary);
color: var(--er-primary-foreground);
}
.er-chat-message[data-status='sending'] .er-chat-message-bubble {
opacity: 0.6;
}
/* Not sent: outlined in the warning colour, and the words underneath say so. */
.er-chat-message[data-status='failed'] .er-chat-message-bubble {
background: transparent;
color: var(--er-foreground);
box-shadow: inset 0 0 0 1px var(--er-destructive);
}
.er-chat-message-meta {
display: flex;
flex-wrap: wrap;
gap: 0 0.375rem;
}
.er-chat-message[data-status='failed'] .er-chat-message-meta {
color: var(--er-destructive);
}
.er-chat-message-meta button {
padding: 0;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
color: var(--er-foreground);
font: inherit;
font-weight: var(--er-weight-medium);
text-decoration: underline;
text-underline-offset: 0.2em;
cursor: pointer;
}
.er-chat-message-meta button:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
}
</style>ChatThread.vue 193 lines
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
withDefaults(
defineProps<{
/** Accessible name of the conversation, e.g. "Chat with support". */
label?: string
/** Someone is writing: shows three dots at the end. Say who in `typingLabel`. */
typing?: boolean
typingLabel?: string
/** The words on the button that appears when new messages arrive while reading further up. */
newLabel?: string
}>(),
{ label: 'Conversation', typingLabel: 'Typing…', newLabel: 'New messages' },
)
const scroller = ref<HTMLElement | null>(null)
const list = ref<HTMLElement | null>(null)
/** Whether the reader is at the newest message. If they have scrolled up to read, new messages must not yank them back down. */
const atEnd = ref(true)
const unseen = ref(false)
let observer: MutationObserver | undefined
function onScroll() {
const el = scroller.value
if (!el) return
atEnd.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
if (atEnd.value) unseen.value = false
}
/** Jumps to the newest message. */
function scrollToEnd() {
const el = scroller.value
if (el) el.scrollTop = el.scrollHeight
unseen.value = false
}
onMounted(() => {
scrollToEnd()
if (!list.value) return
// Messages are whatever is put in the slot, so new ones are noticed by watching the list itself.
observer = new MutationObserver(() => {
if (atEnd.value) void nextTick(scrollToEnd)
else unseen.value = true
})
observer.observe(list.value, { childList: true })
})
onBeforeUnmount(() => observer?.disconnect())
defineExpose({ scrollToEnd })
</script>
<template>
<div class="er-chat-thread">
<!-- A log: new messages are read out as they arrive, without taking focus. It can be focused so the keyboard can scroll it. -->
<div ref="scroller" class="er-chat-thread-scroll" role="log" :aria-label="label" tabindex="0" @scroll.passive="onScroll">
<!-- ChatMessage, one per message, oldest first. -->
<ol ref="list" class="er-chat-thread-list"><slot /></ol>
<p v-if="typing" class="er-chat-thread-typing">
<span aria-hidden="true"><i /><i /><i /></span>
<span class="er-chat-hidden">{{ typingLabel }}</span>
</p>
</div>
<button v-if="unseen" type="button" class="er-chat-thread-new" @click="scrollToEnd">
{{ newLabel }}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M6 13l6 6 6-6" /></svg>
</button>
</div>
</template>
<style>
@layer components {
.er-chat-thread {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
}
.er-chat-thread-scroll {
flex: 1;
min-height: 0;
padding: 1rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.er-chat-thread-scroll:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: -2px;
}
.er-chat-thread-list {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.er-chat-thread-typing {
display: inline-flex;
margin: 0.75rem 0 0;
padding: 0.75rem 0.875rem;
border-radius: 1.125rem;
background: var(--er-muted);
}
.er-chat-thread-typing > span:first-child {
display: flex;
gap: 0.25rem;
}
.er-chat-thread-typing i {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: var(--er-muted-foreground);
animation: er-chat-typing 1.2s infinite;
}
.er-chat-thread-typing i:nth-child(2) {
animation-delay: 0.15s;
}
.er-chat-thread-typing i:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes er-chat-typing {
0%,
60%,
100% {
opacity: 0.35;
transform: none;
}
30% {
opacity: 1;
transform: translateY(-0.1875rem);
}
}
@media (prefers-reduced-motion: reduce) {
.er-chat-thread-typing i {
animation: none;
}
}
.er-chat-thread-new {
position: absolute;
bottom: 0.75rem;
left: 50%;
display: inline-flex;
align-items: center;
gap: 0.375rem;
height: 2rem;
padding: 0 0.75rem;
border: 1px solid var(--er-border);
border-radius: 999px;
background: var(--er-popover);
color: var(--er-popover-foreground);
box-shadow: var(--er-shadow-md);
font: inherit;
font-size: 0.8125rem;
font-weight: var(--er-weight-medium);
transform: translateX(-50%);
cursor: pointer;
}
.er-chat-thread-new:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-chat-thread-new svg {
width: 0.875rem;
height: 0.875rem;
}
.er-chat-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
</style>index.ts 5 lines
/** Chat — a conversation as bubbles that keeps itself scrolled to the newest message, with a box to write in that sends on Enter. */
export { default as ChatThread } from './ChatThread.vue'
export { default as ChatMessage } from './ChatMessage.vue'
export { default as ChatComposer } from './ChatComposer.vue'