The 4 files 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: "Logistics blog cards"
Library page: https://astro.baysixmedia.com/components/logistics-blog-cards/
Files: 4 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/LogisticsBlogCards.astro =====
---
/**
* LogisticsBlogCards
*
* A light grey section with a faint drawn herringbone pattern: centred
* eyebrow and heading, then three white post cards. Each card is one link:
* a rounded photo, a category tag, the title, and an author row (initials
* or photo, name, date) with a pink arrow tile at the right.
*
* Hover, as measured: the photo scales to 1.1 inside its frame and the
* arrow tile turns from #ffe0e0 to orange. The title block and the cards
* rise 40px into place, staggered, when the section scrolls in.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface BlogCardPost {
title: string;
href: string;
tag?: string;
/** Photo. `src: ""` renders the labelled placeholder instead. */
image?: { src?: string; alt?: string };
author?: {
name: string;
/** Optional line before the date, e.g. "Founder". */
role?: string;
/** Small round photo. Without one the author's initials are drawn. */
avatar?: { src: string; alt?: string };
};
/** ISO date, e.g. "2026-08-14". Rendered in a <time>. */
date?: string;
}
export interface LogisticsBlogCardsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
posts?: BlogCardPost[];
/** Locale for the visible date. */
locale?: string;
/** Draw the faint herringbone pattern behind the section. */
pattern?: boolean;
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends LogisticsBlogCardsProps {}
const {
eyebrow = "Blog & news",
heading = "",
headingLevel = "h2",
posts: postsProp = [],
locale = "en-US",
pattern = true,
background = "#ecf0f1",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
/* A post needs a title and a link; anything less is dropped, never padded.
An author with no name is treated as no author. */
const posts = postsProp
.filter((post) => post?.title && post.href)
.map((post) => (post.author && !post.author.name ? { ...post, author: undefined } : post));
const Heading = headingLevel;
const CardHeading = headingLevel === "h2" ? "h3" : "h4";
const uid = id ?? `lbc-${Math.random().toString(36).slice(2, 9)}`;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]!.toUpperCase())
.join("");
function formatDate(iso: string) {
const d = new Date(`${iso}T12:00:00Z`);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(d);
}
/* Measured slot: 388 × 248 at desktop; the files are 776 × 496. */
const IMAGE_SPEC = "~1.56:1 · 776 × 496 px";
---
{posts.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{posts.length > 0 && (
<section
class:list={["lbc", className]}
id={id}
style={`--lbc-bg: ${background};`}
data-pattern={pattern ? "" : undefined}
data-lbc-root
>
<div class="lbc-inner">
{(eyebrow || heading) && (
<div class="lbc-title" data-lbc-appear>
{
eyebrow && (
<p class="lbc-eyebrow">
<span class="lbc-eyebrow-line" aria-hidden="true" />
{eyebrow}
<span class="lbc-eyebrow-line" aria-hidden="true" />
</p>
)
}
{heading && <Heading class="lbc-heading">{heading}</Heading>}
</div>
)}
<ul class="lbc-grid" role="list">
{
posts.map((post, i) => {
const image = { src: "", alt: "", ...(post.image ?? {}) };
const titleId = `${uid}-title-${i + 1}`;
const metaId = `${uid}-meta-${i + 1}`;
return (
<li class="lbc-item" data-lbc-appear style={`--lbc-i: ${i + 1};`}>
<a
class="lbc-card"
href={safeHref(post.href)}
aria-labelledby={titleId}
aria-describedby={post.author || post.date ? metaId : undefined}
>
<div class="lbc-media">
{image.src ? (
<img
src={image.src}
alt={image.alt}
width="776"
height="496"
loading="lazy"
decoding="async"
/>
) : (
<div class="lbc-ph" role="img" aria-label={`Image placeholder: ${post.title}`}>
<div class="lbc-ph-text" aria-hidden="true">
<span class="lbc-ph-label">Image</span>
<span class="lbc-ph-hint">Post photo, landscape</span>
<span class="lbc-ph-spec">{IMAGE_SPEC}</span>
</div>
</div>
)}
</div>
<div class="lbc-body">
{post.tag && <span class="lbc-tag">{post.tag}</span>}
<CardHeading class="lbc-card-title" id={titleId}>
{post.title}
</CardHeading>
</div>
<div class="lbc-foot">
{(post.author || post.date) && (
<div class="lbc-meta" id={metaId}>
{post.author &&
(post.author.avatar?.src ? (
<img
class="lbc-avatar"
src={post.author.avatar.src}
alt={post.author.avatar.alt ?? ""}
width="44"
height="44"
loading="lazy"
decoding="async"
/>
) : (
<span class="lbc-avatar lbc-avatar--initials" aria-hidden="true">
{initials(post.author.name)}
</span>
))}
<span class="lbc-meta-text">
{post.author && <span class="lbc-author">{post.author.name}</span>}
{(post.author?.role || post.date) && (
<span class="lbc-date">
{post.author?.role && <span>{post.author.role}{post.date && " · "}</span>}
{post.date && <time datetime={post.date}>{formatDate(post.date)}</time>}
</span>
)}
</span>
</div>
)}
<span class="lbc-tile" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" focusable="false">
<path d="M4 12h15M13 6l6 6-6 6" />
</svg>
</span>
</div>
</a>
</li>
);
})
}
</ul>
</div>
</section>
)}
<style>
.lbc {
--lbc-navy: var(--tg-navy, #020e28);
--lbc-orange: var(--tg-orange, #fd5523);
--lbc-muted: var(--tg-muted, #61697d);
--lbc-title-ink: #0e2d3a;
--lbc-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--lbc-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
--lbc-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
box-sizing: border-box;
padding: 60px 16px;
background-color: var(--lbc-bg);
color: var(--lbc-navy);
font-family: var(--lbc-body);
overflow: hidden;
}
/* A faint herringbone drawn in SVG: pale zig-zag strokes, lighter and
darker, on the grey surface. Replaces the reference's texture image. */
.lbc[data-pattern] {
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='48' viewBox='0 0 96 48'%3E%3Cg fill='none' stroke-width='1.2'%3E%3Cpath d='M0 24 24 0 48 24 72 0 96 24M0 48 24 24 48 48 72 24 96 48' stroke='%23ffffff' stroke-opacity='.6'/%3E%3Cpath d='M0 36 24 12 48 36 72 12 96 36' stroke='%23020e28' stroke-opacity='.035'/%3E%3C/g%3E%3C/svg%3E");
background-size: 140px 70px;
}
.lbc *,
.lbc *::before,
.lbc *::after {
box-sizing: border-box;
}
.lbc-inner {
position: relative;
display: flex;
flex-direction: column;
gap: 32px;
max-width: 1224px;
margin: 0 auto;
}
.lbc-title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.lbc .lbc-eyebrow {
display: flex;
align-items: center;
gap: 12px;
margin: 0;
font: 600 16px/1 var(--lbc-display);
text-transform: uppercase;
color: var(--lbc-orange);
}
.lbc-eyebrow-line {
display: none;
width: 40px;
height: 1px;
background: currentColor;
}
.lbc-heading {
margin: 0;
font: 600 30px/1.2 var(--lbc-display);
text-transform: capitalize;
color: var(--lbc-navy);
}
.lbc .lbc-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
margin: 0;
padding: 0;
list-style: none;
}
.lbc-item {
display: flex;
min-width: 0;
}
.lbc-card {
display: flex;
flex-direction: column;
width: 100%;
border-radius: 20px;
background: #fff;
color: inherit;
text-decoration: none;
}
.lbc-card:focus-visible {
outline: 3px solid var(--lbc-orange);
outline-offset: 4px;
}
.lbc-media {
position: relative;
overflow: hidden;
aspect-ratio: 388 / 248;
border-radius: 20px;
background: #dfe4e6;
}
.lbc-media img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease-out;
}
.lbc-body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 24px 18px 20px;
flex: 1;
}
.lbc-tag {
display: inline-flex;
padding: 2px 16px;
border: 1px solid #ffd9cc;
border-radius: 100px;
font: 400 16px/21.04px var(--lbc-body);
color: var(--lbc-orange);
}
.lbc-card-title {
margin: 0;
font: 600 20px/1.42 var(--lbc-display);
color: var(--lbc-title-ink);
}
.lbc-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 20px;
}
.lbc-meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.lbc-avatar {
flex: none;
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
}
.lbc-avatar--initials {
display: grid;
place-items: center;
background: var(--lbc-navy);
color: #fff;
font: 600 15px/1 var(--lbc-display);
letter-spacing: 0.02em;
}
.lbc-meta-text {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.lbc-author {
font: 400 16px/21.04px var(--lbc-body);
color: var(--lbc-navy);
}
.lbc-date {
font: 400 14px/1 var(--lbc-body);
color: var(--lbc-muted);
}
/* The arrow tile. Hover: #ffe0e0 → orange, as measured. */
.lbc-tile {
display: grid;
place-items: center;
flex: none;
width: 46px;
height: 48px;
border-radius: 10px;
background: #ffe0e0;
color: var(--lbc-orange);
transition:
background-color 0.3s ease-out,
color 0.3s ease-out;
}
.lbc-tile svg {
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.lbc-card:hover .lbc-media img,
.lbc-card:focus-visible .lbc-media img {
transform: scale(1.1);
}
.lbc-card:hover .lbc-tile,
.lbc-card:focus-visible .lbc-tile {
background: var(--lbc-orange);
color: #fff;
}
/* placeholder for `src: ""` */
.lbc-ph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
text-align: center;
border: 1px dashed rgb(2 14 40 / 0.22);
border-radius: inherit;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgb(2 14 40 / 0.62);
}
.lbc-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.lbc-ph-label {
font: 500 12px/1 var(--lbc-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.lbc-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--lbc-body);
}
.lbc-ph-spec {
font: 400 12px/1.2 var(--lbc-body);
}
/* ---- tablet: centred title, two columns ---- */
@media (min-width: 810px) {
.lbc {
padding: 100px 30px;
}
.lbc-inner {
gap: 60px;
}
.lbc-title {
align-items: center;
text-align: center;
}
.lbc .lbc-eyebrow {
font-size: 18px;
line-height: 12.6px;
}
.lbc-eyebrow-line {
display: block;
}
.lbc-heading {
font-size: 40px;
}
.lbc .lbc-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 30px;
}
.lbc-body {
padding: 30px 30px 20px;
}
.lbc-foot {
padding: 0 30px 30px;
}
.lbc-card-title {
font-size: 24px;
line-height: 34px;
}
}
/* ---- desktop: three 388px cards ---- */
@media (min-width: 1200px) {
.lbc {
padding: 120px 20px;
}
.lbc-heading {
font-size: 48px;
line-height: 57.6px;
}
.lbc .lbc-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* ---- appear on scroll: start pose only once the script is ready ---- */
[data-lbc-appear] {
transition:
opacity 0.8s var(--lbc-ease),
transform 0.8s var(--lbc-ease);
transition-delay: calc(var(--lbc-i, 0) * 0.1s);
}
.lbc[data-lbc-ready]:not([data-in]) [data-lbc-appear] {
opacity: 0;
transform: translateY(40px);
}
@media (prefers-reduced-motion: reduce) {
[data-lbc-appear] {
transition: none;
}
.lbc[data-lbc-ready]:not([data-in]) [data-lbc-appear] {
opacity: 1;
transform: none;
}
.lbc-card:hover .lbc-media img,
.lbc-card:focus-visible .lbc-media img {
transform: none;
}
}
@media (forced-colors: active) {
.lbc-card {
border: 1px solid CanvasText;
}
}
</style>
<script>
/*
* Appear-on-scroll for LogisticsBlogCards. One IntersectionObserver per
* instance, scoped to its root, torn down on astro:before-swap. Under
* reduced motion (or with no IntersectionObserver) it marks the root in
* straight away, so nothing ever starts hidden.
*/
function initLogisticsBlogCards(root: HTMLElement) {
if (root.dataset.lbcReady === "") return;
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
root.dataset.lbcReady = "";
if (reduced || !("IntersectionObserver" in window)) {
root.setAttribute("data-in", "");
} else {
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.setAttribute("data-in", "");
observer.disconnect();
}
},
{ threshold: 0.15 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer.disconnect());
}
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
root.removeAttribute("data-in");
delete root.dataset.lbcReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-lbc-root]").forEach(initLogisticsBlogCards);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/LogisticsBlogCards.astro =====
===== FILE (supporting): src/components/LogisticsBlogCards.md =====
# Logistics blog cards
Three post cards on a light grey band with a faint drawn herringbone:
centred eyebrow and heading, then for each post a rounded photo, a category
tag, the title, and an author row (initials or photo, name, date) with a
pink arrow tile on the right. Each card is one link.
Catalog page: `/components/logistics-blog-cards`
## Files required
**`LogisticsBlogCards.astro`** — one file, the whole component. No imports, no
packages. The pattern is an inline SVG data URI. One small inline `<script>`
for the appear-on-scroll.
`LogisticsBlogCards.md` — this file. Documentation, not required to run.
`public/logistics-blog-cards/post-1..3.webp` — the catalog demo's three
photos (776 × 496), passed as registry demo props. The component has no
default photos; pass your own `image` per post.
## Basic usage
```astro
---
import LogisticsBlogCards from "../components/LogisticsBlogCards.astro";
---
<LogisticsBlogCards
posts={[
{
title: "How We Cut Port Dwell Time",
href: "/blog/port-dwell-time",
tag: "Ocean Freight",
image: { src: "/img/port.webp", alt: "Containers stacked at a port" },
author: { name: "Priya Natarajan" },
date: "2026-08-14",
},
]}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Blog & news"` | Orange uppercase label, lines either side from 810px. `""` drops it. |
| `heading` | `string?` | `""` | Section heading. `""` drops it. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | Card titles are one level below. |
| `posts` | `BlogCardPost[]?` | `[]` | See below. Posts without a `title` or `href` are skipped; an `author` with no `name` is ignored. No posts → the component renders nothing. |
| `locale` | `string?` | `"en-US"` | Formats the visible date. |
| `pattern` | `boolean?` | `true` | The drawn herringbone. |
| `background` | `string?` | `#ecf0f1` | Band colour under the pattern. |
| `fontStylesheet` | `string?` | Outfit + DM Sans (Google Fonts) | `""` to skip loading. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Root id; also seeds the title/meta ids. |
```ts
interface BlogCardPost {
title: string;
href: string;
tag?: string;
image?: { src?: string; alt?: string }; // src: "" → labelled placeholder
author?: { name: string; role?: string; avatar?: { src: string; alt?: string } };
date?: string; // ISO, rendered in <time>
}
```
## Behaviour
- **Hover and keyboard focus** (as measured): the photo scales to 1.1 inside
its 20px-radius frame (0.4s ease-out); the arrow tile goes `#ffe0e0` →
orange `#fd5523` and its arrow orange → white (0.3s).
- **Appear on scroll** (once, at 15% in view): the title block and each card
rise from `translateY(40px)`, opacity 0 → 1, 0.8s ease-out, cards staggered
0.1s.
- **Reduced motion:** no appear, no photo zoom; the tile colour change stays.
## Layout
- **≥ 1200px:** three 388px columns, 30px gap, in a 1224px container; photo
388 × 248; body padding 30px; title Outfit 24/34 in `#0e2d3a`; author row
44px avatar + name (16px) + date (14px muted), 46 × 48 tile, 10px radius.
- **810–1199px:** two columns.
- **< 810px:** one column, left-aligned heading without the eyebrow lines,
20px titles, 18px card padding.
## Accessibility
- Each card is one `<a>` wrapping everything. Its name is the title only
(`aria-labelledby`), and the author and date are its description
(`aria-describedby`), so it doesn't read "Ocean Freight How We Cut…".
- Titles are real headings inside the link; the tag, avatar and tile are
plain text or decorative.
- Visible focus: a 3px orange outline, 4px offset; the hover effects also
apply on `:focus-visible`.
- Dates use `<time datetime="YYYY-MM-DD">`.
## Images and credits
| File | Slot | Size | Credit |
| :--- | :--- | :--- | :--- |
| `public/logistics-blog-cards/post-1.webp` | Post 1 photo | 776 × 496 | Photo: Unsplash 9cCeS9Sg6nU, Unsplash License |
| `public/logistics-blog-cards/post-2.webp` | Post 2 photo | 776 × 496 | Photo: Unsplash k2xIbQC_x6A, Unsplash License |
| `public/logistics-blog-cards/post-3.webp` | Post 3 photo | 776 × 496 | Photo: Unsplash hGxrb0I5vho, Unsplash License |
The catalog demo's heading, post titles, tags, authors and dates are
invented for the fictional Northpass Logistics and live in the registry's
demo props, not in the component.
## Approximations
- **Author row.** The reference shows a round author photo with a job title
under the name. No author photos were supplied, so the demo draws
initials on a navy disc and shows the date (as the brief asked) in the
second line; `author.role` adds the job title before it and
`author.avatar` restores a photo.
- **Tile arrow on hover** turns white; the dump records only the tile
background change, and orange-on-orange would vanish.
- **Tag border** `1px #ffd9cc` is read off the screenshot.
- **Pattern** is an original SVG herringbone (white zig-zags at 60% plus a
faint navy line) drawn to resemble the reference's texture image, not a
copy of it.
- **Appear timing** (0.8s, 0.1s stagger) is not measured; the 40px offset is.
- **Tablet (810–1199)** was not captured; two columns is a judgement call.
===== END src/components/LogisticsBlogCards.md =====
===== FILE (required): public/logistics-blog-cards/post-1.webp =====
[binary asset — download https://astro.baysixmedia.com/logistics-blog-cards/post-1.webp and save it to public/logistics-blog-cards/post-1.webp]
===== FILE (required): public/logistics-blog-cards/post-2.webp =====
[binary asset — download https://astro.baysixmedia.com/logistics-blog-cards/post-2.webp and save it to public/logistics-blog-cards/post-2.webp]
===== FILE (required): public/logistics-blog-cards/post-3.webp =====
[binary asset — download https://astro.baysixmedia.com/logistics-blog-cards/post-3.webp and save it to public/logistics-blog-cards/post-3.webp]
--- 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: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- 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.