Checkout redesign is live
The new checkout went out to every customer this morning. Card and UPI now share one form, and errors are listed at the top when you submit.
6 comments · 14 reactions
Accessibility pattern · Data display
Each post is a focusable article with a name, a description and its place in the list; Page Down and Page Up move between posts, and Control with End or Home leaves the feed. More posts come when you ask, with a button rather than infinite scroll, and new ones wait behind a pill that never moves the page or takes focus.
Live demo
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.
Design team, Bengaluru
The new checkout went out to every customer this morning. Card and UPI now share one form, and errors are listed at the top when you submit.
6 comments · 14 reactions
We fixed 31 of the 45 issues from the September accessibility audit. Most of the rest are chart colors; owners are in the tracker.
3 comments · 9 reactions
Forty new icons, all drawn on the same 24-pixel grid, and named for what they mean rather than what they show.
11 comments · 22 reactions
The place in Indiranagar is shut on Wednesdays, so lunch is now Thursday at 1 pm. Same table, same booking.
4 comments · 8 reactions
You're all caught up.
Posts older than 30 days are archived.
Open the archiveSix sessions with shop owners in Pune and Jaipur, Tuesday to Thursday. Add your name to the sheet if you want to watch one.
2 comments · 5 reactions
Surface and border tokens now pass 3:1 against each other in dark mode. Pull the latest package before your next release.
5 comments · 12 reactions
Say what went wrong and how to fix it, in that order, next to the field. The full guide is in the writing handbook.
7 comments · 16 reactions
Releases went more smoothly with the new checklist. We will keep code review under a day and try pairing on the hardest tickets.
1 comment · 6 reactions
Nisha Kapoor joins us from Mumbai as a content designer. Say hello in the team channel and invite her to your reviews.
9 comments · 31 reactions
Ask for new hardware through the IT form now, not by email. Requests are answered within two working days.
No comments · 3 reactions
The office is closed for Gandhi Jayanti on Friday 2 October. Plan deliveries and visits around it.
2 comments · 7 reactions
The new pricing page copy is in the shared doc. Comments by Wednesday, please; it goes to translation on Thursday.
No comments · 1 reaction
The failing contrast test was a stale snapshot. It is fixed and merged, so deploys are unblocked.
1 comment · 4 reactions
Today's review starts at 4 pm instead of 3, so the Bengaluru and Pune teams can both join.
3 comments · 2 reactions
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab | Moves to each post in turn and to the link inside it, then to Load older posts. |
| Page Down | Moves focus to the next post, from anywhere inside the current one. |
| Page Up | Moves focus to the previous post. |
| CtrlEnd | Leaves the feed for the first thing after it: Load older posts, or the archive link once every post is in. |
| CtrlHome | Leaves the feed for the last thing before it: the new posts pill when it shows, otherwise Notification settings. |
| Enter or Space | On Load older posts, adds the next posts and moves focus to the first of them; on the pill, shows the new posts and moves focus to the newest. |
Screen readers
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| Tab reaches the first post | Checkout redesign is live, article. Asha Rao, 18 min ago. The new checkout went out to every customer this morning. |
| Page Down moves to the next post | September audit: 14 issues left, article |
| Load older posts is pressed | Loading older posts |
| The posts arrive and focus moves to the first of them | Usability sessions booked for next week, article. 4 older posts loaded. |
| New posts arrive while someone is reading | 3 new posts at the top of the feed (focus stays where it is) |
| The last page arrives | 3 older posts loaded. You're all caught up. |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
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.
<div class="ap-feed" data-ap-feed>
<div class="ap-feed__top">
<div>
<h3 class="ap-feed__name" id="feed-name">Team activity</h3>
<p class="ap-feed__sub">Design team, Bengaluru</p>
</div>
<a class="ap-feed__settings" href="#feed-settings">Notification settings</a>
</div>
<div class="ap-feed__scroll" data-ap-scroll>
<div class="ap-feed__pillbar">
<button type="button" class="ap-feed__pill" data-ap-show-new hidden><svg class="ap-feed__pill-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 19V5M6 11l6-6 6 6"/></svg>Show 3 new posts</button>
</div>
<div class="ap-feed__list" role="feed" aria-labelledby="feed-name" aria-busy="false" data-ap-list>
<article class="ap-feed__post" tabindex="0" aria-posinset="1" aria-setsize="-1" aria-labelledby="feed-p1-t" aria-describedby="feed-p1-m feed-p1-d">
<span class="ap-feed__avatar ap-feed__avatar--accent" aria-hidden="true">AR</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p1-m"><span class="ap-feed__author">Asha Rao</span> <span aria-hidden="true">·</span> <time datetime="2026-10-05T09:42">18 min ago</time></p>
<h4 class="ap-feed__subject" id="feed-p1-t"><a href="#feed-post-1">Checkout redesign is live</a></h4>
<p class="ap-feed__text" id="feed-p1-d">The new checkout went out to every customer this morning. Card and UPI now share one form, and errors are listed at the top when you submit.</p>
<p class="ap-feed__stats">6 comments · 14 reactions</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-posinset="2" aria-setsize="-1" aria-labelledby="feed-p2-t" aria-describedby="feed-p2-m feed-p2-d">
<span class="ap-feed__avatar ap-feed__avatar--info" aria-hidden="true">KM</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p2-m"><span class="ap-feed__author">Kabir Mehta</span> <span aria-hidden="true">·</span> <time datetime="2026-10-05T09:00">1 h ago</time></p>
<h4 class="ap-feed__subject" id="feed-p2-t"><a href="#feed-post-2">September audit: 14 issues left</a></h4>
<p class="ap-feed__text" id="feed-p2-d">We fixed 31 of the 45 issues from the September accessibility audit. Most of the rest are chart colors; owners are in the tracker.</p>
<p class="ap-feed__stats">3 comments · 9 reactions</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-posinset="3" aria-setsize="-1" aria-labelledby="feed-p3-t" aria-describedby="feed-p3-m feed-p3-d">
<span class="ap-feed__avatar ap-feed__avatar--success" aria-hidden="true">MI</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p3-m"><span class="ap-feed__author">Meera Iyer</span> <span aria-hidden="true">·</span> <time datetime="2026-10-05T07:10">3 h ago</time></p>
<h4 class="ap-feed__subject" id="feed-p3-t"><a href="#feed-post-3">Icon set 2.0 is in the library</a></h4>
<p class="ap-feed__text" id="feed-p3-d">Forty new icons, all drawn on the same 24-pixel grid, and named for what they mean rather than what they show.</p>
<p class="ap-feed__stats">11 comments · 22 reactions</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-posinset="4" aria-setsize="-1" aria-labelledby="feed-p4-t" aria-describedby="feed-p4-m feed-p4-d">
<span class="ap-feed__avatar ap-feed__avatar--warning" aria-hidden="true">RS</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p4-m"><span class="ap-feed__author">Ravi Shah</span> <span aria-hidden="true">·</span> <time datetime="2026-10-04T16:30">Yesterday</time></p>
<h4 class="ap-feed__subject" id="feed-p4-t"><a href="#feed-post-4">Team lunch moves to Thursday</a></h4>
<p class="ap-feed__text" id="feed-p4-d">The place in Indiranagar is shut on Wednesdays, so lunch is now Thursday at 1 pm. Same table, same booking.</p>
<p class="ap-feed__stats">4 comments · 8 reactions</p>
</div>
</article>
</div>
<div class="ap-feed__loading" data-ap-loading hidden aria-hidden="true">
<span class="ap-feed__ghost"></span>
<span class="ap-feed__ghost"></span>
</div>
<div class="ap-feed__more">
<button type="button" class="ap-btn ap-feed__load" data-ap-more>
<svg class="ap-btn__icon ap-feed__spin" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M21 12a9 9 0 1 1-6.2-8.6"/></svg>
<span data-ap-more-label>Load older posts</span>
</button>
<p class="ap-feed__end" data-ap-end hidden>You're all caught up.</p>
</div>
</div>
<div class="ap-feed__foot">
<p class="ap-feed__foot-text">Posts older than 30 days are archived.</p>
<a class="ap-feed__archive" href="#feed-archive">Open the archive</a>
</div>
<p class="ap-feed__vh" role="status" data-ap-live></p>
<template data-ap-page>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p5-t" aria-describedby="feed-p5-m feed-p5-d">
<span class="ap-feed__avatar ap-feed__avatar--danger" aria-hidden="true">PN</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p5-m"><span class="ap-feed__author">Priya Nair</span> <span aria-hidden="true">·</span> <time datetime="2026-10-04T11:15">Yesterday</time></p>
<h4 class="ap-feed__subject" id="feed-p5-t"><a href="#feed-post-5">Usability sessions booked for next week</a></h4>
<p class="ap-feed__text" id="feed-p5-d">Six sessions with shop owners in Pune and Jaipur, Tuesday to Thursday. Add your name to the sheet if you want to watch one.</p>
<p class="ap-feed__stats">2 comments · 5 reactions</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p6-t" aria-describedby="feed-p6-m feed-p6-d">
<span class="ap-feed__avatar ap-feed__avatar--info" aria-hidden="true">AD</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p6-m"><span class="ap-feed__author">Arjun Das</span> <span aria-hidden="true">·</span> <time datetime="2026-10-03T14:20">2 days ago</time></p>
<h4 class="ap-feed__subject" id="feed-p6-t"><a href="#feed-post-6">Dark mode tokens updated</a></h4>
<p class="ap-feed__text" id="feed-p6-d">Surface and border tokens now pass 3:1 against each other in dark mode. Pull the latest package before your next release.</p>
<p class="ap-feed__stats">5 comments · 12 reactions</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p7-t" aria-describedby="feed-p7-m feed-p7-d">
<span class="ap-feed__avatar ap-feed__avatar--accent" aria-hidden="true">AR</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p7-m"><span class="ap-feed__author">Asha Rao</span> <span aria-hidden="true">·</span> <time datetime="2026-10-02T10:05">3 days ago</time></p>
<h4 class="ap-feed__subject" id="feed-p7-t"><a href="#feed-post-7">New guidelines for error messages</a></h4>
<p class="ap-feed__text" id="feed-p7-d">Say what went wrong and how to fix it, in that order, next to the field. The full guide is in the writing handbook.</p>
<p class="ap-feed__stats">7 comments · 16 reactions</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p8-t" aria-describedby="feed-p8-m feed-p8-d">
<span class="ap-feed__avatar ap-feed__avatar--info" aria-hidden="true">KM</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p8-m"><span class="ap-feed__author">Kabir Mehta</span> <span aria-hidden="true">·</span> <time datetime="2026-10-01T17:45">4 days ago</time></p>
<h4 class="ap-feed__subject" id="feed-p8-t"><a href="#feed-post-8">Sprint 14 retrospective notes</a></h4>
<p class="ap-feed__text" id="feed-p8-d">Releases went more smoothly with the new checklist. We will keep code review under a day and try pairing on the hardest tickets.</p>
<p class="ap-feed__stats">1 comment · 6 reactions</p>
</div>
</article>
</template>
<template data-ap-page>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p9-t" aria-describedby="feed-p9-m feed-p9-d">
<span class="ap-feed__avatar ap-feed__avatar--success" aria-hidden="true">MI</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p9-m"><span class="ap-feed__author">Meera Iyer</span> <span aria-hidden="true">·</span> <time datetime="2026-09-29T09:30">Last week</time></p>
<h4 class="ap-feed__subject" id="feed-p9-t"><a href="#feed-post-9">Welcome Nisha to the team</a></h4>
<p class="ap-feed__text" id="feed-p9-d">Nisha Kapoor joins us from Mumbai as a content designer. Say hello in the team channel and invite her to your reviews.</p>
<p class="ap-feed__stats">9 comments · 31 reactions</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p10-t" aria-describedby="feed-p10-m feed-p10-d">
<span class="ap-feed__avatar ap-feed__avatar--warning" aria-hidden="true">RS</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p10-m"><span class="ap-feed__author">Ravi Shah</span> <span aria-hidden="true">·</span> <time datetime="2026-09-28T15:00">Last week</time></p>
<h4 class="ap-feed__subject" id="feed-p10-t"><a href="#feed-post-10">Laptop requests have a new form</a></h4>
<p class="ap-feed__text" id="feed-p10-d">Ask for new hardware through the IT form now, not by email. Requests are answered within two working days.</p>
<p class="ap-feed__stats">No comments · 3 reactions</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p11-t" aria-describedby="feed-p11-m feed-p11-d">
<span class="ap-feed__avatar ap-feed__avatar--danger" aria-hidden="true">PN</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p11-m"><span class="ap-feed__author">Priya Nair</span> <span aria-hidden="true">·</span> <time datetime="2026-09-28T10:40">Last week</time></p>
<h4 class="ap-feed__subject" id="feed-p11-t"><a href="#feed-post-11">Office closed on 2 October</a></h4>
<p class="ap-feed__text" id="feed-p11-d">The office is closed for Gandhi Jayanti on Friday 2 October. Plan deliveries and visits around it.</p>
<p class="ap-feed__stats">2 comments · 7 reactions</p>
</div>
</article>
</template>
<template data-ap-new>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p12-t" aria-describedby="feed-p12-m feed-p12-d">
<span class="ap-feed__avatar ap-feed__avatar--success" aria-hidden="true">NK</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p12-m"><span class="ap-feed__author">Nisha Kapoor</span> <span aria-hidden="true">·</span> <time datetime="2026-10-05T10:00">Just now</time></p>
<h4 class="ap-feed__subject" id="feed-p12-t"><a href="#feed-post-12">Pricing page copy is ready for review</a></h4>
<p class="ap-feed__text" id="feed-p12-d">The new pricing page copy is in the shared doc. Comments by Wednesday, please; it goes to translation on Thursday.</p>
<p class="ap-feed__stats">No comments · 1 reaction</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p13-t" aria-describedby="feed-p13-m feed-p13-d">
<span class="ap-feed__avatar ap-feed__avatar--info" aria-hidden="true">AD</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p13-m"><span class="ap-feed__author">Arjun Das</span> <span aria-hidden="true">·</span> <time datetime="2026-10-05T09:59">1 min ago</time></p>
<h4 class="ap-feed__subject" id="feed-p13-t"><a href="#feed-post-13">The build is green again</a></h4>
<p class="ap-feed__text" id="feed-p13-d">The failing contrast test was a stale snapshot. It is fixed and merged, so deploys are unblocked.</p>
<p class="ap-feed__stats">1 comment · 4 reactions</p>
</div>
</article>
<article class="ap-feed__post" tabindex="0" aria-setsize="-1" aria-labelledby="feed-p14-t" aria-describedby="feed-p14-m feed-p14-d">
<span class="ap-feed__avatar ap-feed__avatar--success" aria-hidden="true">MI</span>
<div class="ap-feed__body">
<p class="ap-feed__meta" id="feed-p14-m"><span class="ap-feed__author">Meera Iyer</span> <span aria-hidden="true">·</span> <time datetime="2026-10-05T09:58">2 min ago</time></p>
<h4 class="ap-feed__subject" id="feed-p14-t"><a href="#feed-post-14">Design review moves to 4 pm</a></h4>
<p class="ap-feed__text" id="feed-p14-d">Today's review starts at 4 pm instead of 3, so the Bengaluru and Pune teams can both join.</p>
<p class="ap-feed__stats">3 comments · 2 reactions</p>
</div>
</article>
</template>
</div>
/* 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; }
}
/* Feed. Uses the --ap-* design tokens and the shared primitives. */
.ap-feed {
width: min(100%, 580px);
margin-inline: auto;
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;
overflow: hidden;
}
.ap-feed__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ap-feed__top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
padding: 18px 20px;
border-bottom: 1px solid var(--ap-border);
}
.ap-feed__name {
margin: 0;
font-size: 1.0625rem;
font-weight: 650;
line-height: 1.3;
}
.ap-feed__sub {
margin: 2px 0 0;
color: var(--ap-text-3);
font-size: .8125rem;
}
.ap-feed__settings {
display: inline-flex;
align-items: center;
min-height: 32px;
color: var(--ap-accent-text);
font-size: .875rem;
font-weight: 600;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.ap-feed__settings:hover {
text-decoration-thickness: 2px;
}
.ap-feed__settings:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
border-radius: 4px;
}
/* The scrolling area. Its top padding keeps a focused post clear of the pill. */
.ap-feed__scroll {
position: relative;
max-height: 520px;
overflow-y: auto;
overscroll-behavior: contain;
scroll-padding-top: 8px;
}
.ap-feed__scroll:has(.ap-feed__pill:not([hidden])) {
scroll-padding-top: 64px;
}
/* New posts: a pill on a zero-height sticky bar, so showing it moves nothing. */
.ap-feed__pillbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
justify-content: center;
height: 0;
}
.ap-feed__pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
margin-top: 12px;
padding: 0 16px 0 12px;
border: 0;
border-radius: var(--ap-radius-full);
background: var(--ap-accent);
color: var(--ap-on-accent);
box-shadow: var(--ap-shadow-lg);
font: inherit;
font-size: .875rem;
font-weight: 650;
white-space: nowrap;
cursor: pointer;
animation: ap-feed-drop 240ms var(--ap-ease);
}
.ap-feed__pill:hover {
background: var(--ap-accent-hover);
}
.ap-feed__pill:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-feed__pill[hidden] {
display: none;
}
.ap-feed__pill-icon {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Posts */
.ap-feed__post {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: 14px;
padding: 18px 20px;
border-bottom: 1px solid var(--ap-border);
}
.ap-feed__post:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: -4px;
border-radius: var(--ap-radius);
}
.ap-feed__post.is-new {
animation: ap-feed-new 2.4s var(--ap-ease);
}
.ap-feed__avatar {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--ap-radius-full);
font-size: .8125rem;
font-weight: 700;
letter-spacing: .02em;
}
.ap-feed__avatar--accent { background: var(--ap-accent-soft); color: var(--ap-accent-soft-text); }
.ap-feed__avatar--info { background: var(--ap-info-soft); color: var(--ap-info); }
.ap-feed__avatar--success { background: var(--ap-success-soft); color: var(--ap-success); }
.ap-feed__avatar--warning { background: var(--ap-warning-soft); color: var(--ap-warning); }
.ap-feed__avatar--danger { background: var(--ap-danger-soft); color: var(--ap-danger); }
.ap-feed__body {
min-width: 0;
}
.ap-feed__meta {
margin: 0;
color: var(--ap-text-3);
font-size: .8125rem;
line-height: 1.4;
}
.ap-feed__author {
color: var(--ap-text);
font-weight: 600;
}
.ap-feed__subject {
margin: 4px 0 4px;
font-size: 1rem;
font-weight: 650;
line-height: 1.35;
}
.ap-feed__subject a {
color: var(--ap-text);
text-decoration: none;
}
.ap-feed__subject a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.ap-feed__subject a:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
border-radius: 4px;
}
.ap-feed__text {
margin: 0;
color: var(--ap-text-2);
font-size: .9375rem;
line-height: 1.55;
}
.ap-feed__stats {
margin: 10px 0 0;
color: var(--ap-text-3);
font-size: .8125rem;
}
/* Placeholders while a batch loads: decoration only, hidden from assistive technology. */
.ap-feed__loading {
display: grid;
}
.ap-feed__loading[hidden] {
display: none;
}
.ap-feed__ghost {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: 14px;
height: 104px;
padding: 18px 20px;
border-bottom: 1px solid var(--ap-border);
animation: ap-feed-pulse 1.2s ease-in-out infinite alternate;
}
.ap-feed__ghost::before {
content: "";
width: 40px;
height: 40px;
border-radius: var(--ap-radius-full);
background: var(--ap-surface-3);
}
.ap-feed__ghost::after {
content: "";
border-radius: 6px;
background:
linear-gradient(var(--ap-surface-3) 0 0) 0 2px / 34% 10px no-repeat,
linear-gradient(var(--ap-surface-3) 0 0) 0 24px / 72% 14px no-repeat,
linear-gradient(var(--ap-surface-2) 0 0) 0 50px / 92% 10px no-repeat;
}
.ap-feed__more {
display: grid;
justify-items: center;
padding: 18px 20px 22px;
}
.ap-feed__load {
min-height: var(--ap-target);
}
.ap-feed__spin {
display: none;
}
.ap-feed__load[aria-disabled="true"] {
opacity: 1;
cursor: progress;
}
.ap-feed__load[aria-disabled="true"] .ap-feed__spin {
display: block;
animation: ap-feed-spin .8s linear infinite;
}
.ap-feed__end {
margin: 0;
color: var(--ap-text-3);
font-size: .875rem;
font-weight: 600;
}
/* The footer a Load more button keeps within reach. */
.ap-feed__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 4px 16px;
padding: 12px 20px;
border-top: 1px solid var(--ap-border);
background: var(--ap-surface-2);
}
.ap-feed__foot-text {
margin: 0;
color: var(--ap-text-3);
font-size: .8125rem;
}
.ap-feed__archive {
display: inline-flex;
align-items: center;
min-height: 32px;
color: var(--ap-accent-text);
font-size: .875rem;
font-weight: 600;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.ap-feed__archive:hover {
text-decoration-thickness: 2px;
}
.ap-feed__archive:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
border-radius: 4px;
}
@keyframes ap-feed-drop {
from { opacity: 0; transform: translateY(-8px); }
}
@keyframes ap-feed-new {
from { background: var(--ap-accent-soft); }
}
@keyframes ap-feed-pulse {
from { opacity: .55; }
}
@keyframes ap-feed-spin {
to { transform: rotate(360deg); }
}
@container (max-width: 420px) {
.ap-feed__top,
.ap-feed__post {
padding-inline: 16px;
}
.ap-feed__post {
grid-template-columns: 32px minmax(0, 1fr);
gap: 12px;
}
.ap-feed__avatar {
width: 32px;
height: 32px;
font-size: .75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.ap-feed__pill,
.ap-feed__post.is-new,
.ap-feed__ghost { animation: none; }
.ap-feed__load[aria-disabled="true"] .ap-feed__spin { animation: none; }
}
/**
* Feed: a role=feed of focusable articles. Page Down and Page Up move
* between them, Ctrl+End and Ctrl+Home leave the feed, a button loads
* older posts, and new posts wait behind a pill until someone asks.
*
* Markup: [data-ap-feed] holding a scroll area [data-ap-scroll] with
* button[data-ap-show-new] (the pill), [role=feed][data-ap-list] of
* article[tabindex=0][aria-posinset][aria-setsize], [data-ap-loading],
* button[data-ap-more] with [data-ap-more-label], and [data-ap-end]; a
* role=status [data-ap-live]; and template[data-ap-page] (older pages, in
* order) and template[data-ap-new] (posts that arrive later).
*/
const FOCUSABLE = "a[href], button, input, select, textarea, summary, [tabindex]";
const NETWORK = 900; // how long a page takes to arrive in the demo
const NEW_AFTER = 4000; // new posts arrive this long after you first use the feed
export function init(root) {
const feed = root.querySelector("[data-ap-list]");
const scroller = root.querySelector("[data-ap-scroll]");
const more = root.querySelector("[data-ap-more]");
const moreLabel = root.querySelector("[data-ap-more-label]");
const end = root.querySelector("[data-ap-end]");
const loading = root.querySelector("[data-ap-loading]");
const pill = root.querySelector("[data-ap-show-new]");
const live = root.querySelector("[data-ap-live]");
const pages = [...root.querySelectorAll("template[data-ap-page]")];
const fresh = root.querySelector("template[data-ap-new]");
let next = 0;
let loadTimer = 0;
let arriveTimer = 0;
let arrived = false;
const posts = () => [...feed.querySelectorAll(":scope > article")];
const say = (text) => {
live.textContent = "";
requestAnimationFrame(() => { live.textContent = text; });
};
// Each post's place, and the total: -1 for as long as more may load.
function renumber() {
const all = posts();
const total = next < pages.length ? -1 : all.length;
all.forEach((post, i) => {
post.setAttribute("aria-posinset", String(i + 1));
post.setAttribute("aria-setsize", String(total));
});
}
function loadOlder() {
if (more.getAttribute("aria-disabled") === "true" || next >= pages.length) return;
// aria-disabled, not disabled: focus stays on the button while it works.
more.setAttribute("aria-disabled", "true");
moreLabel.textContent = "Loading…";
loading.hidden = false;
feed.setAttribute("aria-busy", "true");
say("Loading older posts");
loadTimer = setTimeout(() => {
const batch = pages[next++].content.cloneNode(true);
const added = [...batch.querySelectorAll("article")];
feed.append(batch);
loading.hidden = true;
renumber();
feed.setAttribute("aria-busy", "false");
more.removeAttribute("aria-disabled");
moreLabel.textContent = "Load older posts";
const last = next >= pages.length;
if (last) {
more.hidden = true;
end.hidden = false;
}
// Reading carries on from the first new post, brought to the top.
scroller.scrollTop = added[0].offsetTop - 8;
added[0].focus({ preventScroll: true });
say(last ? `${added.length} older posts loaded. You're all caught up.` : `${added.length} older posts loaded`);
}, NETWORK);
}
// New posts wait: the pill appears, one polite message, nothing moves.
function arrive() {
arrived = true;
pill.hidden = false;
say("3 new posts at the top of the feed");
}
function showNew() {
const batch = fresh.content.cloneNode(true);
const added = [...batch.querySelectorAll("article")];
for (const post of added) post.classList.add("is-new");
feed.prepend(batch);
renumber();
pill.hidden = true;
scroller.scrollTop = 0;
added[0].focus();
}
function startClock() {
if (!arrived && !arriveTimer) arriveTimer = setTimeout(arrive, NEW_AFTER);
}
// The first focusable thing after the feed, or the last one before it.
function beside(where) {
const outside = [...document.querySelectorAll(FOCUSABLE)].filter((el) => !feed.contains(el)
&& el.tabIndex >= 0 && !el.disabled && el.getClientRects().length);
const after = (el) => feed.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING;
return where === "after" ? outside.find(after) : outside.filter((el) => !after(el)).pop();
}
function onKeydown(event) {
const post = event.target.closest("article");
if (!post || post.parentElement !== feed) return;
const all = posts();
const at = all.indexOf(post);
const ctrl = event.ctrlKey || event.metaKey;
let target = null;
if (event.key === "PageDown") target = all[at + 1];
else if (event.key === "PageUp") target = all[at - 1];
else if (event.key === "End" && ctrl) target = beside("after");
else if (event.key === "Home" && ctrl) target = beside("before");
if (!target) return;
event.preventDefault();
target.focus();
}
function onClick(event) {
if (event.target.closest("a[href^='#feed-']")) {
// The demo's pages do not exist here; a real product follows the link.
event.preventDefault();
return;
}
if (event.target.closest("[data-ap-more]")) loadOlder();
else if (event.target.closest("[data-ap-show-new]")) showNew();
}
root.addEventListener("keydown", onKeydown);
root.addEventListener("click", onClick);
root.addEventListener("pointerenter", startClock);
root.addEventListener("focusin", startClock);
return () => {
clearTimeout(loadTimer);
clearTimeout(arriveTimer);
root.removeEventListener("keydown", onKeydown);
root.removeEventListener("click", onClick);
root.removeEventListener("pointerenter", startClock);
root.removeEventListener("focusin", startClock);
};
}
for (const root of document.querySelectorAll("[data-ap-feed]")) 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
The success criteria this pattern takes care of, and how.
role=feed holds the articles; each is named by its subject, described by its author, time and text, and placed with aria-posinset.
Page Down, Page Up, Control with End or Home, and Tab reach every post and everything after the feed; loading more is a button, not a scroll position.
New posts do not push in on their own: they wait behind a pill until someone asks to see them.
Load older posts moves focus to the first post it added, so reading carries on in order, and focus never jumps when new posts arrive.
Scroll padding keeps a focused post clear of the pill pinned to the top of the feed.
aria-busy is true while a batch goes in, and aria-setsize stays -1 until the last page is in, so the count is never wrong.
Loading, how many posts arrived and the new posts waiting are status messages, announced without moving focus.
Usage
Common failures
Each time a keyboard or screen reader user gets near the footer, more posts load and push it away, so its links are never reached. A Load more button lets people choose when the list grows and reach what comes after it.
Posts inserted above the one someone is reading move it out from under them and can drag focus with it. Here they wait behind a pill that sits on a zero-height bar.
If focus stays on a button that then moves or disappears, people start again from the top. Focus goes to the first new post.
Without tabindex on each article, Page Down has nothing to move to, and a screen reader cannot report which post it is in.
aria-setsize="4" on a feed that will grow tells people the end is near when it is not. -1 says the total is unknown until the last page is in.
Without aria-busy, a screen reader can start reading a batch while half of it is still being inserted.
Notes
Sources: WAI-ARIA Authoring Practices: Feed · WAI-ARIA 1.2: the feed role
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