Accessibility pattern · Communication
Comment thread
Each comment is an article named by its author, and replies are a list nested inside the comment they answer, so the thread's shape is in the markup. Reply, edit and delete open in place and take focus; Escape puts focus back on the button that opened them, and every change is confirmed in a polite status.
- WCAG criteria
- 7
- Keyboard rules
- 4
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab or ShiftTab | Moves through the comment box, each comment's buttons and the thread toggles in reading order. |
| Enter or Space | On Reply, opens a reply box under that comment and moves focus into it. On a thread toggle, hides or shows its replies. |
| Escape | In a reply box, the edit box or the delete question, closes it and puts focus back on the button that opened it. |
| Enter | On Post comment or Post reply, posts it and moves focus to the new comment. On Save, keeps the change and returns focus to Edit. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| Tab reaches a Reply button | Reply to Asha, button, collapsed |
| Enter opens the reply box | Reply to Asha, edit text, multi line |
| The reply is posted | You, article (focus on the new reply), then: Reply posted |
| Focus reaches a thread toggle | Hide 3 replies, button, expanded |
| Delete is pressed on your comment | Delete this comment? This cannot be undone. Group. Cancel, button |
| A comment is posted | You, article, then: Comment posted |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<section class="ap-comments" data-ap-comments aria-labelledby="cmt-h">
<div class="ap-comments__head">
<h3 class="ap-comments__count" id="cmt-h" tabindex="-1">Comments <span class="ap-comments__n" data-ap-cmt-total>6</span></h3>
<p class="ap-comments__status" role="status"></p>
</div>
<form class="ap-comments__new" data-ap-cmt-new data-max="500" novalidate>
<label class="ap-label" for="cmt-new-text">Add a comment</label>
<textarea class="ap-input" id="cmt-new-text" name="comment" rows="3" aria-describedby="cmt-new-count"></textarea>
<p class="ap-error" id="cmt-new-error" hidden><svg class="ap-comments__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span></span></p>
<div class="ap-comments__bar">
<p class="ap-comments__left" id="cmt-new-count"><span data-ap-cmt-left>500</span> <span data-ap-cmt-words>characters left</span></p>
<button type="submit" class="ap-btn ap-btn--primary">Post comment</button>
</div>
</form>
<ol class="ap-comments__list">
<li>
<article class="ap-comments__c" id="cmt-1" aria-labelledby="cmt-1-who" tabindex="-1">
<span class="ap-comments__avatar" aria-hidden="true" translate="no">AR</span>
<div class="ap-comments__main">
<p class="ap-comments__meta"><span class="ap-comments__who" id="cmt-1-who">Asha Rao</span> <time datetime="2026-10-05T09:12+05:30" title="5 October 2026 at 09:12">3 hours ago<span class="ap-comments__vh">, <span>5 October 2026 at 09:12</span></span></time></p>
<p class="ap-comments__text">Carry two pairs of socks. The trail turns into a stream after Udhewadi, and wet feet blister fast.</p>
<div class="ap-comments__acts">
<button type="button" class="ap-comments__act" data-ap-cmt-reply aria-label="Reply to Asha" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v4"/></svg>Reply</button>
</div>
</div>
</article>
<button type="button" class="ap-comments__toggle" data-ap-cmt-toggle aria-expanded="true" aria-controls="cmt-1-replies"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg><span>Hide 3 replies</span></button>
<ol class="ap-comments__replies" id="cmt-1-replies">
<li>
<article class="ap-comments__c" id="cmt-1a" aria-labelledby="cmt-1a-who" tabindex="-1">
<span class="ap-comments__avatar" aria-hidden="true" translate="no">VS</span>
<div class="ap-comments__main">
<p class="ap-comments__meta"><span class="ap-comments__who" id="cmt-1a-who">Vikram Shetty</span> <time datetime="2026-10-05T10:05+05:30" title="5 October 2026 at 10:05">2 hours ago<span class="ap-comments__vh">, <span>5 October 2026 at 10:05</span></span></time></p>
<p class="ap-comments__text">Seconded. Quick-dry socks were worth every rupee.</p>
<div class="ap-comments__acts">
<button type="button" class="ap-comments__act" data-ap-cmt-reply aria-label="Reply to Vikram" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v4"/></svg>Reply</button>
</div>
</div>
</article>
</li>
<li>
<article class="ap-comments__c" id="cmt-1b" aria-labelledby="cmt-1b-who" tabindex="-1">
<span class="ap-comments__avatar" aria-hidden="true" translate="no">NK</span>
<div class="ap-comments__main">
<p class="ap-comments__meta"><span class="ap-comments__who" id="cmt-1b-who">Neha Kulkarni</span> <time datetime="2026-10-05T10:31+05:30" title="5 October 2026 at 10:31">2 hours ago<span class="ap-comments__vh">, <span>5 October 2026 at 10:31</span></span></time></p>
<p class="ap-comments__text">Is the path marked, or do we need a guide from Lonavala?</p>
<div class="ap-comments__acts">
<button type="button" class="ap-comments__act" data-ap-cmt-reply aria-label="Reply to Neha" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v4"/></svg>Reply</button>
</div>
</div>
</article>
</li>
<li>
<article class="ap-comments__c" id="cmt-1c" aria-labelledby="cmt-1c-who" tabindex="-1" data-own>
<span class="ap-comments__avatar ap-comments__avatar--me" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><circle cx="12" cy="8.5" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg></span>
<div class="ap-comments__main">
<p class="ap-comments__meta"><span class="ap-comments__who" id="cmt-1c-who">You</span> <time datetime="2026-10-05T11:02+05:30" title="5 October 2026 at 11:02">1 hour ago<span class="ap-comments__vh">, <span>5 October 2026 at 11:02</span></span></time></p>
<p class="ap-comments__text">It's marked up to the village. After that, follow the fort walls.</p>
<div class="ap-comments__acts">
<button type="button" class="ap-comments__act" data-ap-cmt-edit aria-label="Edit your comment"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>Edit</button>
<button type="button" class="ap-comments__act" data-ap-cmt-delete aria-label="Delete your comment"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16"/><path d="M10 11v6M14 11v6"/><path d="M6 7l1 13h10l1-13"/><path d="M9 7V4h6v3"/></svg>Delete</button>
</div>
</div>
</article>
</li>
</ol>
</li>
<li>
<article class="ap-comments__c" id="cmt-2" aria-labelledby="cmt-2-who" tabindex="-1">
<span class="ap-comments__avatar" aria-hidden="true" translate="no">RM</span>
<div class="ap-comments__main">
<p class="ap-comments__meta"><span class="ap-comments__who" id="cmt-2-who">Rohan Mehta</span> <time datetime="2026-10-04T18:40+05:30" title="4 October 2026 at 18:40">17 hours ago<span class="ap-comments__vh">, <span>4 October 2026 at 18:40</span></span></time></p>
<p class="ap-comments__text">Start before 7. The last shared jeeps back to Lonavala leave by 5.</p>
<div class="ap-comments__acts">
<button type="button" class="ap-comments__act" data-ap-cmt-reply aria-label="Reply to Rohan" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v4"/></svg>Reply</button>
</div>
</div>
</article>
<button type="button" class="ap-comments__toggle" data-ap-cmt-toggle aria-expanded="false" aria-controls="cmt-2-replies"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg><span>Show 1 reply</span></button>
<ol class="ap-comments__replies" id="cmt-2-replies" hidden>
<li>
<article class="ap-comments__c" id="cmt-2a" aria-labelledby="cmt-2a-who" tabindex="-1">
<span class="ap-comments__avatar" aria-hidden="true" translate="no">AR</span>
<div class="ap-comments__main">
<p class="ap-comments__meta"><span class="ap-comments__who" id="cmt-2a-who">Asha Rao</span> <time datetime="2026-10-04T19:15+05:30" title="4 October 2026 at 19:15">16 hours ago<span class="ap-comments__vh">, <span>4 October 2026 at 19:15</span></span></time></p>
<p class="ap-comments__text">And carry cash. The village stalls can't take UPI when the network drops.</p>
<div class="ap-comments__acts">
<button type="button" class="ap-comments__act" data-ap-cmt-reply aria-label="Reply to Asha" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v4"/></svg>Reply</button>
</div>
</div>
</article>
</li>
</ol>
</li>
</ol>
<div class="ap-comments__spare">
<form class="ap-comments__form" id="cmt-replyform" data-ap-cmt-replyform novalidate hidden>
<label class="ap-label" for="cmt-reply-text" data-ap-cmt-replylabel>Reply</label>
<textarea class="ap-input" id="cmt-reply-text" name="reply" rows="2"></textarea>
<p class="ap-error" id="cmt-reply-error" hidden><svg class="ap-comments__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span>Write a reply first.</span></p>
<div class="ap-comments__btns">
<button type="button" class="ap-btn" data-ap-cmt-cancel>Cancel</button>
<button type="submit" class="ap-btn ap-btn--primary">Post reply</button>
</div>
</form>
<form class="ap-comments__form" id="cmt-editform" data-ap-cmt-editform novalidate hidden>
<label class="ap-label" for="cmt-edit-text">Edit your comment</label>
<textarea class="ap-input" id="cmt-edit-text" name="edit" rows="2"></textarea>
<p class="ap-error" id="cmt-edit-error" hidden><svg class="ap-comments__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span>Write something, or delete the comment instead.</span></p>
<div class="ap-comments__btns">
<button type="button" class="ap-btn" data-ap-cmt-cancel>Cancel</button>
<button type="submit" class="ap-btn ap-btn--primary">Save</button>
</div>
</form>
<div class="ap-comments__confirm" role="group" aria-labelledby="cmt-confirm-q" data-ap-cmt-confirm hidden>
<p class="ap-comments__q" id="cmt-confirm-q">Delete this comment? This cannot be undone.</p>
<div class="ap-comments__btns">
<button type="button" class="ap-btn" data-ap-cmt-keep>Cancel</button>
<button type="button" class="ap-btn ap-btn--danger" data-ap-cmt-remove>Delete</button>
</div>
</div>
</div>
<p class="ap-comments__vh" aria-live="polite" data-ap-cmt-say></p>
</section>
/* Shared primitives: buttons and form fields used across the patterns.
Uses the --ap-* design tokens. */
.ap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
font-weight: 600;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-btn:hover {
background: var(--ap-surface-2);
}
.ap-btn:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-btn:disabled,
.ap-btn[aria-disabled="true"] {
opacity: .55;
cursor: not-allowed;
}
.ap-btn--primary {
border-color: var(--ap-accent);
background: var(--ap-accent);
color: var(--ap-on-accent);
box-shadow: var(--ap-shadow-sm);
}
.ap-btn--primary:hover {
border-color: var(--ap-accent-hover);
background: var(--ap-accent-hover);
}
.ap-btn--danger {
border-color: var(--ap-danger);
background: var(--ap-danger);
color: var(--ap-surface);
}
.ap-btn--danger:hover {
filter: brightness(1.08);
}
.ap-btn--ghost {
border-color: transparent;
background: transparent;
}
.ap-btn--ghost:hover {
background: var(--ap-surface-2);
}
.ap-btn__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-field {
display: grid;
gap: 6px;
}
.ap-label {
color: var(--ap-text);
font-weight: 600;
font-size: .9375rem;
}
.ap-hint {
margin: 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
.ap-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0;
color: var(--ap-danger);
font-size: .875rem;
font-weight: 600;
line-height: 1.45;
}
.ap-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
line-height: 1.4;
transition: border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-input::placeholder {
color: var(--ap-text-3);
}
.ap-input:hover {
border-color: var(--ap-text-2);
}
.ap-input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 1px;
border-color: var(--ap-focus);
}
.ap-input[aria-invalid="true"] {
border-color: var(--ap-danger);
box-shadow: inset 4px 0 0 var(--ap-danger);
}
textarea.ap-input {
resize: vertical;
min-height: 88px;
}
@media (prefers-reduced-motion: reduce) {
.ap-btn,
.ap-input { transition: none; }
}
/* Comment thread. Uses the --ap-* design tokens and the shared primitives. */
.ap-comments {
width: min(100%, 600px);
margin-inline: auto;
padding: 22px 24px 26px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
color: var(--ap-text);
box-shadow: var(--ap-shadow-md);
container-type: inline-size;
}
.ap-comments [hidden] {
display: none !important;
}
/* Heading, count and the outcome of the last action */
.ap-comments__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 6px 16px;
}
.ap-comments__count {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 1.125rem;
font-weight: 650;
line-height: 1.3;
}
.ap-comments__count:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 4px;
border-radius: 4px;
}
.ap-comments__n {
min-width: 26px;
padding: 1px 8px;
border-radius: var(--ap-radius-full);
background: var(--ap-surface-2);
color: var(--ap-text-2);
font-size: .8125rem;
font-weight: 650;
text-align: center;
font-variant-numeric: tabular-nums;
}
.ap-comments__status {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: var(--ap-success);
font-size: .875rem;
font-weight: 600;
}
.ap-comments__status:not(:empty)::before {
content: "";
width: 10px;
height: 5px;
margin-top: -3px;
border-bottom: 2px solid currentColor;
border-left: 2px solid currentColor;
transform: rotate(-45deg);
}
/* The new comment box */
.ap-comments__new {
display: grid;
gap: 6px;
margin-top: 16px;
padding-bottom: 22px;
border-bottom: 1px solid var(--ap-border);
}
.ap-comments__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 4px;
}
.ap-comments__left {
margin: 0;
color: var(--ap-text-3);
font-size: .8125rem;
}
.ap-comments__left [data-ap-cmt-left] {
color: var(--ap-text-2);
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.ap-comments__left[data-over],
.ap-comments__left[data-over] [data-ap-cmt-left] {
color: var(--ap-danger);
font-weight: 650;
}
.ap-comments__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
margin-top: 1px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
/* Comments and replies */
.ap-comments__list,
.ap-comments__replies {
margin: 0;
padding: 0;
list-style: none;
}
.ap-comments__list > li {
padding-top: 18px;
}
.ap-comments__list > li + li {
margin-top: 18px;
border-top: 1px solid var(--ap-border);
}
.ap-comments__replies {
display: grid;
gap: 14px;
margin: 6px 0 0 19px;
padding: 8px 0 2px 25px;
border-left: 2px solid var(--ap-border);
}
.ap-comments__c {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
border-radius: var(--ap-radius-sm);
}
.ap-comments__c:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 6px;
}
.ap-comments__avatar {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--ap-radius-full);
background: var(--ap-surface-3);
color: var(--ap-text);
font-size: .8125rem;
font-weight: 700;
letter-spacing: .02em;
}
.ap-comments__replies .ap-comments__avatar {
width: 32px;
height: 32px;
font-size: .75rem;
}
.ap-comments__avatar--me {
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
}
.ap-comments__avatar svg {
width: 55%;
height: 55%;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.ap-comments__main {
min-width: 0;
}
.ap-comments__meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 8px;
margin: 0;
color: var(--ap-text-3);
font-size: .8125rem;
}
.ap-comments__who {
color: var(--ap-text);
font-size: .9375rem;
font-weight: 650;
}
.ap-comments__edited {
padding: 0 6px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-full);
font-size: .75rem;
}
.ap-comments__text {
margin: 4px 0 0;
color: var(--ap-text-2);
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Reply, Edit and Delete */
.ap-comments__acts {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 6px 0 0 -8px;
}
.ap-comments__act,
.ap-comments__toggle {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 8px;
border: 0;
border-radius: var(--ap-radius-sm);
background: transparent;
color: var(--ap-text-2);
font: inherit;
font-size: .875rem;
font-weight: 600;
cursor: pointer;
}
.ap-comments__act:hover,
.ap-comments__toggle:hover {
background: var(--ap-surface-2);
color: var(--ap-text);
}
.ap-comments__act:focus-visible,
.ap-comments__toggle:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 0;
}
.ap-comments__act[aria-expanded="true"] {
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
}
.ap-comments__act svg,
.ap-comments__toggle svg {
width: 16px;
height: 16px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-comments__toggle {
margin: 8px 0 0 44px;
color: var(--ap-accent-text);
}
.ap-comments__toggle svg {
transition: rotate var(--ap-duration) var(--ap-ease);
}
.ap-comments__toggle[aria-expanded="false"] svg {
rotate: -90deg;
}
/* The reply box, the edit box and the delete question, moved into place */
.ap-comments__form {
display: grid;
gap: 6px;
margin-top: 10px;
padding: 14px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius);
background: var(--ap-surface-2);
animation: ap-comments-in 180ms var(--ap-ease);
}
.ap-comments__c + .ap-comments__form {
margin-left: 52px;
}
.ap-comments__replies .ap-comments__c + .ap-comments__form {
margin-left: 44px;
}
.ap-comments__form textarea.ap-input {
min-height: 72px;
}
.ap-comments__btns {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.ap-comments__confirm {
display: grid;
gap: 8px;
margin-top: 10px;
padding: 12px 14px;
border: 1px solid var(--ap-danger);
border-radius: var(--ap-radius);
background: var(--ap-danger-soft);
animation: ap-comments-in 180ms var(--ap-ease);
}
.ap-comments__q {
margin: 0;
color: var(--ap-danger);
font-weight: 600;
}
.ap-comments__confirm .ap-comments__btns {
justify-content: flex-start;
margin-top: 0;
}
.ap-comments__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes ap-comments-in {
from { opacity: 0; transform: translateY(-4px); }
}
@container (max-width: 440px) {
.ap-comments { padding: 18px 16px 20px; }
.ap-comments__c { gap: 10px; }
.ap-comments__avatar { width: 32px; height: 32px; font-size: .75rem; }
.ap-comments__replies { margin-left: 15px; padding-left: 14px; }
.ap-comments__replies .ap-comments__avatar { width: 28px; height: 28px; }
.ap-comments__toggle { margin-left: 34px; }
.ap-comments__c + .ap-comments__form,
.ap-comments__replies .ap-comments__c + .ap-comments__form { margin-left: 0; }
.ap-comments__bar .ap-btn { flex: 1 1 100%; }
}
@media (prefers-reduced-motion: reduce) {
.ap-comments__form,
.ap-comments__confirm { animation: none; }
.ap-comments__toggle svg { transition: none; }
}
/**
* Comment thread: articles named by their author, replies as nested lists,
* a reply box that opens under the comment it answers, collapsible threads,
* and edit or delete (with a question first) for your own comments.
*
* Markup: [data-ap-comments] holding a heading with the total
* ([data-ap-cmt-total]), a role=status, the new-comment form
* ([data-ap-cmt-new][data-max]), an ol of comments (li > article, then an
* optional toggle button[data-ap-cmt-toggle] and ol.ap-comments__replies),
* one spare reply form, edit form and delete question that the script moves
* into place, and a polite region ([data-ap-cmt-say]) for errors.
*/
const LIMIT_WORDS = (left) => (left < 0
? (left === -1 ? "character too many" : "characters too many")
: (left === 1 ? "character left" : "characters left"));
// One piece of text per count, so a translation can put the number anywhere.
const TOGGLE_WORDS = (n, open) => `${open ? "Hide" : "Show"} ${n} ${n === 1 ? "reply" : "replies"}`;
export function init(root) {
const ac = new AbortController();
const on = (target, type, fn) => target.addEventListener(type, fn, { signal: ac.signal });
const heading = root.querySelector(".ap-comments__count");
const total = root.querySelector("[data-ap-cmt-total]");
const status = root.querySelector("[role=status]");
const say = root.querySelector("[data-ap-cmt-say]");
const list = root.querySelector(".ap-comments__list");
const spare = root.querySelector(".ap-comments__spare");
const newForm = root.querySelector("[data-ap-cmt-new]");
const replyForm = root.querySelector("[data-ap-cmt-replyform]");
const replyLabel = replyForm.querySelector("[data-ap-cmt-replylabel]");
const editForm = root.querySelector("[data-ap-cmt-editform]");
const confirm = root.querySelector("[data-ap-cmt-confirm]");
const max = Number(newForm.dataset.max) || 500;
// Your own comment, as it was at the start: the model for new ones.
const ownModel = root.querySelector("article[data-own]")?.closest("li").cloneNode(true);
let replyingFrom = null; // the Reply button whose box is open
let editing = null; // the article being edited
let deleting = null; // the article whose delete question is open
let seq = 0;
const field = (form) => form.querySelector("textarea");
const articleOf = (el) => el.closest("article");
const threadOf = (el) => el.closest(".ap-comments__list > li");
function tell(text) {
status.textContent = "";
requestAnimationFrame(() => { status.textContent = text; });
}
function speak(text) {
say.textContent = "";
requestAnimationFrame(() => { say.textContent = text; });
}
/* ── Errors: shown under the box, tied to it only while they show ── */
function setError(form, message) {
const input = field(form);
const error = form.querySelector(".ap-error");
if (message) error.querySelector("span").textContent = message;
error.hidden = !message;
const ids = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter((id) => id && id !== error.id);
if (message) ids.push(error.id);
if (ids.length) input.setAttribute("aria-describedby", ids.join(" "));
else input.removeAttribute("aria-describedby");
if (message) input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
}
function refuse(form, message) {
setError(form, message);
const input = field(form);
// Already in the box, a new description is not reread: say it instead.
if (document.activeElement === input) speak(form.querySelector(".ap-error").textContent.trim());
else input.focus();
}
/* ── Counts ── */
function addToTotal(by) {
total.textContent = String(Number(total.textContent) + by);
}
function paintToggle(thread) {
const replies = thread.querySelector(":scope > .ap-comments__replies");
const toggle = thread.querySelector(":scope > .ap-comments__toggle");
if (!replies || !toggle) return;
const n = replies.children.length;
if (!n) {
toggle.remove();
replies.remove();
return;
}
toggle.querySelector("span").textContent = TOGGLE_WORDS(n, toggle.getAttribute("aria-expanded") === "true");
}
function setThreadOpen(thread, open) {
const toggle = thread.querySelector(":scope > .ap-comments__toggle");
const replies = thread.querySelector(":scope > .ap-comments__replies");
if (!open) {
// Anything open inside goes first, quietly.
if (replyingFrom && replies.contains(replyingFrom)) closeReply(false);
if (editing && replies.contains(editing)) closeEdit(false);
if (deleting && replies.contains(deleting)) closeConfirm(false);
}
toggle.setAttribute("aria-expanded", String(open));
replies.hidden = !open;
paintToggle(thread);
}
/** The replies list of a thread, made (with its toggle) if it has none yet. */
function repliesOf(thread) {
let replies = thread.querySelector(":scope > .ap-comments__replies");
if (replies) return replies;
const parent = thread.querySelector(":scope > article");
replies = document.createElement("ol");
replies.className = "ap-comments__replies";
replies.id = `${parent.id}-replies`;
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ap-comments__toggle";
toggle.setAttribute("data-ap-cmt-toggle", "");
toggle.setAttribute("aria-expanded", "true");
toggle.setAttribute("aria-controls", replies.id);
const chevron = root.querySelector(".ap-comments__toggle svg");
if (chevron) toggle.append(chevron.cloneNode(true));
toggle.append(document.createElement("span"));
thread.append(toggle, replies);
return replies;
}
/* ── A new comment of yours, made from the model ── */
function ownComment(text) {
const li = ownModel.cloneNode(true);
const article = li.querySelector("article");
seq += 1;
const id = `cmt-you-${seq}`;
article.id = id;
article.setAttribute("aria-labelledby", `${id}-who`);
li.querySelector(".ap-comments__who").id = `${id}-who`;
li.querySelector(".ap-comments__edited")?.remove();
const now = new Date();
const locale = document.documentElement.lang.startsWith("hi") ? "hi-IN" : "en-GB";
const full = new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(now);
const time = li.querySelector("time");
time.dateTime = now.toISOString();
time.title = full;
time.firstChild.textContent = "Just now";
time.querySelector(".ap-comments__vh > span").textContent = full;
li.querySelector(".ap-comments__text").textContent = text;
return li;
}
/* ── Close whatever is open ── */
function closeAll() {
closeReply(false);
closeEdit(false);
closeConfirm(false);
}
/* ── Reply ── */
function openReply(button) {
if (replyingFrom === button) {
field(replyForm).focus();
return;
}
closeAll();
replyingFrom = button;
button.setAttribute("aria-expanded", "true");
button.setAttribute("aria-controls", replyForm.id);
replyLabel.textContent = button.getAttribute("aria-label");
setError(replyForm, "");
field(replyForm).value = "";
articleOf(button).after(replyForm);
replyForm.hidden = false;
field(replyForm).focus();
}
function closeReply(returnFocus) {
if (!replyingFrom) return;
const button = replyingFrom;
replyingFrom = null;
replyForm.hidden = true;
spare.append(replyForm);
button.setAttribute("aria-expanded", "false");
button.removeAttribute("aria-controls");
if (returnFocus) button.focus();
}
function postReply(event) {
event.preventDefault();
const text = field(replyForm).value.trim();
if (!text) {
refuse(replyForm, "Write a reply first.");
return;
}
const thread = threadOf(replyingFrom);
closeReply(false);
const li = ownComment(text);
repliesOf(thread).append(li);
setThreadOpen(thread, true);
addToTotal(1);
li.querySelector("article").focus();
tell("Reply posted");
}
/* ── Edit ── */
function openEdit(article) {
closeAll();
editing = article;
const text = article.querySelector(".ap-comments__text");
const acts = article.querySelector(".ap-comments__acts");
setError(editForm, "");
field(editForm).value = text.textContent;
text.hidden = true;
acts.hidden = true;
text.after(editForm);
editForm.hidden = false;
const input = field(editForm);
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
function closeEdit(returnFocus) {
if (!editing) return;
const article = editing;
editing = null;
editForm.hidden = true;
spare.append(editForm);
article.querySelector(".ap-comments__text").hidden = false;
article.querySelector(".ap-comments__acts").hidden = false;
if (returnFocus) article.querySelector("[data-ap-cmt-edit]").focus();
}
function saveEdit(event) {
event.preventDefault();
const text = field(editForm).value.trim();
if (!text) {
refuse(editForm, "Write something, or delete the comment instead.");
return;
}
const article = editing;
const target = article.querySelector(".ap-comments__text");
const changed = target.textContent !== text;
target.textContent = text;
if (changed && !article.querySelector(".ap-comments__edited")) {
const mark = document.createElement("span");
mark.className = "ap-comments__edited";
mark.textContent = "Edited";
article.querySelector(".ap-comments__meta").append(" ", mark);
}
closeEdit(true);
if (changed) tell("Comment updated");
}
/* ── Delete, after a question ── */
function openConfirm(article) {
closeAll();
deleting = article;
const acts = article.querySelector(".ap-comments__acts");
acts.hidden = true;
acts.after(confirm);
confirm.hidden = false;
// The safe choice takes focus.
confirm.querySelector("[data-ap-cmt-keep]").focus();
}
function closeConfirm(returnFocus) {
if (!deleting) return;
const article = deleting;
deleting = null;
confirm.hidden = true;
spare.append(confirm);
article.querySelector(".ap-comments__acts").hidden = false;
if (returnFocus) article.querySelector("[data-ap-cmt-delete]").focus();
}
function remove() {
const article = deleting;
closeConfirm(false);
const li = article.closest("li");
const thread = threadOf(article);
const isReply = thread !== li;
// Focus goes to the comment before it, or to the heading.
const articles = [...root.querySelectorAll(".ap-comments__list article")].filter((a) => !a.closest("[hidden]"));
const before = articles[articles.indexOf(article) - 1];
const gone = li.querySelectorAll("article").length; // with any replies under it
li.remove();
if (isReply) paintToggle(thread);
addToTotal(-gone);
(before && before.isConnected ? before : heading).focus();
tell("Comment deleted");
}
/* ── New comment ── */
function paintCount() {
const left = max - [...field(newForm).value].length;
newForm.querySelector("[data-ap-cmt-left]").textContent = String(Math.abs(left));
const words = newForm.querySelector("[data-ap-cmt-words]");
const next = LIMIT_WORDS(left);
if (words.dataset.en !== next) {
words.textContent = next;
words.dataset.en = next;
}
newForm.querySelector(".ap-comments__left").toggleAttribute("data-over", left < 0);
return left;
}
function postComment(event) {
event.preventDefault();
const input = field(newForm);
const text = input.value.trim();
const left = paintCount();
if (!text) {
refuse(newForm, "Write a comment first.");
return;
}
if (left < 0) {
refuse(newForm, "Your comment is too long. Shorten it, then post.");
return;
}
setError(newForm, "");
closeAll();
const li = ownComment(text);
list.append(li);
addToTotal(1);
input.value = "";
paintCount();
li.querySelector("article").focus();
tell("Comment posted");
}
/* ── Wiring ── */
function onClick(event) {
const t = event.target;
const reply = t.closest("[data-ap-cmt-reply]");
if (reply) return openReply(reply);
const toggle = t.closest("[data-ap-cmt-toggle]");
if (toggle) return setThreadOpen(threadOf(toggle), toggle.getAttribute("aria-expanded") !== "true");
if (t.closest("[data-ap-cmt-edit]")) return openEdit(articleOf(t));
if (t.closest("[data-ap-cmt-delete]")) return openConfirm(articleOf(t));
if (t.closest("[data-ap-cmt-keep]")) return closeConfirm(true);
if (t.closest("[data-ap-cmt-remove]")) return remove();
if (t.closest("[data-ap-cmt-cancel]")) {
if (replyForm.contains(t)) closeReply(true);
else closeEdit(true);
}
return undefined;
}
function onKeydown(event) {
if (event.key !== "Escape") return;
const t = event.target;
if (replyForm.contains(t)) closeReply(true);
else if (editForm.contains(t)) closeEdit(true);
else if (confirm.contains(t)) closeConfirm(true);
else return;
event.preventDefault();
}
function onInput(event) {
const form = event.target.form;
if (form === newForm) paintCount();
// An error goes as soon as there is something to post.
if (form && field(form).getAttribute("aria-invalid") === "true" && field(form).value.trim()) {
if (form !== newForm || paintCount() >= 0) setError(form, "");
}
}
on(root, "click", onClick);
on(root, "keydown", onKeydown);
on(root, "input", onInput);
on(newForm, "submit", postComment);
on(replyForm, "submit", postReply);
on(editForm, "submit", saveEdit);
paintCount();
return () => ac.abort();
}
for (const root of document.querySelectorAll("[data-ap-comments]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.3.1 Info and Relationships
Level A
Comments are articles named by their author, replies are lists nested inside the comment they answer, and the count is part of the heading.
-
2.1.1 Keyboard
Level A
Replying, collapsing threads, editing and deleting all work from the keyboard with ordinary buttons.
-
2.4.3 Focus Order
Level A
Opening a reply or edit box moves focus into it; posting moves focus to the new comment; cancelling returns it to the button that started it.
-
3.3.1 Error Identification
Level A
An empty or too-long comment is not posted; an error under the box says why, tied to the box and announced.
-
3.3.4 Error Prevention (Legal, Financial, Data)
Level AA
Deleting asks first, in place, with Cancel focused, so one stray press never removes a comment.
-
4.1.2 Name, Role, Value
Level A
Every Reply button and reply box names whom it answers; thread toggles and Reply buttons report whether they are open with aria-expanded.
-
4.1.3 Status Messages
Level AA
Comment posted, Reply posted, Comment updated and Comment deleted are written to a status region, read without moving focus.
Usage
When to use it
Use it
- Discussions under an article, a product, a design or a task, where replies answer one comment.
- Review threads where people edit or remove what they wrote.
Use something else
- Real-time back-and-forth between a few people: a chat with a live log suits it better.
- Threads more than one level deep: past one level of replies, nesting is hard to follow by ear and by eye.
- Short ratings or reactions: a reaction bar says more with less.
Common failures
How it usually goes wrong
Reply buttons that all say Reply
A list of identical Reply buttons is meaningless out of context. Each one here is named Reply to Asha, Reply to Neha and so on.
A reply box that opens somewhere else
A box opened at the top of the page, or with focus left on the button, leaves people hunting for it. Here it opens under the comment and takes focus.
Focus dropped after posting
If focus stays on a button that has just disappeared, keyboard users land at the top of the page. Focus moves to the new comment, and the status confirms it.
Delete with no way back
A one-press delete next to Edit is easy to hit by mistake. The question here appears in place, with Cancel focused.
Indentation as the only sign of a reply
Indented divs look nested but read flat. Nested lists make each reply's place in the thread available to screen readers too.
Relative dates with no actual date
"3 hours ago" goes stale and is vague. The time element carries the exact date and time, and screen readers hear it after the relative one.
Notes
Building it
- One reply form, one edit form and one delete question are moved to where they are needed, so there is never more than one open at a time and ids stay unique.
- The visually hidden full date sits inside the time element after the relative one; the title attribute shows the same date to mouse users on hover.
- Thread toggles keep aria-expanded and also change their words between Hide and Show, as the GOV.UK accordion does; both say the same thing, so they never disagree.
- Posting a reply into a collapsed thread opens the thread first, so focus never lands on something hidden.
- Each count phrase (Hide 3 replies) is one piece of text, so translations can reorder the words and the number.
Sources: WAI-ARIA Authoring Practices: Disclosure · HTML: the article element
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction
Comments 6
Asha Rao
Carry two pairs of socks. The trail turns into a stream after Udhewadi, and wet feet blister fast.
Vikram Shetty
Seconded. Quick-dry socks were worth every rupee.
Neha Kulkarni
Is the path marked, or do we need a guide from Lonavala?
You
It's marked up to the village. After that, follow the fort walls.
Rohan Mehta
Start before 7. The last shared jeeps back to Lonavala leave by 5.
Asha Rao
And carry cash. The village stalls can't take UPI when the network drops.
Delete this comment? This cannot be undone.