Building zero-JS charts with SVG and Astro
How we replaced a 200 kB charting library with build-time SVG, without losing tooltips or accessibility.
1 estrella en GitHub = 1 componente nuevo. Llevamos estrellas: ayúdanos a desbloquear el siguiente.
Dar una estrella →Últimos artículos con un post destacado y tarjetas con portadas degradadas, categoría y tiempo de lectura.
Engineering · 8 min read
How we replaced a 200 kB charting library with build-time SVG, without losing tooltips or accessibility.
By Marcus Lee
Design · 5 min read
Contrast, elevation and color tokens that hold up at night.
By Sofía Ruiz
Accessibility · 6 min read
Focus, keyboard, contrast and screen readers — our exact process.
By Priya Natarajan
Growth · 7 min read
A data-backed look at hero, proof, pricing and FAQ placement.
By Alicia Gómez
Copia y pega este código directamente en tu página de Astro:
---
import { BlogGridSection } from 'astro-component-kit/sections';
---
<BlogGridSection
posts={[
{ title: 'Zero-JS charts', excerpt: '…', category: 'Engineering', date: '2026-10-28', readTime: '8 min', author: 'Marcus', href: '/blog/charts' },
]}
/> ---
import { BlogGridSection } from 'astro-component-kit/sections';
---
<BlogGridSection
posts={[
{ title: 'Zero-JS charts', excerpt: '…', category: 'Engineering', date: '2026-10-28', readTime: '8 min', author: 'Marcus', href: '/blog/charts' },
]}
/> Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/sections/BlogGridSection.astro:
---
/**
* BlogGridSection — Latest articles section with one featured post and a grid of
* smaller posts. Covers are CSS gradients by default (pass `image` for photos).
*
* @param {string} title - Section heading.
* @param {{label: string, href: string}} viewAll - Link to the full blog.
* @param {Array<{title: string, excerpt: string, category: string, date: string, readTime: string, author: string, href: string, image?: string}>} posts - Posts (first is featured).
* @param {string} variant - Card style (cards|minimal|overlay).
* @param {string} size - Vertical rhythm (sm|md|lg).
* @param {string} className - Extra CSS class.
*/
interface Post { title: string; excerpt: string; category: string; date: string; readTime: string; author: string; href: string; image?: string }
interface Props {
title?: string;
viewAll?: { label: string; href: string };
posts?: Post[];
variant?: 'cards' | 'minimal' | 'overlay';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'From the blog',
viewAll = { label: 'View all posts', href: '#' },
posts = [
{ title: 'Building zero-JS charts with SVG and Astro', excerpt: 'How we replaced a 200 kB charting library with build-time SVG, without losing tooltips or accessibility.', category: 'Engineering', date: '2026-10-28', readTime: '8 min', author: 'Marcus Lee', href: '#' },
{ title: 'Designing for dark mode first', excerpt: 'Contrast, elevation and color tokens that hold up at night.', category: 'Design', date: '2026-10-21', readTime: '5 min', author: 'Sofía Ruiz', href: '#' },
{ title: 'The accessibility checklist we run on every component', excerpt: 'Focus, keyboard, contrast and screen readers — our exact process.', category: 'Accessibility', date: '2026-10-14', readTime: '6 min', author: 'Priya Natarajan', href: '#' },
{ title: 'Landing page anatomy: 7 sections that convert', excerpt: 'A data-backed look at hero, proof, pricing and FAQ placement.', category: 'Growth', date: '2026-10-07', readTime: '7 min', author: 'Alicia Gómez', href: '#' },
],
variant = 'cards',
size = 'md',
className = '',
} = Astro.props;
const id = `bgs-${Math.random().toString(36).slice(2, 8)}`;
const gradients = [
'radial-gradient(circle at 20% 20%, rgba(57,135,229,.65), transparent 55%), radial-gradient(circle at 80% 80%, rgba(144,133,233,.55), transparent 50%), #101018',
'radial-gradient(circle at 70% 30%, color-mix(in srgb, var(--c-primary-light, #d4a017) 60%, transparent), transparent 50%), #15120a',
'radial-gradient(circle at 30% 70%, rgba(25,158,112,.6), transparent 55%), #0b1411',
'radial-gradient(circle at 60% 40%, rgba(213,81,129,.55), transparent 55%), #150b10',
];
const fmtDate = (d: string) => new Date(`${d}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
---
<div class="cq-blog-grid-section-cq">
<section class={`bgs bgs--${variant} bgs--${size} ${className}`} aria-labelledby={id}>
<div class="bgs__inner">
<header class="bgs__head">
<h2 id={id} class="bgs__title">{title}</h2>
{viewAll && (
<a class="bgs__all" href={viewAll.href}>
{viewAll.label}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</a>
)}
</header>
<ul class="bgs__grid">
{posts.map((p, i) => (
<li class:list={['bgs__post', { 'bgs__post--featured': i === 0 }]}>
<article class="bgs__card">
<div class="bgs__cover" style={p.image ? undefined : `background:${gradients[i % gradients.length]}`}>
{p.image && <img src={p.image} alt="" loading="lazy" decoding="async" />}
</div>
<div class="bgs__body">
<p class="bgs__meta">
<span class="bgs__cat">{p.category}</span>
<time datetime={p.date}>{fmtDate(p.date)}</time>
<span aria-hidden="true">·</span>
<span>{p.readTime} read</span>
</p>
<h3 class="bgs__post-title"><a href={p.href}>{p.title}</a></h3>
<p class="bgs__excerpt">{p.excerpt}</p>
<p class="bgs__author">By {p.author}</p>
</div>
</article>
</li>
))}
</ul>
</div>
</section>
</div>
<style>
.cq-blog-grid-section-cq { container-type: inline-size; }
.bgs {
--bgs-py: 5.5rem;
padding: var(--bgs-py) var(--sp-6, 1.5rem);
background: var(--c-bg, #050505);
}
.bgs--sm { --bgs-py: 3.5rem; }
.bgs--lg { --bgs-py: 7.5rem; }
.bgs__inner { max-width: 1180px; margin: 0 auto; }
.bgs__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap; }
.bgs__title { margin: 0; font-size: clamp(1.75rem, 4vw, 2.5rem); font-weight: 800; letter-spacing: -0.03em; color: var(--c-text-1, #fff); }
.bgs__all { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 44px; font-weight: 600; color: var(--c-primary-light, #d4a017); text-decoration: none; }
.bgs__all:focus-visible { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; border-radius: 4px; }
.bgs__grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; }
.bgs__post--featured { grid-column: 1 / -1; }
.bgs__card { position: relative; height: 100%; display: flex; flex-direction: column; border-radius: var(--r-lg, 20px); overflow: hidden; transition: transform 0.25s ease, border-color 0.25s ease; }
.bgs--cards .bgs__card { background: rgb(var(--ink-rgb, 255 255 255) / 0.025); border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.07)); }
.bgs__card:hover, .bgs__card:focus-within { transform: translateY(-3px); border-color: rgb(var(--ink-rgb, 255 255 255) / 0.18); }
.bgs__post--featured .bgs__card { display: grid; grid-template-columns: 1.2fr 1fr; }
.bgs__cover { aspect-ratio: 16 / 9; position: relative; }
.bgs__post--featured .bgs__cover { aspect-ratio: auto; min-height: 280px; }
.bgs__cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bgs--minimal .bgs__cover { border-radius: var(--r-lg, 20px); }
.bgs__body { padding: 1.5rem; display: flex; flex-direction: column; gap: 0.6rem; }
.bgs--minimal .bgs__body { padding: 1.25rem 0 0; }
.bgs__post--featured .bgs__body { justify-content: center; padding: clamp(1.5rem, 4vw, 2.75rem); }
.bgs--overlay .bgs__card { min-height: 320px; }
.bgs--overlay .bgs__cover { position: absolute; inset: 0; aspect-ratio: auto; }
.bgs--overlay .bgs__cover::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent 70%); }
.bgs--overlay .bgs__body { position: relative; margin-top: auto; }
.bgs--overlay .bgs__post--featured .bgs__card { display: flex; }
.bgs__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0; font-size: 0.8125rem; color: var(--c-text-2, #a3a3a3); }
.bgs__cat { padding: 0.15rem 0.55rem; border-radius: 999px; background: rgb(var(--ink-rgb, 255 255 255) / 0.07); color: var(--c-text-1, #fff); font-weight: 600; font-size: 0.75rem; }
.bgs__post-title { margin: 0; font-size: 1.1875rem; font-weight: 700; line-height: 1.3; letter-spacing: -0.015em; }
.bgs__post--featured .bgs__post-title { font-size: clamp(1.5rem, 3vw, 2rem); }
.bgs__post-title a { color: var(--c-text-1, #fff); text-decoration: none; }
.bgs__post-title a::after { content: ''; position: absolute; inset: 0; }
.bgs__post-title a:focus-visible { outline: none; }
.bgs__card:has(a:focus-visible) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 3px; }
.bgs__excerpt { margin: 0; font-size: 0.9375rem; line-height: 1.6; color: var(--c-text-2, #a3a3a3); }
.bgs__author { margin: auto 0 0; padding-top: 0.5rem; font-size: 0.8125rem; color: var(--c-text-3, #8a8a8a); }
@container (max-width: 900px) {
.bgs__grid { grid-template-columns: 1fr 1fr; }
.bgs__post--featured .bgs__card { grid-template-columns: 1fr; }
.bgs__post--featured .bgs__cover { min-height: 200px; }
}
@container (max-width: 600px) { .bgs__grid { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .bgs__card { transition: none; } }
</style> ---
/**
* BlogGridSection — Latest articles section with one featured post and a grid of
* smaller posts. Covers are CSS gradients by default (pass `image` for photos).
*
* @param {string} title - Section heading.
* @param {{label: string, href: string}} viewAll - Link to the full blog.
* @param {Array<{title: string, excerpt: string, category: string, date: string, readTime: string, author: string, href: string, image?: string}>} posts - Posts (first is featured).
* @param {string} variant - Card style (cards|minimal|overlay).
* @param {string} size - Vertical rhythm (sm|md|lg).
* @param {string} className - Extra CSS class.
*/
interface Post { title: string; excerpt: string; category: string; date: string; readTime: string; author: string; href: string; image?: string }
interface Props {
title?: string;
viewAll?: { label: string; href: string };
posts?: Post[];
variant?: 'cards' | 'minimal' | 'overlay';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'From the blog',
viewAll = { label: 'View all posts', href: '#' },
posts = [
{ title: 'Building zero-JS charts with SVG and Astro', excerpt: 'How we replaced a 200 kB charting library with build-time SVG, without losing tooltips or accessibility.', category: 'Engineering', date: '2026-10-28', readTime: '8 min', author: 'Marcus Lee', href: '#' },
{ title: 'Designing for dark mode first', excerpt: 'Contrast, elevation and color tokens that hold up at night.', category: 'Design', date: '2026-10-21', readTime: '5 min', author: 'Sofía Ruiz', href: '#' },
{ title: 'The accessibility checklist we run on every component', excerpt: 'Focus, keyboard, contrast and screen readers — our exact process.', category: 'Accessibility', date: '2026-10-14', readTime: '6 min', author: 'Priya Natarajan', href: '#' },
{ title: 'Landing page anatomy: 7 sections that convert', excerpt: 'A data-backed look at hero, proof, pricing and FAQ placement.', category: 'Growth', date: '2026-10-07', readTime: '7 min', author: 'Alicia Gómez', href: '#' },
],
variant = 'cards',
size = 'md',
className = '',
} = Astro.props;
const id = `bgs-${Math.random().toString(36).slice(2, 8)}`;
const gradients = [
'radial-gradient(circle at 20% 20%, rgba(57,135,229,.65), transparent 55%), radial-gradient(circle at 80% 80%, rgba(144,133,233,.55), transparent 50%), #101018',
'radial-gradient(circle at 70% 30%, color-mix(in srgb, var(--c-primary-light, #d4a017) 60%, transparent), transparent 50%), #15120a',
'radial-gradient(circle at 30% 70%, rgba(25,158,112,.6), transparent 55%), #0b1411',
'radial-gradient(circle at 60% 40%, rgba(213,81,129,.55), transparent 55%), #150b10',
];
const fmtDate = (d: string) => new Date(`${d}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
---
<div class="cq-blog-grid-section-cq">
<section class={`bgs bgs--${variant} bgs--${size} ${className}`} aria-labelledby={id}>
<div class="bgs__inner">
<header class="bgs__head">
<h2 id={id} class="bgs__title">{title}</h2>
{viewAll && (
<a class="bgs__all" href={viewAll.href}>
{viewAll.label}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</a>
)}
</header>
<ul class="bgs__grid">
{posts.map((p, i) => (
<li class:list={['bgs__post', { 'bgs__post--featured': i === 0 }]}>
<article class="bgs__card">
<div class="bgs__cover" style={p.image ? undefined : `background:${gradients[i % gradients.length]}`}>
{p.image && <img src={p.image} alt="" loading="lazy" decoding="async" />}
</div>
<div class="bgs__body">
<p class="bgs__meta">
<span class="bgs__cat">{p.category}</span>
<time datetime={p.date}>{fmtDate(p.date)}</time>
<span aria-hidden="true">·</span>
<span>{p.readTime} read</span>
</p>
<h3 class="bgs__post-title"><a href={p.href}>{p.title}</a></h3>
<p class="bgs__excerpt">{p.excerpt}</p>
<p class="bgs__author">By {p.author}</p>
</div>
</article>
</li>
))}
</ul>
</div>
</section>
</div>
<style>
.cq-blog-grid-section-cq { container-type: inline-size; }
.bgs {
--bgs-py: 5.5rem;
padding: var(--bgs-py) var(--sp-6, 1.5rem);
background: var(--c-bg, #050505);
}
.bgs--sm { --bgs-py: 3.5rem; }
.bgs--lg { --bgs-py: 7.5rem; }
.bgs__inner { max-width: 1180px; margin: 0 auto; }
.bgs__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap; }
.bgs__title { margin: 0; font-size: clamp(1.75rem, 4vw, 2.5rem); font-weight: 800; letter-spacing: -0.03em; color: var(--c-text-1, #fff); }
.bgs__all { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 44px; font-weight: 600; color: var(--c-primary-light, #d4a017); text-decoration: none; }
.bgs__all:focus-visible { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; border-radius: 4px; }
.bgs__grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; }
.bgs__post--featured { grid-column: 1 / -1; }
.bgs__card { position: relative; height: 100%; display: flex; flex-direction: column; border-radius: var(--r-lg, 20px); overflow: hidden; transition: transform 0.25s ease, border-color 0.25s ease; }
.bgs--cards .bgs__card { background: rgb(var(--ink-rgb, 255 255 255) / 0.025); border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.07)); }
.bgs__card:hover, .bgs__card:focus-within { transform: translateY(-3px); border-color: rgb(var(--ink-rgb, 255 255 255) / 0.18); }
.bgs__post--featured .bgs__card { display: grid; grid-template-columns: 1.2fr 1fr; }
.bgs__cover { aspect-ratio: 16 / 9; position: relative; }
.bgs__post--featured .bgs__cover { aspect-ratio: auto; min-height: 280px; }
.bgs__cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bgs--minimal .bgs__cover { border-radius: var(--r-lg, 20px); }
.bgs__body { padding: 1.5rem; display: flex; flex-direction: column; gap: 0.6rem; }
.bgs--minimal .bgs__body { padding: 1.25rem 0 0; }
.bgs__post--featured .bgs__body { justify-content: center; padding: clamp(1.5rem, 4vw, 2.75rem); }
.bgs--overlay .bgs__card { min-height: 320px; }
.bgs--overlay .bgs__cover { position: absolute; inset: 0; aspect-ratio: auto; }
.bgs--overlay .bgs__cover::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent 70%); }
.bgs--overlay .bgs__body { position: relative; margin-top: auto; }
.bgs--overlay .bgs__post--featured .bgs__card { display: flex; }
.bgs__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0; font-size: 0.8125rem; color: var(--c-text-2, #a3a3a3); }
.bgs__cat { padding: 0.15rem 0.55rem; border-radius: 999px; background: rgb(var(--ink-rgb, 255 255 255) / 0.07); color: var(--c-text-1, #fff); font-weight: 600; font-size: 0.75rem; }
.bgs__post-title { margin: 0; font-size: 1.1875rem; font-weight: 700; line-height: 1.3; letter-spacing: -0.015em; }
.bgs__post--featured .bgs__post-title { font-size: clamp(1.5rem, 3vw, 2rem); }
.bgs__post-title a { color: var(--c-text-1, #fff); text-decoration: none; }
.bgs__post-title a::after { content: ''; position: absolute; inset: 0; }
.bgs__post-title a:focus-visible { outline: none; }
.bgs__card:has(a:focus-visible) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 3px; }
.bgs__excerpt { margin: 0; font-size: 0.9375rem; line-height: 1.6; color: var(--c-text-2, #a3a3a3); }
.bgs__author { margin: auto 0 0; padding-top: 0.5rem; font-size: 0.8125rem; color: var(--c-text-3, #8a8a8a); }
@container (max-width: 900px) {
.bgs__grid { grid-template-columns: 1fr 1fr; }
.bgs__post--featured .bgs__card { grid-template-columns: 1fr; }
.bgs__post--featured .bgs__cover { min-height: 200px; }
}
@container (max-width: 600px) { .bgs__grid { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .bgs__card { transition: none; } }
</style>