Social

Comment

One comment: avatar, name, role, time, text, Like and Reply, with its replies nested inside it.

#social#comment#user#no-js

Demo

Preview
Ana Silva

Does this work with SQLite too? Asking for a small project.

Ben OkaforAuthor

It does. The migrations run the same way.

Ana Silva

Great, thanks!

Lee Chen

Same question here, answered then.

Ben OkaforAuthor

Yes, and Postgres: the same query runs on both.

Not liked. Reply pressed 0 times.

Mia Rossi

A comment with no actions under it.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/comment.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/comment.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/comment.qwe
component CommentDemoLive() {    state liked = false    state likes = 3    state replies = 0    <div.demo-stack>        <Comment id="comment-demo-live" name="Ben Okafor" role="Author" time="1 h" datetime="2026-10-28T10:30:00Z"                 text="Yes, and Postgres: the same query runs on both." likes={likes} liked={liked}                 on_like={(on) => { liked = on; likes = on ? likes + 1 : likes - 1 }}                 on_reply={() => { replies += 1 }}/>        <p.demo-note>{liked ? "You liked it." : "Not liked."} Reply pressed {replies} {replies == 1 ? "time" : "times"}.</p>    </div>}view CommentDemo() =    <div.demo-stack>        <Comment id="comment-demo-first" name="Ana Silva" time="2 h" datetime="2026-10-28T09:12:00Z" likes={12}                 text="Does this work with SQLite too?Asking for a small project.">            <Comment name="Ben Okafor" role="Author" time="1 h" text="It does. The migrations run the same way." likes={2}>                <Comment name="Ana Silva" time="40 min" text="Great, thanks!"/>            </Comment>            <Comment name="Lee Chen" avatar="https://i.pravatar.cc/80?img=12" time="30 min" text="Same question here, answered then."/>        </Comment>        <CommentDemoLive/>        <Comment id="comment-demo-quiet" name="Mia Rossi" time="Yesterday" text="A comment with no actions under it." actions={false}/>    </div>

Source

ui/comment.qwe
// Comment: one comment under a post: who wrote it (avatar, name, a role badge), when,// what they said, and Like and Reply under it. Its replies go inside it, as more// Comments, indented and joined by a line. (CommentThread folds and answers a whole// tree of them.)////   <Comment name="Ana Silva" time="2 h" datetime="2026-10-28T09:12:00Z" likes={3}//            text="Does this work with SQLite too?"/>//   <Comment name="Ben Okafor" role="Author" text="Yes, and Postgres." liked={true} likes={1}//            on_like={(on) => { liked = on }} on_reply={() => { open = true }}>//       <Comment name="Ana Silva" text="Great, thanks!"/>//   </Comment>//// name: who wrote it. avatar: their picture (else their initials). role: a small badge// after the name ("Author", "Mod"). time: when, as it should read ("2 h", "Yesterday");// datetime: the same in ISO form for machines. text: what they said, plain text (line// breaks kept). likes: how many liked it; liked: whether you did. children: its// replies. actions: false hides the Like and Reply buttons. reply_label ("Reply").// id: the element's id, for a link to it ("#comment-42").// on_like(liked): inside one of your components, called with true or false when the// Like button is pressed. on_reply(): called when Reply is pressed.import ui/theme.qwe use ui_clslet comment_css = css`.ui-comment { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 10px; min-width: 0; font: 14px/1.5 var(--ui-font); color: var(--ui-fg); }.ui-comment-avatar {  display: grid; place-items: center; grid-row: span 3; width: 34px; height: 34px; border-radius: 50%; overflow: hidden;  background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 12px; font-weight: 650; user-select: none;}.ui-comment-avatar img { width: 100%; height: 100%; object-fit: cover; }.ui-comment-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; min-width: 0; }.ui-comment-name { font-weight: 600; }.ui-comment-role { padding: 1px 6px; border-radius: 999px; background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 11px; font-weight: 600; }.ui-comment-time { color: var(--ui-muted); font-size: 12px; }.ui-comment-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }.ui-comment-actions { display: flex; gap: 4px; margin-left: -8px; }.ui-comment-btn {  display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 8px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-muted); font: 500 13px/1 var(--ui-font); cursor: pointer;}.ui-comment-btn svg { width: 15px; height: 15px; }.ui-comment-btn:hover { background: var(--ui-subtle); color: var(--ui-fg); }.ui-comment-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-comment-btn[aria-pressed="true"] { color: var(--ui-danger); }.ui-comment-btn[aria-pressed="true"] svg { fill: currentColor; }.ui-comment-replies { grid-column: 2; display: grid; gap: 14px; margin-top: 8px; padding-left: 14px; border-left: 2px solid var(--ui-border); }.ui-comment-replies:empty { display: none; }`// Up to two initials of a name: "Ana Silva" is "AS".fn comment_initials(name: str) -> str =    upper(join(split(trim(name), " ") | filter((w) => w != "") | map((w) => slice(w, 0, 1)) | slice(0, 2), ""))view Comment(name: str, text: str = "", avatar: str? = null, role: str? = null, time: str? = null,             datetime: str? = null, likes: int = 0, liked: bool = false, actions: bool = true,             reply_label: str = "Reply", id: str? = null, on_like = null, on_reply = null,             class: str? = null, children) {    style global comment_css    <article class={ui_cls(["ui-comment", class])} id={id} aria-label={`Comment by ${name}`}>        <span.ui-comment-avatar aria-hidden="true">            {avatar != null ? <img src={avatar} alt="" loading="lazy"/> : comment_initials(name)}        </span>        <div.ui-comment-head>            <span.ui-comment-name>{name}</span>            {role != null && <span.ui-comment-role>{role}</span>}            {time != null && <time.ui-comment-time datetime={datetime}>{time}</time>}        </div>        <p.ui-comment-text>{text}</p>        {actions && <div.ui-comment-actions>            <button.ui-comment-btn type="button" aria-pressed={liked ? "true" : "false"}                    aria-label={`Like, ${likes} ${likes == 1 ? "like" : "likes"}`}                    @click={() => { on_like && on_like(!liked) }}>                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                     stroke-linejoin="round" aria-hidden="true"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>                <span aria-hidden="true">{likes > 0 ? str(likes) : "Like"}</span>            </button>            <button.ui-comment-btn type="button" @click={() => { on_reply && on_reply() }}>                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                     stroke-linejoin="round" aria-hidden="true"><path d="M9 17l-5-5 5-5"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>                {reply_label}            </button>        </div>}        {children != null && <div.ui-comment-replies>{children}</div>}    </article>}
Thread
68Useful? Like it, or add to its thread below.

Thread 0 posts

On Comment: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.