diff --git a/blocks/article-cards/article-cards.css b/blocks/article-cards/article-cards.css
new file mode 100644
index 0000000..289996c
--- /dev/null
+++ b/blocks/article-cards/article-cards.css
@@ -0,0 +1,104 @@
+.article-cards-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 32px 24px;
+}
+
+@media (width >= 600px) {
+ .article-cards-list {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (width >= 900px) {
+ .article-cards-list {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+.article-cards-list li {
+ margin: 0;
+}
+
+.article-card {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ color: inherit;
+ text-decoration: none;
+}
+
+.article-card-image {
+ overflow: hidden;
+ background-color: var(--conti-lightest-grey);
+}
+
+.article-card-image img {
+ display: block;
+ width: 100%;
+ aspect-ratio: 3 / 2;
+ object-fit: cover;
+ transition: transform 0.4s ease;
+}
+
+.article-card:hover .article-card-image img,
+.article-card:focus-visible .article-card-image img {
+ transform: scale(1.05);
+}
+
+.article-card-body {
+ padding: 16px 2px 0;
+}
+
+.article-card-body h3 {
+ margin: 0;
+ font-family: var(--body-font-family);
+ font-size: 16px;
+ font-weight: 700;
+ line-height: 1.3;
+ letter-spacing: 0.3px;
+ color: var(--conti-black);
+}
+
+.article-card:hover .article-card-body h3,
+.article-card:focus-visible .article-card-body h3 {
+ color: var(--conti-yellow);
+}
+
+.article-card-body p {
+ margin: 8px 0 0;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--conti-darkest-grey);
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.article-cards-more {
+ display: block;
+ margin: 40px auto 0;
+ border: 1px solid currentcolor;
+ border-radius: 24px;
+ padding: 12px 32px;
+ background: none;
+ color: inherit;
+ font-family: var(--heading-font-family);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 1.25px;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: background-color 0.2s ease, color 0.2s ease;
+}
+
+.article-cards-more:hover,
+.article-cards-more:focus-visible {
+ background-color: var(--conti-black);
+ color: var(--conti-white);
+}
diff --git a/blocks/article-cards/article-cards.js b/blocks/article-cards/article-cards.js
new file mode 100644
index 0000000..63b4d0b
--- /dev/null
+++ b/blocks/article-cards/article-cards.js
@@ -0,0 +1,85 @@
+import { createOptimizedPicture } from '../../scripts/aem.js';
+
+const DEFAULT_SOURCE = '/learn/query-index.json';
+const BATCH = 12;
+
+/** Drops the " | Continental Tire" suffix the article
carries. */
+function cleanTitle(title) {
+ return (title || '').replace(/\s*\|\s*Continental Tire\s*$/i, '').trim();
+}
+
+/** Builds one card: an anchor wrapping the optimized image and the title. */
+function buildCard(row) {
+ const card = document.createElement('a');
+ card.className = 'article-card';
+ card.href = row.path;
+
+ const figure = document.createElement('div');
+ figure.className = 'article-card-image';
+ figure.append(createOptimizedPicture(row.image, cleanTitle(row.title), false, [{ width: '750' }]));
+
+ const heading = document.createElement('h3');
+ heading.textContent = cleanTitle(row.title);
+
+ const body = document.createElement('div');
+ body.className = 'article-card-body';
+ body.append(heading);
+ if (row.description) {
+ const desc = document.createElement('p');
+ desc.textContent = row.description;
+ body.append(desc);
+ }
+
+ card.append(figure, body);
+ return card;
+}
+
+/**
+ * Article cards: fetches a query-index and renders its rows as a card grid,
+ * newest first. Shows a first batch and reveals the rest on demand. The
+ * index path can be authored in the block; it defaults to the learn index.
+ * @param {Element} block the article-cards block
+ */
+export default async function decorate(block) {
+ const configured = block.textContent.trim();
+ const source = configured.startsWith('/') ? configured : DEFAULT_SOURCE;
+ block.textContent = '';
+
+ let rows = [];
+ try {
+ const resp = await fetch(source);
+ if (resp.ok) ({ data: rows = [] } = await resp.json());
+ } catch (e) {
+ rows = [];
+ }
+
+ rows = rows
+ .filter((row) => row.image)
+ .sort((a, b) => Number(b.lastModified || 0) - Number(a.lastModified || 0));
+
+ const list = document.createElement('ul');
+ list.className = 'article-cards-list';
+
+ const more = document.createElement('button');
+ more.type = 'button';
+ more.className = 'article-cards-more';
+ more.textContent = 'Load more';
+
+ let shown = 0;
+ const renderNext = () => {
+ rows.slice(shown, shown + BATCH).forEach((row) => {
+ const li = document.createElement('li');
+ li.append(buildCard(row));
+ list.append(li);
+ });
+ shown = Math.min(shown + BATCH, rows.length);
+ if (shown >= rows.length) more.remove();
+ };
+
+ renderNext();
+ block.append(list);
+ if (shown < rows.length) {
+ more.addEventListener('click', renderNext);
+ block.append(more);
+ }
+}
diff --git a/blocks/category-tabs/category-tabs.css b/blocks/category-tabs/category-tabs.css
new file mode 100644
index 0000000..af84efd
--- /dev/null
+++ b/blocks/category-tabs/category-tabs.css
@@ -0,0 +1,41 @@
+.category-tabs-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ border-bottom: 1px solid var(--conti-lightest-grey);
+}
+
+@media (width <= 700px) {
+ .category-tabs-list {
+ justify-content: flex-start;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ }
+}
+
+.category-tabs-list li {
+ margin: 0;
+}
+
+.category-tabs-list a {
+ display: block;
+ padding: 16px 20px;
+ color: var(--conti-black);
+ text-decoration: none;
+ font-family: var(--heading-font-family);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 1.25px;
+ text-transform: uppercase;
+ white-space: nowrap;
+ border-bottom: 3px solid transparent;
+ transition: border-color 0.2s ease;
+}
+
+.category-tabs-list a:hover,
+.category-tabs-list a:focus-visible {
+ border-bottom-color: var(--conti-yellow);
+}
diff --git a/blocks/category-tabs/category-tabs.js b/blocks/category-tabs/category-tabs.js
new file mode 100644
index 0000000..7b768eb
--- /dev/null
+++ b/blocks/category-tabs/category-tabs.js
@@ -0,0 +1,9 @@
+/**
+ * Category tab bar: a horizontal, scrollable row of section links. The
+ * authored list is reused as-is; this only tags it for styling.
+ * @param {Element} block the category-tabs block
+ */
+export default function decorate(block) {
+ const list = block.querySelector('ul');
+ if (list) list.classList.add('category-tabs-list');
+}
diff --git a/test/blocks/article-cards/article-cards.test.js b/test/blocks/article-cards/article-cards.test.js
new file mode 100644
index 0000000..f711d2c
--- /dev/null
+++ b/test/blocks/article-cards/article-cards.test.js
@@ -0,0 +1,70 @@
+/* eslint-disable no-unused-expressions */
+/* global describe it afterEach */
+
+import { expect } from '@esm-bundle/chai';
+import sinon from 'sinon';
+import decorate from '../../../blocks/article-cards/article-cards.js';
+
+/** A query-index response with `count` article rows, newest last. */
+function indexResponse(count) {
+ const data = Array.from({ length: count }, (unused, i) => ({
+ path: `/learn/article-${i}`,
+ title: `Article ${i} | Continental Tire`,
+ image: `/learn/media_${i}.png?width=1200&format=pjpg&optimize=medium`,
+ description: `Description ${i}.`,
+ lastModified: `${1700000000 + i}`,
+ }));
+ return {
+ total: count, offset: 0, limit: count, data,
+ };
+}
+
+function buildBlock() {
+ document.body.innerHTML = '';
+ return document.querySelector('.article-cards.block');
+}
+
+describe('Article cards block', () => {
+ let fetchStub;
+ afterEach(() => fetchStub?.restore());
+
+ it('renders a card per indexed article, newest first', async () => {
+ fetchStub = sinon.stub(window, 'fetch').resolves(new Response(JSON.stringify(indexResponse(3))));
+ const block = buildBlock();
+ await decorate(block);
+
+ const cards = block.querySelectorAll('.article-card');
+ expect(cards).to.have.length(3);
+ // newest (highest lastModified = Article 2) comes first
+ expect(cards[0].querySelector('h3').textContent).to.equal('Article 2');
+ expect(cards[0].getAttribute('href')).to.equal('/learn/article-2');
+ // title suffix is stripped
+ expect(cards[0].textContent).to.not.contain('Continental Tire');
+ // each card has an optimized picture
+ expect(cards[0].querySelector('picture img')).to.exist;
+ });
+
+ it('caps the first render and reveals more on demand', async () => {
+ fetchStub = sinon.stub(window, 'fetch').resolves(new Response(JSON.stringify(indexResponse(15))));
+ const block = buildBlock();
+ await decorate(block);
+
+ expect(block.querySelectorAll('.article-card')).to.have.length(12);
+ const more = block.querySelector('.article-cards-more');
+ expect(more).to.exist;
+
+ more.click();
+ expect(block.querySelectorAll('.article-card')).to.have.length(15);
+ expect(block.querySelector('.article-cards-more')).to.not.exist;
+ });
+
+ it('skips rows that have no image', async () => {
+ const res = indexResponse(3);
+ res.data[1].image = '';
+ fetchStub = sinon.stub(window, 'fetch').resolves(new Response(JSON.stringify(res)));
+ const block = buildBlock();
+ await decorate(block);
+
+ expect(block.querySelectorAll('.article-card')).to.have.length(2);
+ });
+});