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 init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add chat
pnpm dlx ermineui add chat
yarn dlx ermineui add chat
bunx ermineui add chat

That 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

FileWhere it goes
ChatComposer.vuesrc/components/ui/chat/ChatComposer.vue
ChatMessage.vuesrc/components/ui/chat/ChatMessage.vue
ChatThread.vuesrc/components/ui/chat/ChatThread.vue
index.tssrc/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'