UI Components Medium
Comment Thread
Nested comment thread with replies, likes, collapse/expand, relative timestamps, and inline reply form. No dependencies.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
--accent: #818cf8;
--green: #34d399;
--red: #f87171;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.page-wrap {
max-width: 700px;
margin: 0 auto;
padding: 40px 24px;
}
.content-area {
margin-bottom: 40px;
padding-bottom: 32px;
border-bottom: 1px solid var(--border);
}
.article-title {
font-size: 1.6rem;
font-weight: 700;
margin-bottom: 12px;
}
.article-body {
color: var(--text-muted);
line-height: 1.7;
font-size: 0.9rem;
}
/* Comments section */
.comments-title {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 20px;
}
.comments-count {
font-size: 0.78rem;
font-weight: 600;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 999px;
padding: 1px 8px;
margin-left: 6px;
}
/* Top form */
.top-form {
display: flex;
gap: 12px;
margin-bottom: 28px;
}
.comment-av {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.68rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.comment-form-inner {
flex: 1;
}
.comment-textarea {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 0.875rem;
padding: 10px 12px;
font-family: inherit;
resize: vertical;
outline: none;
transition: border-color .15s;
}
.comment-textarea:focus {
border-color: var(--accent);
}
.comment-textarea::placeholder {
color: var(--text-muted);
}
.comment-form-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.char-count {
font-size: 0.72rem;
color: var(--text-muted);
}
.char-count.warn {
color: var(--red);
}
.comment-submit-btn {
background: var(--accent);
border: none;
border-radius: 7px;
color: #fff;
font-size: 0.82rem;
font-weight: 600;
padding: 7px 14px;
cursor: pointer;
font-family: inherit;
transition: background .15s;
}
.comment-submit-btn:hover {
background: #a5b4fc;
}
/* Thread */
.comment-list {
display: flex;
flex-direction: column;
gap: 0;
}
.comment {
padding: 16px 0;
border-top: 1px solid var(--border);
}
.comment-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.comment-name {
font-size: 0.875rem;
font-weight: 700;
}
.comment-time {
font-size: 0.72rem;
color: var(--text-muted);
}
.comment-body {
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.6;
margin-bottom: 10px;
}
.comment-actions {
display: flex;
align-items: center;
gap: 14px;
}
.action-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.78rem;
cursor: pointer;
font-family: inherit;
display: flex;
align-items: center;
gap: 5px;
padding: 0;
transition: color .15s;
}
.action-btn:hover {
color: var(--text);
}
.action-btn.liked {
color: var(--red);
}
.like-count {
font-size: 0.75rem;
}
/* Replies */
.replies-wrap {
margin-top: 12px;
padding-left: 20px;
border-left: 2px solid var(--border);
display: flex;
flex-direction: column;
gap: 0;
}
.reply {
padding: 12px 0;
border-top: 1px solid var(--border);
}
.reply:first-child {
border-top: none;
}
/* Inline reply form */
.inline-reply-form {
margin-top: 10px;
display: flex;
gap: 8px;
}
.inline-reply-form .comment-textarea {
font-size: 0.82rem;
}let comments = [
{
id: 1,
name: "Sarah Kim",
color: "#0ea5e9",
text: "This is exactly what I needed! Copying the sidebar component right now 🚀",
time: new Date(Date.now() - 3600000 * 2),
likes: 14,
liked: false,
replies: [
{
id: 11,
name: "Marcus Reed",
color: "#8b5cf6",
text: "Same here — saved me hours of work.",
time: new Date(Date.now() - 3600000),
likes: 5,
liked: false,
replies: [],
},
],
},
{
id: 2,
name: "Julia Lee",
color: "#f59e0b",
text: "The breadcrumb with SEO structured data injection is a really nice touch. Love the attention to detail.",
time: new Date(Date.now() - 86400000),
likes: 8,
liked: false,
replies: [],
},
{
id: 3,
name: "David Okonkwo",
color: "#34d399",
text: "Is there a React version of the sidebar component planned?",
time: new Date(Date.now() - 86400000 * 2),
likes: 3,
liked: false,
replies: [],
},
];
let nextId = 100;
function timeAgo(date) {
const sec = Math.floor((Date.now() - date) / 1000);
if (sec < 60) return "just now";
if (sec < 3600) return Math.floor(sec / 60) + " min ago";
if (sec < 86400)
return Math.floor(sec / 3600) + " hour" + (Math.floor(sec / 3600) > 1 ? "s" : "") + " ago";
return Math.floor(sec / 86400) + " day" + (Math.floor(sec / 86400) > 1 ? "s" : "") + " ago";
}
function buildComment(c, isReply = false) {
const el = document.createElement("div");
el.className = isReply ? "comment reply" : "comment";
el.dataset.id = c.id;
const repliesHTML = c.replies?.length
? `<div class="replies-wrap">${c.replies.map((r) => buildComment(r, true).outerHTML).join("")}</div>`
: "";
el.innerHTML = `
<div class="comment-header">
<div class="comment-av" style="background:${c.color};width:32px;height:32px;border-radius:50%;display:grid;place-items:center;font-size:0.65rem;font-weight:700;color:#fff;flex-shrink:0">${c.name
.split(" ")
.map((n) => n[0])
.join("")}</div>
<span class="comment-name">${c.name}</span>
<span class="comment-time">${timeAgo(c.time)}</span>
</div>
<p class="comment-body">${c.text}</p>
<div class="comment-actions">
<button class="action-btn like-btn ${c.liked ? "liked" : ""}" data-id="${c.id}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="${c.liked ? "currentColor" : "none"}" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
<span class="like-count">${c.likes}</span>
</button>
${!isReply ? `<button class="action-btn reply-btn" data-id="${c.id}">Reply</button>` : ""}
</div>
${repliesHTML}
${
!isReply
? `<div class="inline-reply-form" id="replyForm-${c.id}" hidden>
<div class="comment-av" style="background:#818cf8;width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font-size:0.6rem;font-weight:700;color:#fff">You</div>
<div class="comment-form-inner" style="flex:1">
<textarea class="comment-textarea" rows="2" placeholder="Write a reply…"></textarea>
<div class="comment-form-footer">
<button class="comment-submit-btn cancel-reply-btn" data-id="${c.id}" style="background:transparent;border:1px solid #2a2d3a;color:#64748b;margin-right:8px">Cancel</button>
<button class="comment-submit-btn submit-reply-btn" data-id="${c.id}">Reply</button>
</div>
</div>
</div>`
: ""
}
`;
return el;
}
function renderComments() {
const list = document.getElementById("commentList");
list.innerHTML = "";
comments.forEach((c) => list.appendChild(buildComment(c)));
document.getElementById("commentsCount").textContent = comments.reduce(
(a, c) => a + 1 + (c.replies?.length || 0),
0
);
// Events
list.querySelectorAll(".like-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const id = +btn.dataset.id;
const c = findComment(id);
if (!c) return;
c.liked = !c.liked;
c.likes += c.liked ? 1 : -1;
renderComments();
});
});
list.querySelectorAll(".reply-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const form = document.getElementById("replyForm-" + btn.dataset.id);
if (form) form.hidden = !form.hidden;
});
});
list.querySelectorAll(".cancel-reply-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const form = document.getElementById("replyForm-" + btn.dataset.id);
if (form) form.hidden = true;
});
});
list.querySelectorAll(".submit-reply-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const form = document.getElementById("replyForm-" + btn.dataset.id);
const ta = form?.querySelector("textarea");
if (!ta?.value.trim()) return;
const c = findComment(+btn.dataset.id);
if (c) {
c.replies.push({
id: nextId++,
name: "You",
color: "#818cf8",
text: ta.value.trim(),
time: new Date(),
likes: 0,
liked: false,
replies: [],
});
renderComments();
}
});
});
}
function findComment(id) {
for (const c of comments) {
if (c.id === id) return c;
if (c.replies) for (const r of c.replies) if (r.id === id) return r;
}
return null;
}
// Top comment form
const topForm = document.getElementById("topCommentForm");
const topTA = document.getElementById("topTextarea");
const topCC = document.getElementById("topCharCount");
topTA?.addEventListener("input", () => {
const n = topTA.value.length;
topCC.textContent = n + " / 500";
topCC.classList.toggle("warn", n > 450);
});
topForm?.addEventListener("submit", (e) => {
e.preventDefault();
const txt = topTA.value.trim();
if (!txt) return;
comments.unshift({
id: nextId++,
name: "You",
color: "#818cf8",
text: txt,
time: new Date(),
likes: 0,
liked: false,
replies: [],
});
topTA.value = "";
topCC.textContent = "0 / 500";
renderComments();
});
renderComments();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Comment Thread</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page-wrap">
<article class="content-area">
<h1 class="article-title">Building a Component Library</h1>
<p class="article-body">
A great component library starts with a clear design system, consistent naming, and well-documented
APIs.
Steal, adapt, and make it your own.
</p>
</article>
<!-- Comment section -->
<section class="comments-section">
<h2 class="comments-title">Comments <span class="comments-count" id="commentsCount">3</span></h2>
<!-- New comment form -->
<form class="comment-form top-form" id="topCommentForm">
<div class="comment-av" style="background:#818cf8">You</div>
<div class="comment-form-inner">
<textarea class="comment-textarea" id="topTextarea" rows="3" placeholder="Share your thoughts…"
aria-label="New comment"></textarea>
<div class="comment-form-footer">
<span class="char-count" id="topCharCount">0 / 500</span>
<button type="submit" class="comment-submit-btn">Comment</button>
</div>
</div>
</form>
<!-- Thread -->
<div class="comment-list" id="commentList"></div>
</section>
</main>
<script src="script.js"></script>
</body>
</html>Comment Thread
A fully interactive nested comment thread supporting inline reply forms, like/heart toggling, collapse/expand branches, relative timestamps, and new comment posting.
Features
- Top-level comment form with avatar and textarea
- Up to 2 levels of nested replies (comment → reply)
- Inline replied-to indicator showing parent author
- Like button with animated heart toggle and count increment
- Collapse thread toggle to hide/show a comment’s replies
- Relative timestamps (“2 minutes ago”, “Yesterday”, etc.)
- New comment and new reply appended to the DOM instantly
- Character count on textarea with soft limit warning
How it works
- Comments are rendered from a JS data array on load;
renderComment()creates DOM nodes recursively - “Reply” button inserts an inline
<form>below the comment; submit appends to that comment’s replies - Likes use a
data-likedattribute toggle + CSS class for the filled heart state timeAgo(date)utility converts Date objects to human-readable relative strings