Component library
Back to catalog

Article card row

A blog band: eyebrow pill, centred display heading, three article cards (rounded image, date chip, display-face title, one link each) and a "View all" pill whose navy disc floods on hover.

blogarticlescardsnewsstatic

Open preview in a new tab

Use this component

Three ways to get Article card row into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Article card row from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/article-card-row/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Article card row"
Library page: https://astro.baysixmedia.com/components/article-card-row/
Files: 1 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/ArticleCardRow.astro =====
---
/**
 * Article card row
 *
 * An eyebrow pill and a centred display heading over three article cards —
 * a rounded image with the date on a white chip, then the title in the
 * display face — and a "View all" pill under them. Each card is one link.
 *
 * Static: no script. Hover and focus are CSS only.
 *
 * Theming reads `--lx-*` custom properties with fallbacks, so a page can
 * re-theme every logistics section from one ancestor and this one still
 * works on its own.
 */

export interface ArticleCard {
	title: string;
	href: string;
	/** Shown as written, uppercased by CSS, e.g. "Apr 14, 2026". */
	date: string;
	/** Machine-readable date for `<time>`, e.g. "2026-04-14". */
	datetime?: string;
	/**
	 * The card photo. With `src` it is an `<img>`; without, a labelled
	 * striped placeholder carrying `hint` and the size to shoot for.
	 * `alt` "" (the default) marks it decorative: the title names the link.
	 */
	image?: { src?: string; alt?: string; hint?: string };
}

interface Props {
	/** Small uppercase label in the pill. `""` hides the pill. */
	eyebrow?: string;
	heading?: string;
	/** The card titles take the next level down. */
	headingLevel?: "h2" | "h3";
	articles?: ArticleCard[];
	/** The pill under the cards. `null` hides it. */
	viewAll?: { label: string; href: string } | null;
	/** `""` when the page already loads Bebas Neue and Poppins. */
	fontStylesheet?: string;
	id?: string;
}

const {
	eyebrow = "Blog & article",
	heading = "",
	headingLevel = "h2",
	articles: articlesProp = [],
	viewAll = null,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
	id,
} = Astro.props;

/* A card needs a title and a link; anything less is dropped, never padded. */
const articles = articlesProp.filter((article) => article?.title && article.href);

const Heading = headingLevel;
const CardHeading = headingLevel === "h2" ? "h3" : "h4";
const uid = id ?? `acr-${Math.random().toString(36).slice(2, 9)}`;

