Comment thread
Comments with replies nested under them, a box to add one, and a reply box that opens under any comment.
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 comment-threadpnpm dlx ermineui add comment-threadyarn dlx ermineui add comment-threadbunx ermineui add comment-threadThat copies 5 files into src/components/ui/comment-thread/, 1 helper into src/lib/ermine/, and the 3 components it is built from. Files you already have are kept.
Use
import { CommentThread, countComments } from '@/components/ui/comment-thread'<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 |
|---|---|
CommentItem.vue | src/components/ui/comment-thread/CommentItem.vue |
CommentThread.vue | src/components/ui/comment-thread/CommentThread.vue |
context.ts | src/components/ui/comment-thread/context.ts |
index.ts | src/components/ui/comment-thread/index.ts |
types.ts | src/components/ui/comment-thread/types.ts |
context.ts helper | src/lib/ermine/context.ts |
It is built from: Avatar, Button, Textarea. These are added with it.
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.
CommentItem.vue 84 lines
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { Avatar } from '../avatar'
import { Button } from '../button'
import { Textarea } from '../textarea'
import { useCommentThread } from './context'
import { initials, type CommentNode } from './types'
const props = defineProps<{
comment: CommentNode
/** How deep in the thread this comment is, starting at 1. */
depth: number
}>()
const thread = useCommentThread()
const draft = ref('')
const form = ref<HTMLFormElement | null>(null)
const replyButton = ref<HTMLButtonElement | null>(null)
const replying = computed(() => thread.replyingTo.value === props.comment.id)
// Read as UTC when only a day is given, so the day shown does not depend on where the server or the reader is.
const written = computed(() => {
const date = props.comment.date
if (!date) return undefined
const day = new Date(date.length === 10 ? `${date}T00:00:00Z` : date)
return Number.isNaN(day.getTime()) ? date : new Intl.DateTimeFormat(thread.locale.value, { dateStyle: 'medium', timeZone: 'UTC' }).format(day)
})
async function open() {
thread.replyingTo.value = replying.value ? undefined : props.comment.id
draft.value = ''
await nextTick()
form.value?.querySelector('textarea')?.focus()
}
async function close() {
thread.replyingTo.value = undefined
// The box that had focus is gone; it goes back to the button that opened it.
await nextTick()
replyButton.value?.focus()
}
function send() {
const text = draft.value.trim()
if (!text) return
thread.reply(text, props.comment)
void close()
}
</script>
<template>
<li class="er-comment">
<div class="er-comment-row">
<Avatar :src="comment.avatar" size="sm">{{ initials(comment.author) }}</Avatar>
<div class="er-comment-main">
<div class="er-comment-head">
<span class="er-comment-author">{{ comment.author }}</span>
<span v-if="comment.badge" class="er-comment-badge">{{ comment.badge }}</span>
<time v-if="written" :datetime="comment.date">{{ written }}</time>
</div>
<p class="er-comment-body">{{ comment.body }}</p>
<div v-if="thread.canReply.value || comment.canDelete" class="er-comment-actions">
<button v-if="thread.canReply.value" ref="replyButton" type="button" :aria-expanded="replying" @click="open">
Reply<span class="er-comment-hidden"> to {{ comment.author }}</span>
</button>
<button v-if="comment.canDelete" type="button" @click="thread.remove(comment)">
Delete<span class="er-comment-hidden"> your comment</span>
</button>
</div>
<form v-if="replying" ref="form" class="er-comment-reply" @submit.prevent="send" @keydown.esc="close">
<Textarea v-model="draft" rows="2" :aria-label="`Reply to ${comment.author}`" :placeholder="`Reply to ${comment.author}`" />
<div class="er-comment-reply-buttons">
<Button type="button" variant="ghost" size="sm" @click="close">Cancel</Button>
<Button type="submit" size="sm" :disabled="!draft.trim()">Reply</Button>
</div>
</form>
</div>
</div>
<ul v-if="comment.replies?.length" class="er-comment-replies" :data-flat="depth >= thread.maxDepth.value ? '' : undefined">
<CommentItem v-for="reply in comment.replies" :key="reply.id" :comment="reply" :depth="depth + 1" />
</ul>
</li>
</template>CommentThread.vue 228 lines
<script setup lang="ts">
import { computed, ref, toRef } from 'vue'
import { Button } from '../button'
import { Textarea } from '../textarea'
import CommentItem from './CommentItem.vue'
import { provideCommentThread } from './context'
import { countComments, type CommentNode } from './types'
const props = withDefaults(
defineProps<{
comments: readonly CommentNode[]
/** Heading over the thread. The number of comments is added after it. */
title?: string
/** Heading level of the title. */
titleAs?: string
/** Let the reader write comments and replies. Turn off for someone who is not signed in. */
canComment?: boolean
/** How many levels of reply are indented. Replies deeper than this line up with the last level. */
maxDepth?: number
locale?: string
placeholder?: string
submitLabel?: string
/** Set while a comment is being saved: the button shows a spinner and cannot be pressed twice. */
loading?: boolean
}>(),
{ title: 'Comments', titleAs: 'h3', canComment: true, maxDepth: 3, locale: 'en-US', placeholder: 'Add a comment', submitLabel: 'Comment' },
)
const emit = defineEmits<{
/** A new comment was written. Save it, then add it to `comments`. */
submit: [text: string]
/** A reply was written under a comment. Save it, then add it to that comment's `replies`. */
reply: [text: string, parent: CommentNode]
/** "Delete" was pressed on a comment. */
delete: [comment: CommentNode]
}>()
const draft = ref('')
const total = computed(() => countComments(props.comments))
provideCommentThread({
replyingTo: ref<string>(),
maxDepth: toRef(props, 'maxDepth'),
locale: toRef(props, 'locale'),
canReply: toRef(props, 'canComment'),
reply: (text, parent) => emit('reply', text, parent),
remove: (comment) => emit('delete', comment),
})
function submit() {
const text = draft.value.trim()
if (!text) return
emit('submit', text)
draft.value = ''
}
</script>
<template>
<section class="er-comment-thread">
<component :is="titleAs" class="er-comment-thread-title">{{ title }} <span>{{ total }}</span></component>
<form v-if="canComment" class="er-comment-thread-form" @submit.prevent="submit">
<Textarea v-model="draft" rows="2" :placeholder="placeholder" :aria-label="placeholder" />
<Button type="submit" size="sm" :loading="loading" :disabled="!draft.trim()">{{ submitLabel }}</Button>
</form>
<!-- Shown when there are no comments yet. -->
<p v-if="!comments.length" class="er-comment-thread-empty"><slot name="empty">No comments yet.</slot></p>
<ul v-else class="er-comment-thread-list">
<CommentItem v-for="comment in comments" :key="comment.id" :comment="comment" :depth="1" />
</ul>
</section>
</template>
<style>
@layer components {
.er-comment-thread {
display: grid;
gap: 1rem;
width: 100%;
min-width: 0;
}
.er-comment-thread-title {
margin: 0;
font-size: 1rem;
font-weight: var(--er-weight-semibold);
line-height: 1.5rem;
}
.er-comment-thread-title span {
color: var(--er-muted-foreground);
font-weight: 400;
}
.er-comment-thread-form {
display: grid;
justify-items: end;
gap: 0.5rem;
}
.er-comment-thread-empty {
margin: 0;
color: var(--er-muted-foreground);
font-size: 0.875rem;
}
.er-comment-thread-list,
.er-comment-replies {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
/* Replies hang off a line down from the comment they answer. */
.er-comment-replies {
margin-top: 1rem;
padding-left: 1rem;
border-left: 2px solid var(--er-border);
}
.er-comment-replies[data-flat] {
padding-left: 0;
border-left: 0;
}
.er-comment {
min-width: 0;
}
.er-comment-row {
display: flex;
gap: 0.625rem;
}
.er-comment-main {
display: grid;
flex: 1;
gap: 0.25rem;
min-width: 0;
}
.er-comment-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.125rem 0.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.er-comment-author {
font-weight: var(--er-weight-semibold);
}
.er-comment-badge {
padding: 0 0.375rem;
border: 1px solid var(--er-border);
border-radius: 999px;
font-size: 0.6875rem;
font-weight: var(--er-weight-medium);
line-height: 1.125rem;
}
.er-comment-head time {
color: var(--er-muted-foreground);
font-size: 0.8125rem;
}
.er-comment-body {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.er-comment-actions {
display: flex;
gap: 0.75rem;
}
.er-comment-actions button {
padding: 0;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
color: var(--er-muted-foreground);
font: inherit;
font-size: 0.8125rem;
font-weight: var(--er-weight-medium);
cursor: pointer;
}
.er-comment-actions button:hover {
color: var(--er-foreground);
}
.er-comment-actions button:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-comment-reply {
display: grid;
gap: 0.5rem;
margin-top: 0.5rem;
}
.er-comment-reply-buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.er-comment-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
</style>context.ts 16 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'
import type { CommentNode } from './types'
export interface CommentThreadContext {
/** The id of the comment whose reply box is open. Only one is open at a time. */
replyingTo: Ref<string | undefined>
maxDepth: Ref<number>
locale: Ref<string>
canReply: Ref<boolean>
reply: (text: string, parent: CommentNode) => void
remove: (comment: CommentNode) => void
}
export const [provideCommentThread, useCommentThread] = createContext<CommentThreadContext>('CommentThread')index.ts 4 lines
/** Comment thread — comments with replies nested under them, a box to add one, and a reply box that opens under any comment. */
export { default as CommentThread } from './CommentThread.vue'
export { countComments, type CommentNode } from './types'types.ts 27 lines
export interface CommentNode {
id: string
author: string
/** The author's picture. Their initials are shown when there is none. */
avatar?: string
/** The comment itself, as plain text. Line breaks are kept. */
body: string
/** When it was written, as an ISO date: 2026-09-14 or 2026-09-14T10:30:00Z. */
date?: string
/** Something to show beside the name, such as "Author" or "Staff". */
badge?: string
/** The reader may delete this comment. */
canDelete?: boolean
replies?: CommentNode[]
}
/** A name's initials, for the avatar of someone without a picture. */
export function initials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean)
return ((words[0]?.[0] ?? '') + (words.length > 1 ? words[words.length - 1]![0] : '')).toUpperCase()
}
/** How many comments there are, counting every reply. */
export function countComments(comments: readonly CommentNode[]): number {
return comments.reduce((sum, comment) => sum + 1 + countComments(comment.replies ?? []), 0)
}