The 5 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: "Chamfer blog cards"
Library page: https://astro.baysixmedia.com/components/chamfer-blog-cards/
Files: 5 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/ChamferBlogCards.astro =====
---
/**
* ChamferBlogCards
*
* A centred "latest articles" section: outlined eyebrow and a big uppercase
* heading over two wide white cards. Each card is one link: a landscape photo
* with a chamfered top-left corner, an orange date, a title, a hairline and
* the author's avatar and name.
*
* Heading words rise in and the cards lift in, staggered, the first time they
* scroll into view.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface ChamferPost {
title: string;
href: string;
/** Display date, e.g. "May 2, 2026". */
date?: string;
/** Machine date for <time>, e.g. "2026-05-02". */
datetime?: string;
author?: { name: string; avatar?: string };
/** `src: ""` renders a labelled placeholder. */
image?: { src?: string; alt?: string };
}
export interface ChamferBlogCardsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
posts?: ChamferPost[];
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ChamferBlogCardsProps {}
const {
eyebrow = "Latest Blog",
heading = "",
headingLevel = "h2",
posts: postsProp = [],
background = "#f0f8fa",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&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((p) => p?.title && p.href)
.map((p) => (p.author && !p.author.name ? { ...p, author: undefined } : p));
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Title = headingLevel === "h2" ? "h3" : "h4";
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
---
{posts.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{posts.length > 0 && (
<section class:list={["cbc", className]} id={id} style={`--cbc-band:${background}`} data-cbc-root>
<div class="cbc-inner">
{(eyebrow || words.length > 0) && (
<div class="cbc-head">
{eyebrow && <p class="cbc-eyebrow">{eyebrow}</p>}
{words.length > 0 && (
<Heading class="cbc-heading" data-cbc-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["cbc-w", { "cbc-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
</div>
)}
<ul class="cbc-grid">
{
posts.map((p, i) => {
const img = p.image ?? { src: "" };
return (
<li class="cbc-card" data-cbc-reveal="up" style={`--d:${i * 0.12}s`}>
<div class="cbc-media">
{img.src ? (
<img src={img.src} alt={img.alt ?? ""} width="1210" height="846" loading="lazy" decoding="async" />
) : (
<div class="cbc-ph" role="img" aria-label={`Image placeholder: ${p.title}`}>
<span>Image · 1210 × 846</span>
</div>
)}
</div>
<div class="cbc-copy">
{p.date && (
<time class="cbc-date" datetime={p.datetime}>
{p.date}
</time>
)}
<Title class="cbc-title">
<a href={safeHref(p.href)}>{p.title}</a>
</Title>
{p.author && (
<p class="cbc-author">
{p.author.avatar && (
<img src={p.author.avatar} alt="" width="36" height="36" loading="lazy" decoding="async" />
)}
<span>{p.author.name}</span>
</p>
)}
</div>
</li>
);
})
}
</ul>
</div>
</section>
)}
<style>
.cbc {
--cbc-navy: var(--tw-navy, #0b4654);
--cbc-accent: var(--tw-accent, #ff6d45);
--cbc-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--cbc-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--cbc-ease: cubic-bezier(0.44, 0, 0.56, 1);
--cbc-cut: 44px;
background: var(--cbc-band);
color: var(--cbc-navy);
font-family: var(--cbc-body);
}
.cbc *,
.cbc *::before,
.cbc *::after {
box-sizing: border-box;
}
.cbc-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.cbc-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
text-align: center;
}
.cbc-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--cbc-accent);
border-radius: 999px;
color: var(--cbc-accent);
font: 500 15px/24px var(--cbc-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.cbc-heading {
max-width: 700px;
margin: 0;
font: 700 28px/1.2 var(--cbc-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.cbc-w {
display: inline-block;
}
.cbc-accent {
color: var(--cbc-accent);
}
.cbc-grid {
display: grid;
gap: 20px;
margin: 40px 0 0;
padding: 0;
list-style: none;
}
.cbc-card {
position: relative;
padding: 16px;
border-radius: 15px;
background: #fff;
}
.cbc-card:has(a:focus-visible) {
outline: 2px solid var(--cbc-accent);
outline-offset: 3px;
}
.cbc-media {
aspect-ratio: 605 / 423;
}
.cbc-media img,
.cbc-ph {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 15px;
clip-path: polygon(var(--cbc-cut) 0, 100% 0, 100% 100%, 0 100%, 0 var(--cbc-cut));
}
.cbc-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #e3eef1 0 12px, #edf5f7 12px 24px);
font: 500 13px/1.4 var(--cbc-body);
}
.cbc-copy {
padding: 22px 8px 4px;
}
.cbc-date {
display: block;
color: var(--cbc-accent);
font: 600 14px/22.4px var(--cbc-body);
letter-spacing: -0.28px;
text-transform: uppercase;
}
.cbc-title {
margin: 8px 0 0;
padding-bottom: 24px;
border-bottom: 1px solid rgb(11 70 84 / 0.12);
font: 500 22px/1.4 var(--cbc-display);
letter-spacing: -0.44px;
}
.cbc-title a {
color: inherit;
text-decoration: none;
}
/* The title link covers the whole card. */
.cbc-title a::after {
content: "";
position: absolute;
inset: 0;
border-radius: 15px;
}
.cbc-title a:focus-visible {
outline: none;
}
.cbc-author {
display: flex;
align-items: center;
gap: 10px;
margin: 24px 0 0;
font: 400 16px/25.6px var(--cbc-body);
letter-spacing: -0.32px;
}
.cbc-author img {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
[data-cbc-reveal="up"] {
transition:
opacity 0.8s var(--cbc-ease) var(--d, 0s),
transform 0.8s var(--cbc-ease) var(--d, 0s);
}
.cbc[data-cbc-ready] [data-cbc-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.cbc-w {
transition:
opacity 0.6s var(--cbc-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--cbc-ease) calc(var(--i) * 0.06s);
}
.cbc[data-cbc-ready] [data-cbc-reveal="words"]:not([data-in]) .cbc-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.cbc-inner {
padding: 90px 40px;
}
.cbc-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.cbc-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cbc-card {
padding: 20px;
}
.cbc-copy {
padding: 26px 15px 0;
}
}
@media (min-width: 1200px) {
.cbc-inner {
padding: 125px 65px 120px;
}
.cbc-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.cbc-grid {
margin-top: 60px;
}
.cbc-title {
font-size: 28px;
line-height: 39.2px;
letter-spacing: -0.56px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-cbc-reveal="up"],
.cbc-w {
transition: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initChamferBlogCards(root: HTMLElement) {
if (root.dataset.cbcReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.cbcReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-cbc-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.cbcReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-cbc-root]").forEach(initChamferBlogCards);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ChamferBlogCards.astro =====
===== FILE (supporting): src/components/ChamferBlogCards.md =====
# Chamfer blog cards
A centred latest-articles section: outlined eyebrow and a big uppercase
heading over two wide white cards. Each card is one link: a landscape photo
with a chamfered top-left corner, an orange date, a title, a hairline and the
author's avatar and name.
Catalog page: `/components/chamfer-blog-cards`
## Files required
- **`ChamferBlogCards.astro`** — the component, with one small appear script.
- **`public/chamfer-blog-cards/mountain-truck.webp`**, **`winter-train.webp`**,
**`author-1.webp`**, **`author-2.webp`** — the catalog demo's photos, passed
as registry demo props. The component has no default photos or posts.
- `ChamferBlogCards.md` — this file.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Latest Blog"` | Outlined label. `""` drops it. |
| `heading` | `string?` | `""` | Orange words in `*asterisks*`. `""` drops it. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | Card titles are one level below. |
| `posts` | `{ title, href, date?, datetime?, author?, image? }[]` | `[]` | `author: { name, avatar? }`. Posts without a `title` or `href` are skipped; an `author` with no `name` is ignored. No posts → the component renders nothing. |
| `background` | `string?` | `#f0f8fa` | |
| `fontStylesheet`, `class`, `id` | | | |
## Behaviour (measured)
Cards rise from 80px, staggered; heading words rise from 10px. The reference
cards have no hover change; a keyboard focus ring outlines the whole card.
## Images and credits
| File | Size | Credit |
| :--- | :--- | :--- |
| `mountain-truck.webp` | 1210 × 846 | Unsplash g_FizakXz50, Unsplash License |
| `winter-train.webp` | 1210 × 846 | Unsplash 6wJH2YbhFs4, Unsplash License |
| `author-1.webp` | 72 × 72 | Unsplash cdZcNIca4w0, Unsplash License |
| `author-2.webp` | 72 × 72 | Unsplash tixe_fCjSIc, Unsplash License |
===== END src/components/ChamferBlogCards.md =====
===== FILE (required): public/chamfer-blog-cards/mountain-truck.webp =====
[binary asset — download https://astro.baysixmedia.com/chamfer-blog-cards/mountain-truck.webp and save it to public/chamfer-blog-cards/mountain-truck.webp]
===== FILE (required): public/chamfer-blog-cards/winter-train.webp =====
[binary asset — download https://astro.baysixmedia.com/chamfer-blog-cards/winter-train.webp and save it to public/chamfer-blog-cards/winter-train.webp]
===== FILE (required): public/chamfer-blog-cards/author-1.webp =====
[binary asset — download https://astro.baysixmedia.com/chamfer-blog-cards/author-1.webp and save it to public/chamfer-blog-cards/author-1.webp]
===== FILE (required): public/chamfer-blog-cards/author-2.webp =====
[binary asset — download https://astro.baysixmedia.com/chamfer-blog-cards/author-2.webp and save it to public/chamfer-blog-cards/author-2.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.