Comment Thread UI — Free HTML CSS JS Snippet
Comment Thread · Cards · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Comment Thread — Nested Replies, Voting, Sorting & Auto-Grow Composer

A threaded comment system is one of the most complex common UI patterns — it combines recursion, voting state, dynamic insertion, and sorting. It sits below a social post card in most feeds. This snippet delivers a complete, working comment thread with nested replies, a Reddit-style up/down voting widget (compare the emoji reaction bar), top/newest sorting, an author badge, relative timestamps, and an auto-growing comment composer.
The nesting structure
Each comment is a .comment element containing an avatar and a .comment-body. The body holds the comment content and a .replies container, which itself holds more .comment elements — making the structure naturally recursive. Replies are visually indented with a left padding and a 2px left border that acts as a thread line, the convention popularised by Reddit and Hacker News. Because the markup is self-similar at every depth, the same JavaScript functions work at any nesting level.
The voting widget
The vote() function implements toggle voting with proper state tracking. It stores the base vote count and a per-comment state of 0, 1, or −1. Clicking upvote toggles between +1 and 0; clicking downvote toggles between −1 and 0; switching from up to down moves directly between states. The displayed count is always base + state, and the active arrow gets a coloured highlight. This matches the exact behaviour users expect from social platforms and prevents the common bug of double-counting repeated clicks.
Dynamic reply insertion
toggleReply() injects an inline reply box directly beneath a comment's actions, using the :scope selector to target only the immediate children — critical in a recursive structure where a naive querySelector would match nested descendants. sendReply() builds a new comment node via buildComment() and appends it to that comment's own .replies container, so the reply lands at the correct depth.
Sorting
sortThread() reorders top-level comments by either vote count (Top) or recency (Newest), reading data-votes and data-time attributes. It sorts an array of the DOM nodes and re-appends them in order — appendChild on an existing node moves it rather than cloning, so reordering is efficient and preserves all event handlers and state.
The auto-growing composer
The composer textarea grows to fit its content via autoGrow() — the same auto-resize textarea technique — which resets the height to auto and then sets it to scrollHeight on every input. This avoids inner scrollbars and gives the comfortable expanding-input feel of modern comment boxes. Posting a root comment prepends it to the thread and updates the comment count.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to reconstruct the vote state machine or the recursive DOM targeting in your head. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why vote() stores a separate base value and a tri-state (0, 1, -1) rather than just incrementing a counter directly, and why toggleReply and sendReply use :scope-qualified queries instead of a plain querySelector. The same assistant can help optimize it — for instance asking whether sortThread's full re-append of every top-level comment node is necessary versus a more targeted DOM reorder for very long threads. It's also useful for extending the thread: ask it to add comment editing and deletion, collapse/expand controls for deeply nested replies on narrow screens, or wire the whole thing to a real backend so votes and new comments persist across reloads. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a nested, threaded comment system in plain HTML, CSS, and JavaScript with voting and sorting — no frameworks, no state library.
Requirements:
- Each comment must be a self-similar DOM structure (avatar, author, timestamp, text, action row, and a replies container that itself can hold more comments of the identical structure) so the same functions work correctly at any nesting depth, with replies visually indented and marked with a left border thread line.
- An upvote/downvote control per comment that tracks a base score plus a separate tri-state value (neutral, upvoted, downvoted) so the displayed count is always base plus state: clicking the active arrow again returns to neutral, and clicking the opposite arrow switches directly between the two active states without ever double-counting or drifting from repeated clicks.
- A "Reply" action that inserts an inline textarea box immediately after that specific comment's own action row — using a scoped child query (not a query that could accidentally match a nested descendant's reply box) — with Cancel and Reply buttons, and submitting appends the new comment into that exact parent's own replies container, not the root thread.
- A composer textarea at the top of the thread that grows its height automatically to fit its content as the user types, instead of scrolling internally, and clears itself after a successful post.
- A sort control that reorders only the top-level comments by either total votes (descending) or recency, by reading data attributes on each comment node and re-inserting the existing DOM nodes in the new order (moving them, not cloning or rebuilding them) so all attached event handlers and any open reply boxes are preserved.
- A live comment counter that updates whenever a new top-level comment or reply is posted.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Write a top-level commentType in the composer at the top. It grows as you type. Click Comment to post — your comment appears at the top of the thread and the count updates.
- 2Reply to a commentClick Reply under any comment to open an inline reply box at that exact level. Type your reply and click Reply to nest it under the parent.
- 3Vote on commentsClick the up or down arrow to vote. Clicking the same arrow again removes your vote; clicking the opposite arrow switches it. The count and arrow colour update instantly.
- 4Sort the discussionUse the Top / Newest dropdown to reorder top-level comments by vote count or recency.
- 5Wire to a backendReplace the in-DOM buildComment with API calls: POST new comments and replies to your server, and render the returned comment objects. Store votes server-side keyed by user and comment.
- 6Export for your frameworkClick "JSX" for a React component that renders comments recursively from a nested data array. Click "Vue" for a Vue 3 SFC using a recursive component.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Each vote count stores two pieces of data: a base value (the original score) and a state of 0, 1, or −1 representing the current user's vote. The displayed number is always base + state. Clicking upvote sets state to 1, or back to 0 if it was already 1. Clicking downvote sets it to −1, or back to 0. Switching directly from up to down moves from 1 to −1 in one click. Because the display is derived from base + state rather than incremented, repeated clicks can never accumulate a runaway count.
In a recursive structure, a comment contains a .replies container that holds more comments, each with their own .comment-actions and .replies. A plain querySelector(".replies") from a comment body would match the first nested .replies anywhere in its subtree, not necessarily its own direct child. The :scope pseudo-class (querySelector(":scope > .replies")) restricts the match to immediate children, so reply boxes and new comments are inserted at the correct depth rather than leaking into a descendant.
Track the depth as you render and stop indenting past a threshold (commonly 3-5 levels), which is how Reddit handles it. Beyond the limit, render deeper replies at the same indentation as their parent and prefix them with "replying to @user" for context. In CSS, you can also cap the cumulative left padding with a max value so the thread line never pushes content off a narrow screen.
Model comments as a nested array where each comment has a replies array. Create a recursive Comment component that renders its content and maps over comment.replies, rendering a Comment for each — the recursion mirrors the DOM nesting. Manage votes and the open reply box in component state or a normalised store keyed by comment id. For posting, update the tree immutably by inserting the new reply into the correct parent's replies array.