const SAFE_SCHEME = /^(?:https?:|mailto:|[#/.?])/i;
const safeHref = (href: string) => (SAFE_SCHEME.test(href.trim()) ? href.trim() : "#");
---

{articles.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{articles.length > 0 && (
<section class="acr" id={id} aria-labelledby={heading ? `${uid}-heading` : undefined}>
	<div class="acr-inner">
		{(eyebrow || heading) && (
			<div class="acr-head">
				{eyebrow && <p class="acr-eyebrow">{eyebrow}</p>}
				{heading && <Heading class="acr-heading" id={`${uid}-heading`}>{heading}</Heading>}
			</div>
		)}

		<div class="acr-body">
			<ul class="acr-grid" role="list">
				{articles.map((article) => (
					<li class="acr-item">
						<a class="acr-card" href={safeHref(article.href)}>
							<span class="acr-media">
								{article.image?.src ? (
									<img class="acr-img" src={article.image.src} alt={article.image.alt ?? ""} loading="lazy" decoding="async" />
								) : (
									<span class="acr-img acr-ph" role="img" aria-label={`Image placeholder: ${article.image?.hint ?? "article photo"}`}>
										<span class="acr-ph-text">
											<span class="acr-ph-label">Image</span>
											{article.image?.hint && <span class="acr-ph-hint">{article.image.hint}</span>}
											{/* Measured card image: 380×300. */}
											<span class="acr-ph-spec">~5:4 · 1520 × 1200 px</span>
										</span>
									</span>
								)}
								{article.date && <time class="acr-date" datetime={article.datetime}>{article.date}</time>}
							</span>
							<CardHeading class="acr-title">{article.title}</CardHeading>
						</a>
					</li>
				))}
			</ul>

			{viewAll && viewAll.label && (
				<a class="acr-more" href={safeHref(viewAll.href)}>
					<span class="acr-more-label">{viewAll.label}</span>
					<span class="acr-more-icon" aria-hidden="true">
						<svg viewBox="0 0 24 24" focusable="false">
							<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
						</svg>
					</span>
				</a>
			)}
		</div>
	</div>
</section>
)}

<style>
	.acr {
		container-type: inline-size;
		box-sizing: border-box;
		background: #ffffff;
		color: var(--lx-navy, #0a2540);
		font-family: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
		--acr-ease: cubic-bezier(0.22, 1, 0.36, 1);
	}

	.acr *,
	.acr *::before,
	.acr *::after {
		box-sizing: border-box;
	}

	/* Phone first: 60/20 padding, 40px head gap, 32px between cards. */
	.acr-inner {
		max-width: calc(1200px + 60px);
		margin-inline: auto;
		padding: 60px 20px;
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 40px;
	}

	.acr-head {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 20px;
		text-align: center;
	}

	.acr-eyebrow {
		margin: 0;
		padding: 8px 16px;
		border-radius: 100px;
		box-shadow: inset 0 0 0 1px var(--lx-hairline, rgba(10, 37, 64, 0.14));
		font-size: 14px;
		font-weight: 500;
		line-height: 14px;
		text-transform: uppercase;
		color: var(--lx-navy, #0a2540);
	}

	.acr-heading {
		margin: 0;
		max-width: 25rem;
		font-family: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		font-size: 30px;
		font-weight: 400;
		line-height: 1;
		letter-spacing: -0.02em;
		color: var(--lx-navy, #0a2540);
		text-wrap: balance;
	}

	.acr-body {
		width: 100%;
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 32px;
	}

	.acr-grid {
		width: 100%;
		max-width: 540px;
		margin: 0;
		padding: 0;
		list-style: none;
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 32px 30px;
	}

	.acr-card {
		display: flex;
		flex-direction: column;
		gap: 20px;
		color: inherit;
		text-decoration: none;
		border-radius: 10px;
	}

	.acr-card:focus-visible {
		outline: 3px solid var(--lx-accent, #ff5f00);
		outline-offset: 6px;
	}

	.acr-media {
		position: relative;
		display: block;
		aspect-ratio: 380 / 300;
		overflow: hidden;
		border-radius: 10px;
		background: var(--lx-surface, #f6f6f6);
	}

	.acr-img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		transition: transform 600ms var(--acr-ease);
	}

	.acr-card:hover .acr-img,
	.acr-card:focus-visible .acr-img {
		transform: scale(1.05);
	}

	/* ---- photo placeholder (no `src`) ----------------------------------- */

	.acr-ph {
		display: grid;
		place-items: center;
		/* Clear the date chip in the top-left corner. */
		padding: 52px 16px 16px;
		text-align: center;
		border-radius: inherit;
		--acr-ph-ink: rgba(10, 37, 64, 0.62);
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: var(--acr-ph-ink);
		border: 1px dashed color-mix(in srgb, var(--acr-ph-ink) 35%, transparent);
	}

	.acr-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 6px;
		max-width: 28ch;
	}

	.acr-ph-label {
		font-size: 11px;
		font-weight: 500;
		line-height: 1.2;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.acr-ph-hint {
		font-size: 13px;
		font-weight: 500;
		line-height: 1.4;
	}

	.acr-ph-spec {
		font-size: 12px;
		font-weight: 400;
		line-height: 1.3;
	}

	.acr-date {
		position: absolute;
		top: 20px;
		left: 20px;
		padding: 6px 8px;
		border-radius: 4px;
		background: #ffffff;
		font-size: 14px;
		font-weight: 500;
		line-height: 14px;
		text-transform: uppercase;
		color: var(--lx-navy, #0a2540);
	}

	.acr-title {
		margin: 0;
		font-family: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		font-size: 20px;
		font-weight: 400;
		line-height: 1;
		letter-spacing: -0.02em;
		color: var(--lx-navy, #0a2540);
	}

	/* ---- View all pill --------------------------------------------------- */

	.acr-more {
		position: relative;
		isolation: isolate;
		overflow: hidden;
		display: inline-flex;
		align-items: center;
		/* 16, not the measured 24: there the 24px arrow sits inside a 40px
		   disc that overhangs it, which nets out to the same 135px pill. */
		gap: 16px;
		min-height: 50px;
		padding: 5px 5px 5px 19px;
		border-radius: 100px;
		background: #ffffff;
		box-shadow: inset 0 0 0 1px var(--lx-hairline, rgba(10, 37, 64, 0.14));
		color: var(--lx-navy, #0a2540);
		font-size: 14px;
		font-weight: 600;
		line-height: 14px;
		text-decoration: none;
	}

	.acr-more-label {
		transition: color 400ms var(--acr-ease);
	}

	.acr-more-icon {
		position: relative;
		display: grid;
		place-items: center;
		width: 40px;
		height: 40px;
		color: #ffffff;
	}

	/* The navy disc behind the arrow; on hover it floods the whole pill. */
	.acr-more-icon::before {
		content: "";
		position: absolute;
		inset: 0;
		z-index: -1;
		border-radius: 50%;
		background: var(--lx-navy, #0a2540);
		transition: transform 500ms var(--acr-ease);
	}

	.acr-more-icon svg {
		width: 24px;
		height: 24px;
	}

	.acr-more:hover .acr-more-icon::before,
	.acr-more:focus-visible .acr-more-icon::before {
		transform: scale(8);
	}

	.acr-more:hover .acr-more-label,
	.acr-more:focus-visible .acr-more-label {
		color: #ffffff;
	}

	.acr-more:focus-visible {
		outline: 3px solid var(--lx-accent, #ff5f00);
		outline-offset: 3px;
	}

	/* ---- tablet and up: three across ------------------------------------- */

	@container (min-width: 795px) {
		.acr-inner {
			padding: 90px 30px;
			gap: 56px;
		}

		.acr-heading {
			font-size: 40px;
		}

		.acr-body {
			gap: 48px;
		}

		.acr-grid {
			max-width: none;
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}

		.acr-card {
			gap: 24px;
		}

		.acr-title {
			font-size: 24px;
		}
	}

	@container (min-width: 1180px) {
		.acr-inner {
			padding: 120px 30px;
			gap: 70px;
		}

		.acr-heading {
			font-size: 48px;
		}

		.acr-body {
			gap: 60px;
		}

		.acr-grid {
			gap: 60px 30px;
		}

		.acr-card {
			gap: 30px;
		}

		.acr-title {
			font-size: 32px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.acr-img,
		.acr-more-label,
		.acr-more-icon::before {
			transition: none;
		}

		.acr-card:hover .acr-img,
		.acr-card:focus-visible .acr-img {
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.acr-more {
			border: 1px solid ButtonText;
		}

		.acr-date {
			border: 1px solid CanvasText;
		}
	}
</style>

===== END src/components/ArticleCardRow.astro =====

===== FILE (supporting): src/components/ArticleCardRow.md =====
# Article card row

An eyebrow pill and a centred display heading over three article cards
(a rounded image with the date on a white chip, then the title in the
display face) and a "View all" pill under them. Each card is one link.

Catalog page: `/components/article-card-row`

## Purpose

The "latest from the blog" band near the foot of a homepage. Three is the
design; two or four also lay out, but the desktop grid is three across.

## Files required

**`ArticleCardRow.astro`**: the whole component.

- No imports, no packages, no script.
- Component-scoped `<style>`, reading `--lx-*` custom properties with
  fallbacks.
- Loads Bebas Neue and Poppins through `fontStylesheet` (`""` to skip).

`ArticleCardRow.md`: this file.

No image files. Card images default to labelled placeholders; see
**Images**.

## Basic usage

```astro
---
import ArticleCardRow from "../components/ArticleCardRow.astro";
const posts = (await getCollection("blog")).slice(0, 3);
---

<ArticleCardRow
  heading="From the dispatch desk"
  articles={posts.map((p) => ({
    title: p.data.title,
    href: `/blog/${p.id}/`,
    date: p.data.date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }),
    datetime: p.data.date.toISOString().slice(0, 10),
    image: { src: p.data.cover },
  }))}
  viewAll={{ label: "View all", href: "/blog/" }}
/>
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Blog & article"` | The pill label. `""` hides it. |
| `heading` | `string?` | `""` | Centred display heading. `""` drops it. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | Card titles take the next level down. |
| `articles` | `ArticleCard[]?` | `[]` | See below. Articles without a `title` or `href` are skipped. No articles → the component renders nothing (not even the eyebrow or the font `<link>`). |
| `viewAll` | `{ label, href } \| null` | `null` | The pill under the cards. Pass it to show it, e.g. `{ label: "View all", href: "/blog/" }`. |
| `fontStylesheet` | `string?` | Google Fonts URL | `""` turns the `<link>` off. |
| `id` | `string?` | none | Root id. Also seeds the heading id. |

```ts
interface ArticleCard {
  title: string;
  href: string;
  date: string;       // shown as written, uppercased by CSS; "" drops the chip
  datetime?: string;  // for <time datetime>
  image?: {
    src?: string;     // unset = placeholder
    alt?: string;     // "" (default) = decorative, the title names the link
    hint?: string;    // what the photo should show, printed on the placeholder
  };
}
```

Hrefs are filtered to `http(s):`, `mailto:`, and relative/hash URLs; anything
else (e.g. `javascript:`) becomes `#`.

## Images

Each card has one slot, `{ src?, alt?, hint? }`. Without `src` it renders a
striped placeholder in the rounded image box (under the date chip), labelled
`IMAGE` with the hint and the spec, and `role="img"` with
`aria-label="Image placeholder: …"`.

| Slot | Default hint | Ratio · size | Measured box |
| :--- | :--- | :--- | :--- |
| Each card | none (the placeholder reads IMAGE plus the spec) | ~5:4 · 1520 × 1200 px | 380 × 300 |

The catalog demo's heading, three articles, their hints and the "View all"
pill are registry demo props, not component defaults.

## Behaviour

| Input | What happens |
| :--- | :--- |
| Hover a card | The image eases up to `scale(1.05)` over 600ms inside its rounded frame. |
| Focus a card | Same zoom, plus a 3px orange outline around the whole card, 6px out. |
| Hover / focus "View all" | The navy disc behind the arrow floods the pill and the label turns white. |

## Accessibility

- Section labelled by its heading; cards are a `<ul>` of `<li>`s.
- **One link per card**, wrapping image, date and title, so there is one tab
  stop and one accessible name. The image is `alt=""` by default so the name
  is the date plus the title, not a photo description first.
- Dates are `<time datetime>`.
- While a placeholder shows, its "Image placeholder: …" label becomes part of
  the card link's name. That goes away once you pass a real `src` (alt "").
- The pill is ≥ 50px tall; cards are far above 44px.
- Forced colours: the pill and the date chip get real borders.

## Responsive

Container queries against the section.

| Section width | Layout |
| :--- | :--- |
| ≥ 1180px | Three 380px cards, 30px column gap, 60px to the pill, 32px titles, 120px block padding. |
| 795–1179px | Three across, fluid, 24px titles. |
| < 795px | One column (capped at 540px, centred), 32px gaps, 20px titles, 60/20 padding. |

Images keep the measured 380:300 ratio at every width.

## Reduced motion

The image zoom and the pill flood lose their transitions; the zoom is dropped
entirely. The pill still changes colour on hover, instantly.

## Without JavaScript

Identical. It ships no script.

## Approximations

- **Card hover.** The dump shows a `filter: blur(0px)` wrapper on each image,
  which suggests Framer animates *something* on hover, but no hover state was
  captured. The 1.05 image zoom is a stand-in, not a measurement. The title
  colour is left unchanged.
- **"View all" hover.** The DOM has a 40px navy "Icon Bg" layered under the
  label, the usual Framer fill-on-hover button. The flood is inferred from
  that structure; its timing is a guess.
- Pill spacing: the reference has 13/13/13/19 padding with a 24px icon inside
  an overhanging 40px disc. Rebuilt as 5/5/5/19 padding with a 16px gap and a
  40px disc: same 135 × 50 box, simpler markup.
- The pill's hairline edge and the eyebrow's are visible in the screenshots
  but not in computed styles; drawn as a 1px inset `rgba(10,37,64,0.14)`.
- Tablet has no capture; three-across with smaller titles is interpolated.
- No photos ship: every card image is a placeholder until you pass `src`.

===== END src/components/ArticleCardRow.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.

--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Article card row” from my Astro Component Library:
https://astro.baysixmedia.com/components/article-card-row/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Not applicable — ships no script
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixture

src/components/library/ArticleCardRow.astro

one fileNo imports, no companion files, no packages. Copy ArticleCardRow.astro into any Astro project's components folder and render it.

---
/**
 * Article card row
 *
 * An eyebrow pill and a centred display heading over three article cards —
 * a rounded image with the date on a white chip, then the title in the
 * display face — and a "View all" pill under them. Each card is one link.
 *
 * Static: no script. Hover and focus are CSS only.
 *
 * Theming reads `--lx-*` custom properties with fallbacks, so a page can
 * re-theme every logistics section from one ancestor and this one still
 * works on its own.
 */

export interface ArticleCard {
	title: string;
	href: string;
	/** Shown as written, uppercased by CSS, e.g. "Apr 14, 2026". */
	date: string;
	/** Machine-readable date for `<time>`, e.g. "2026-04-14". */
	datetime?: string;
	/**
	 * The card photo. With `src` it is an `<img>`; without, a labelled
	 * striped placeholder carrying `hint` and the size to shoot for.
	 * `alt` "" (the default) marks it decorative: the title names the link.
	 */
	image?: { src?: string; alt?: string; hint?: string };
}

interface Props {
	/** Small uppercase label in the pill. `""` hides the pill. */
	eyebrow?: string;
	heading?: string;
	/** The card titles take the next level down. */
	headingLevel?: "h2" | "h3";
	articles?: ArticleCard[];
	/** The pill under the cards. `null` hides it. */
	viewAll?: { label: string; href: string } | null;
	/** `""` when the page already loads Bebas Neue and Poppins. */
	fontStylesheet?: string;
	id?: string;
}

const {
	eyebrow = "Blog & article",
	heading = "",
	headingLevel = "h2",
	articles: articlesProp = [],
	viewAll = null,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
	id,
} = Astro.props;

/* A card needs a title and a link; anything less is dropped, never padded. */
const articles = articlesProp.filter((article) => article?.title && article.href);

const Heading = headingLevel;
const CardHeading = headingLevel === "h2" ? "h3" : "h4";
const uid = id ?? `acr-${Math.random().toString(36).slice(2, 9)}`;

const SAFE_SCHEME = /^(?:https?:|mailto:|[#/.?])/i;
const safeHref = (href: string) => (SAFE_SCHEME.test(href.trim()) ? href.trim() : "#");
---

{articles.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{articles.length > 0 && (
<section class="acr" id={id} aria-labelledby={heading ? `${uid}-heading` : undefined}>
	<div class="acr-inner">
		{(eyebrow || heading) && (
			<div class="acr-head">
				{eyebrow && <p class="acr-eyebrow">{eyebrow}</p>}
				{heading && <Heading class="acr-heading" id={`${uid}-heading`}>{heading}</Heading>}
			</div>
		)}

		<div class="acr-body">
			<ul class="acr-grid" role="list">
				{articles.map((article) => (
					<li class="acr-item">
						<a class="acr-card" href={safeHref(article.href)}>
							<span class="acr-media">
								{article.image?.src ? (
									<img class="acr-img" src={article.image.src} alt={article.image.alt ?? ""} loading="lazy" decoding="async" />
								) : (
									<span class="acr-img acr-ph" role="img" aria-label={`Image placeholder: ${article.image?.hint ?? "article photo"}`}>
										<span class="acr-ph-text">
											<span class="acr-ph-label">Image</span>
											{article.image?.hint && <span class="acr-ph-hint">{article.image.hint}</span>}
											{/* Measured card image: 380×300. */}
											<span class="acr-ph-spec">~5:4 · 1520 × 1200 px</span>
										</span>
									</span>
								)}
								{article.date && <time class="acr-date" datetime={article.datetime}>{article.date}</time>}
							</span>
							<CardHeading class="acr-title">{article.title}</CardHeading>
						</a>
					</li>
				))}
			</ul>

			{viewAll && viewAll.label && (
				<a class="acr-more" href={safeHref(viewAll.href)}>
					<span class="acr-more-label">{viewAll.label}</span>
					<span class="acr-more-icon" aria-hidden="true">
						<svg viewBox="0 0 24 24" focusable="false">
							<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
						</svg>
					</span>
				</a>
			)}
		</div>
	</div>
</section>
)}

<style>
	.acr {
		container-type: inline-size;
		box-sizing: border-box;
		background: #ffffff;
		color: var(--lx-navy, #0a2540);
		font-family: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
		--acr-ease: cubic-bezier(0.22, 1, 0.36, 1);
	}

	.acr *,
	.acr *::before,
	.acr *::after {
		box-sizing: border-box;
	}

	/* Phone first: 60/20 padding, 40px head gap, 32px between cards. */
	.acr-inner {
		max-width: calc(1200px + 60px);
		margin-inline: auto;
		padding: 60px 20px;
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 40px;
	}

	.acr-head {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 20px;
		text-align: center;
	}

	.acr-eyebrow {
		margin: 0;
		padding: 8px 16px;
		border-radius: 100px;
		box-shadow: inset 0 0 0 1px var(--lx-hairline, rgba(10, 37, 64, 0.14));
		font-size: 14px;
		font-weight: 500;
		line-height: 14px;
		text-transform: uppercase;
		color: var(--lx-navy, #0a2540);
	}

	.acr-heading {
		margin: 0;
		max-width: 25rem;
		font-family: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		font-size: 30px;
		font-weight: 400;
		line-height: 1;
		letter-spacing: -0.02em;
		color: var(--lx-navy, #0a2540);
		text-wrap: balance;
	}

	.acr-body {
		width: 100%;
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 32px;
	}

	.acr-grid {
		width: 100%;
		max-width: 540px;
		margin: 0;
		padding: 0;
		list-style: none;
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 32px 30px;
	}

	.acr-card {
		display: flex;
		flex-direction: column;
		gap: 20px;
		color: inherit;
		text-decoration: none;
		border-radius: 10px;
	}

	.acr-card:focus-visible {
		outline: 3px solid var(--lx-accent, #ff5f00);
		outline-offset: 6px;
	}

	.acr-media {
		position: relative;
		display: block;
		aspect-ratio: 380 / 300;
		overflow: hidden;
		border-radius: 10px;
		background: var(--lx-surface, #f6f6f6);
	}

	.acr-img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		transition: transform 600ms var(--acr-ease);
	}

	.acr-card:hover .acr-img,
	.acr-card:focus-visible .acr-img {
		transform: scale(1.05);
	}

	/* ---- photo placeholder (no `src`) ----------------------------------- */

	.acr-ph {
		display: grid;
		place-items: center;
		/* Clear the date chip in the top-left corner. */
		padding: 52px 16px 16px;
		text-align: center;
		border-radius: inherit;
		--acr-ph-ink: rgba(10, 37, 64, 0.62);
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: var(--acr-ph-ink);
		border: 1px dashed color-mix(in srgb, var(--acr-ph-ink) 35%, transparent);
	}

	.acr-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 6px;
		max-width: 28ch;
	}

	.acr-ph-label {
		font-size: 11px;
		font-weight: 500;
		line-height: 1.2;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.acr-ph-hint {
		font-size: 13px;
		font-weight: 500;
		line-height: 1.4;
	}

	.acr-ph-spec {
		font-size: 12px;
		font-weight: 400;
		line-height: 1.3;
	}

	.acr-date {
		position: absolute;
		top: 20px;
		left: 20px;
		padding: 6px 8px;
		border-radius: 4px;
		background: #ffffff;
		font-size: 14px;
		font-weight: 500;
		line-height: 14px;
		text-transform: uppercase;
		color: var(--lx-navy, #0a2540);
	}

	.acr-title {
		margin: 0;
		font-family: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		font-size: 20px;
		font-weight: 400;
		line-height: 1;
		letter-spacing: -0.02em;
		color: var(--lx-navy, #0a2540);
	}

	/* ---- View all pill --------------------------------------------------- */

	.acr-more {
		position: relative;
		isolation: isolate;
		overflow: hidden;
		display: inline-flex;
		align-items: center;
		/* 16, not the measured 24: there the 24px arrow sits inside a 40px
		   disc that overhangs it, which nets out to the same 135px pill. */
		gap: 16px;
		min-height: 50px;
		padding: 5px 5px 5px 19px;
		border-radius: 100px;
		background: #ffffff;
		box-shadow: inset 0 0 0 1px var(--lx-hairline, rgba(10, 37, 64, 0.14));
		color: var(--lx-navy, #0a2540);
		font-size: 14px;
		font-weight: 600;
		line-height: 14px;
		text-decoration: none;
	}

	.acr-more-label {
		transition: color 400ms var(--acr-ease);
	}

	.acr-more-icon {
		position: relative;
		display: grid;
		place-items: center;
		width: 40px;
		height: 40px;
		color: #ffffff;
	}

	/* The navy disc behind the arrow; on hover it floods the whole pill. */
	.acr-more-icon::before {
		content: "";
		position: absolute;
		inset: 0;
		z-index: -1;
		border-radius: 50%;
		background: var(--lx-navy, #0a2540);
		transition: transform 500ms var(--acr-ease);
	}

	.acr-more-icon svg {
		width: 24px;
		height: 24px;
	}

	.acr-more:hover .acr-more-icon::before,
	.acr-more:focus-visible .acr-more-icon::before {
		transform: scale(8);
	}

	.acr-more:hover .acr-more-label,
	.acr-more:focus-visible .acr-more-label {
		color: #ffffff;
	}

	.acr-more:focus-visible {
		outline: 3px solid var(--lx-accent, #ff5f00);
		outline-offset: 3px;
	}

	/* ---- tablet and up: three across ------------------------------------- */

	@container (min-width: 795px) {
		.acr-inner {
			padding: 90px 30px;
			gap: 56px;
		}

		.acr-heading {
			font-size: 40px;
		}

		.acr-body {
			gap: 48px;
		}

		.acr-grid {
			max-width: none;
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}

		.acr-card {
			gap: 24px;
		}

		.acr-title {
			font-size: 24px;
		}
	}

	@container (min-width: 1180px) {
		.acr-inner {
			padding: 120px 30px;
			gap: 70px;
		}

		.acr-heading {
			font-size: 48px;
		}

		.acr-body {
			gap: 60px;
		}

		.acr-grid {
			gap: 60px 30px;
		}

		.acr-card {
			gap: 30px;
		}

		.acr-title {
			font-size: 32px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.acr-img,
		.acr-more-label,
		.acr-more-icon::before {
			transition: none;
		}

		.acr-card:hover .acr-img,
		.acr-card:focus-visible .acr-img {
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.acr-more {
			border: 1px solid ButtonText;
		}

		.acr-date {
			border: 1px solid CanvasText;
		}
	}
</style>