Building a tag system in Astro
Cover image: Four paper card tags on a dark background — Photo by Angèle Kamp on Unsplash
astro engineering web development

Building a tag system in Astro

Slug normalisation, blog archive clouds, ring layouts, tag archives, and a post sidebar browser

Published
21 April 2026
Read time
23 min read
SeriesPart of How this blog was built — documenting every decision that shaped this site.

Tags do more than connect related posts. They create a second navigation system. On this site the same tag data powers the blog archive cloud, the dedicated /tags overview, paginated tag archive pages, and a sidebar browser on individual post pages.

The key constraint is consistency. If each surface counts tags differently, builds URLs differently, or decides for itself which posts are visible, the whole system drifts. The implementation here stays simple by sharing two rules everywhere: one slug helper and one publication filter.

Tag system wireframe showing three views: the blog archive weighted cloud with tier-1/2/3 pill sizes, the /tags page with concentric ring layout, and the post sidebar tag list

Slug normalisation

Every tag is displayed as-is, but routed via a URL-safe slug. A small utility in src/utils/tags.ts handles that conversion:

src/utils/tags.ts
export function tagSlug(tag: string): string {
return tag
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}

That turns "web development" into "web-development", "Node.js" into "nodejs", and "C#" into "c". It is intentionally small. A more generic utility might transliterate Unicode or preserve special cases, but the tag vocabulary on this site is controlled enough that a simple transform is easier to reason about.

The important detail is reuse. The cloud component, sidebar, tags index, and tag archive routes all import the same helper, so every link resolves to the same path format.

Publication-aware tag counts

Counting tags is trivial. Counting the right tags is the subtle part.

This site does not let each tag surface invent its own visibility rules. It filters posts through the shared publication helper and only then derives tag counts:

src/components/BlogTagCloud.astro
import { isPublished } from "../utils/drafts";
const allPosts = (await getCollection("posts")).filter(isPublished);
const tagCounts = new Map<string, number>();
for (const post of allPosts) {
for (const tag of post.data.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}

That matters because isPublished() hides both drafts and future-dated posts unless SHOW_DRAFTS=true. The weighted cloud, ring layout, tag archives, and sidebar all stay in sync with the rest of the site because they start from the same filtered set.

Right now the counting logic is repeated across several files. That is still a good trade-off: the logic is tiny and the behaviour stays obvious. If the rules become more complex later, extracting a shared getTagCounts() helper would be the next clean-up step.

The weighted cloud on the blog archive

The weighted cloud no longer lives on the homepage. It sits on the blog archive: /blog and the paginated /blog/page/[page] pages both render the same component.

src/pages/blog/index.astro
import BlogTagCloud from "../../components/BlogTagCloud.astro";
// ...
<BlogTagCloud />

Inside BlogTagCloud.astro, counts are converted into three visual tiers:

src/components/BlogTagCloud.astro
function tier(count: number): number {
if (maxCount === minCount) return 2;
const ratio = (count - minCount) / (maxCount - minCount);
if (ratio >= 0.66) return 3;
if (ratio >= 0.33) return 2;
return 1;
}

Those tiers map to modifier classes such as tag-cloud__pill--tier-1, tag-cloud__pill--tier-2, and tag-cloud__pill--tier-3. A stepped system is deliberate here. Continuous scaling works well on the more expressive /tags page, but in a compact cloud of pill links it creates visual noise faster than it adds meaning.

Each pill also includes a small count badge, so someone hovering or scanning the cloud sees both the qualitative weight and the exact number. An “All topics” link pushes people from the compact archive cloud into the more detailed /tags overview.

The concentric ring layout on /tags

The /tags page is the more exploratory view. It combines two separate signals:

  • Font size scales continuously with tag frequency.
  • Tags are placed on concentric rings, with the most common topic in the centre and less common topics pushed outward.

The scaling and ring layout are both calculated at build time in the page frontmatter:

src/pages/tags/index.astro
const MIN_REM = 1.1;
const MAX_REM = 4;
function scale(count: number): number {
if (maxCount === minCount) return (MIN_REM + MAX_REM) / 2;
return (
MIN_REM + ((count - minCount) / (maxCount - minCount)) * (MAX_REM - MIN_REM)
);
}
const ringConfig = [
{ radiusPct: 0, max: 1, startDeg: 0 },
{ radiusPct: 18, max: 4, startDeg: -90 },
{ radiusPct: 35, max: 7, startDeg: -50 },
{ radiusPct: 48, max: 12, startDeg: 10 },
];

After sorting tags by count descending, the page fills the rings from the inside out and converts each ring slot into percentage coordinates:

src/pages/tags/index.astro
const angleDeg = radiusPct === 0 ? 0 : startDeg + (360 * i) / items.length;
const rad = (angleDeg * Math.PI) / 180;
const x = radiusPct === 0 ? 50 : 50 + radiusPct * Math.cos(rad);
const y = radiusPct === 0 ? 50 : 50 + radiusPct * Math.sin(rad);

That produces an SVG-like layout without SVG. Each tag is still a normal anchor, just absolutely positioned inside a relative container.

The accessibility model matters here too. The circular cloud is decorative and marked aria-hidden, while a plain list remains in the accessibility tree and is also the visual fallback on small screens. The fallback list is shuffled with a seeded function so it does not read as a rigid alphabetical index, but it stays deterministic across builds.

Tag archive pages

Each topic gets a clean first-page URL at /tags/[tag] and subsequent archive pages at /tags/[tag]/2, /tags/[tag]/3, and so on.

Rather than using Astro’s paginate() helper, the current implementation keeps two route files:

  • src/pages/tags/[tag]/index.astro handles page 1.
  • src/pages/tags/[tag]/[page].astro emits pages 2 and above.

The second file builds its paths manually:

src/pages/tags/[tag]/[page].astro
export async function getStaticPaths() {
const PAGE_SIZE = 9;
const allPosts = (await getCollection("posts")).filter(isPublished);
const tagMap = new Map<string, typeof allPosts>();
for (const post of allPosts) {
for (const tag of post.data.tags) {
const slug = tagSlug(tag);
if (!tagMap.has(slug)) tagMap.set(slug, []);
tagMap.get(slug)!.push(post);
}
}
const paths: object[] = [];
for (const [slug, posts] of tagMap.entries()) {
const totalPages = Math.ceil(posts.length / PAGE_SIZE);
const label = posts[0].data.tags.find((t) => tagSlug(t) === slug) ?? slug;
for (let pageNum = 2; pageNum <= totalPages; pageNum++) {
paths.push({
params: { tag: slug, page: String(pageNum) },
props: { label },
});
}
}
return paths;
}

The label recovery is a nice detail. Routes use slugs, but page heroes should still display the human-readable tag text. Looking up the original label from the first matching post preserves spaces and casing without a separate lookup table.

Both route files sort posts newest first and share the same PAGE_SIZE = 9, so pagination behaves consistently across every topic.

The sidebar browser

TagsSidebar.astro is the compact version of the same system. It keeps the same counting and sorting pattern, but renders a simple vertical list with count badges rather than a cloud:

src/components/TagsSidebar.astro
const tags = [...tagCounts.entries()]
.sort(([, a], [, b]) => b - a)
.map(([tag, count]) => ({ tag, count, slug: tagSlug(tag) }));

The sidebar only matters because the post layout mounts it alongside the table of contents and share controls:

src/layouts/MarkdownPostLayout.astro
import TagsSidebar from "../components/TagsSidebar.astro";
<aside class="post__sidebar" aria-label="Post sidebar">
{/* other sidebar blocks */}
<TagsSidebar />
</aside>

That keeps topic browsing available even when someone lands deep on an individual article. The UI copy uses “topics” in the reader-facing labels, but the routes stay under /tags, which keeps the underlying implementation short and stable.

Pulling the system together

The interesting part of this tag system is not any single surface. It is the fact that every surface starts from the same visible post set and uses the same slug transform. Once those two rules are stable, the same dataset can show up as weighted pills, a ring cloud, paginated archives, or a sidebar list without the site disagreeing with itself.

Full code listings

If you want to inspect the finished implementation end to end, expand the listings below. These are the complete files that make up the tag system itself. The shared publication helper lives in src/utils/drafts.ts and is reused across the rest of the site too.

src/utils/tags.ts
export function tagSlug(tag: string): string {
return tag
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
src/components/BlogTagCloud.astro
---
import { getCollection } from "astro:content";
import { isPublished } from "../utils/drafts";
import { tagSlug } from "../utils/tags";
const allPosts = (await getCollection("posts")).filter(isPublished);
const tagCounts = new Map<string, number>();
for (const post of allPosts) {
for (const tag of post.data.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
const counts = [...tagCounts.values()];
const minCount = Math.min(...counts);
const maxCount = Math.max(...counts);
function tier(count: number): number {
if (maxCount === minCount) return 2;
const ratio = (count - minCount) / (maxCount - minCount);
if (ratio >= 0.66) return 3;
if (ratio >= 0.33) return 2;
return 1;
}
const tags = [...tagCounts.entries()]
.sort(([, a], [, b]) => b - a)
.map(([tag, count]) => ({
tag,
count,
slug: tagSlug(tag),
tier: tier(count),
}));
---
<section class="tag-cloud stack-space" aria-labelledby="tag-cloud-heading">
<div class="container is-max-desktop">
<div class="tag-cloud__panel page-panel">
<div class="tag-cloud__header">
<div class="tag-cloud__intro">
<p class="section-label">Topics</p>
<h2 class="tag-cloud__title" id="tag-cloud-heading">
Browse by topic
</h2>
<p class="tag-cloud__description">
Jump straight into the subjects you care about without paging
through the full archive first.
</p>
</div>
<a
href="/tags"
class="tag-cloud__all-link"
aria-label="View all topics"
>
All topics &rarr;
</a>
</div>
<ul class="tag-cloud__list" role="list">
{
tags.map(({ tag, count, slug, tier }) => (
<li>
<a
href={`/tags/${slug}`}
class={`tag-cloud__pill tag-cloud__pill--tier-${tier}`}
aria-label={`${tag}${count} post${count === 1 ? "" : "s"}`}
>
{tag}
<span class="tag-cloud__count" aria-hidden="true">
{count}
</span>
</a>
</li>
))
}
</ul>
</div>
</div>
</section>
<style lang="scss">
.tag-cloud {
padding: 0 1.5rem;
}
.tag-cloud__panel {
position: relative;
overflow: hidden;
padding: clamp(1.75rem, 3.5vw, 2.35rem);
background:
radial-gradient(
circle at top right,
color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
transparent 34%
),
radial-gradient(
circle at left bottom,
var(--accent-primary-alpha-08) 0%,
transparent 30%
),
linear-gradient(
145deg,
color-mix(
in srgb,
var(--surface-page) 88%,
var(--accent-secondary) 12%
),
var(--surface-elevated)
);
}
.tag-cloud__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1.75rem;
gap: 1rem;
@media (max-width: 639px) {
flex-direction: column;
align-items: flex-start;
}
}
.tag-cloud__intro {
min-width: 0;
}
.tag-cloud__title {
margin: 0;
font-family: "Barlow Condensed", sans-serif;
font-size: clamp(1.8rem, 4vw, 2.5rem);
line-height: 1.05;
text-transform: uppercase;
color: var(--text-primary);
}
.tag-cloud__description {
margin: 0.5rem 0 0;
max-width: 48ch;
line-height: 1.65;
color: var(--text-muted);
}
.tag-cloud__all-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.85rem;
padding: 0.75rem 1.05rem;
border: 1px solid
color-mix(in srgb, var(--accent-secondary) 20%, transparent);
border-radius: var(--radius-pill);
background: color-mix(
in srgb,
var(--accent-secondary) 9%,
var(--surface-elevated)
);
font-family: "Barlow Condensed", sans-serif;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
text-decoration: none;
transition:
color 0.15s ease,
border-color 0.15s ease,
transform 0.15s ease;
&:hover {
color: var(--accent-secondary);
border-color: var(--accent-secondary-alpha-28);
transform: translateY(-1px);
}
@media (min-width: 640px) {
align-self: center;
}
}
.tag-cloud__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.tag-cloud__pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
font-family: "Barlow Condensed", sans-serif;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
border: 1.5px solid var(--border-subtle);
border-radius: var(--radius-pill);
color: var(--text-muted);
background-color: color-mix(
in srgb,
var(--surface-elevated) 78%,
transparent
);
box-shadow: inset 0 1px 0 var(--text-on-strong-alpha-45);
transition:
color 0.15s ease,
border-color 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
color: var(--accent-secondary);
border-color: var(--accent-secondary-alpha-26);
background-color: color-mix(
in srgb,
var(--accent-secondary) 9%,
var(--surface-elevated)
);
transform: translateY(-1px);
}
&--tier-1 {
font-size: 0.74rem;
padding: 0.3rem 0.6rem;
}
&--tier-2 {
font-size: 0.9rem;
padding: 0.35rem 0.72rem;
}
&--tier-3 {
font-size: 1.04rem;
padding: 0.42rem 0.82rem;
border-width: 2px;
color: var(--text-primary);
border-color: var(--accent-secondary-alpha-18);
}
}
.tag-cloud__count {
font-size: 0.65em;
font-weight: 700;
color: var(--accent-secondary);
background-color: color-mix(
in srgb,
var(--accent-secondary) 12%,
transparent
);
padding: 0.05rem 0.35rem;
border-radius: var(--radius-pill);
transition:
background-color 0.15s ease,
color 0.15s ease;
.tag-cloud__pill:hover & {
background-color: var(--accent-secondary-alpha-16);
color: var(--accent-secondary);
}
}
</style>
src/components/TagsSidebar.astro
---
import { getCollection } from "astro:content";
import { isPublished } from "../utils/drafts";
import { tagSlug } from "../utils/tags";
const allPosts = (await getCollection("posts")).filter(isPublished);
const tagCounts = new Map<string, number>();
for (const post of allPosts) {
for (const tag of post.data.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
const tags = [...tagCounts.entries()]
.sort(([, a], [, b]) => b - a)
.map(([tag, count]) => ({ tag, count, slug: tagSlug(tag) }));
---
<nav class="tags-sidebar" aria-labelledby="tags-sidebar-heading">
<p class="tags-sidebar__heading" id="tags-sidebar-heading">Browse topics</p>
<ul class="tags-sidebar__list" role="list">
{
tags.map(({ tag, count, slug }) => (
<li>
<a href={`/tags/${slug}`} class="tags-sidebar__tag">
<span class="tags-sidebar__name">{tag}</span>
<span
class="tags-sidebar__count"
aria-label={`${count} post${count === 1 ? "" : "s"}`}
>
{count}
</span>
</a>
</li>
))
}
</ul>
<a href="/tags" class="tags-sidebar__all">All tags →</a>
</nav>
<style lang="scss">
.tags-sidebar {
margin-top: 2rem;
margin-bottom: 2rem;
}
.tags-sidebar__heading {
font-family: "Barlow Condensed", sans-serif;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent-primary);
padding-top: 0.75rem;
border-top: 2px solid var(--accent-primary);
margin-bottom: 0.75rem;
}
.tags-sidebar__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.tags-sidebar__tag {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0;
border-bottom: 1px solid var(--border-subtle);
text-decoration: none;
color: var(--text-muted);
transition:
color 0.15s ease,
padding-left 0.15s ease;
&:hover {
color: var(--accent-primary);
padding-left: 0.35rem;
}
}
.tags-sidebar__name {
font-family: "Barlow Condensed", sans-serif;
font-size: 0.875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tags-sidebar__count {
font-family: "Barlow Condensed", sans-serif;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
background-color: var(--border-subtle);
border-radius: var(--radius-tight);
padding: 0.1rem 0.4rem;
min-width: 1.4rem;
text-align: center;
transition:
background-color 0.15s ease,
color 0.15s ease;
.tags-sidebar__tag:hover & {
background-color: rgba(var(--accent-primary-rgb), 0.12);
color: var(--accent-primary);
}
}
.tags-sidebar__all {
display: block;
margin-top: 1rem;
font-family: "Barlow Condensed", sans-serif;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
text-decoration: none;
transition: color 0.15s ease;
&:hover {
color: var(--accent-primary);
}
}
</style>
src/pages/tags/index.astro
---
import { getCollection } from "astro:content";
import { isPublished } from "../../utils/drafts";
import BaseLayout from "../../layouts/BaseLayout.astro";
import PageHero from "../../components/PageHero.astro";
import MailingListCTA from "../../components/MailingListCTA.astro";
import { tagSlug } from "../../utils/tags";
const allPosts = (await getCollection("posts")).filter(isPublished);
const tagCounts = new Map<string, number>();
for (const post of allPosts) {
for (const tag of post.data.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
const counts = [...tagCounts.values()];
const minCount = Math.min(...counts);
const maxCount = Math.max(...counts);
const MIN_REM = 1.1;
const MAX_REM = 4;
function scale(count: number): number {
if (maxCount === minCount) return (MIN_REM + MAX_REM) / 2;
return (
MIN_REM + ((count - minCount) / (maxCount - minCount)) * (MAX_REM - MIN_REM)
);
}
const sorted = [...tagCounts.entries()]
.sort(([, a], [, b]) => b - a)
.map(([tag, count]) => ({
tag,
count,
slug: tagSlug(tag),
fontSize: scale(count),
}));
const ringConfig = [
{ radiusPct: 0, max: 1, startDeg: 0 },
{ radiusPct: 18, max: 4, startDeg: -90 },
{ radiusPct: 35, max: 7, startDeg: -50 },
{ radiusPct: 48, max: 12, startDeg: 10 },
];
type TagPos = {
tag: string;
count: number;
slug: string;
fontSize: number;
x: number;
y: number;
ringIdx: number;
};
const positioned: TagPos[] = [];
let si = 0;
for (let ringIdx = 0; ringIdx < ringConfig.length; ringIdx++) {
if (si >= sorted.length) break;
const { radiusPct, max, startDeg } = ringConfig[ringIdx];
const items = sorted.slice(si, si + max);
si += items.length;
items.forEach((item, i) => {
const angleDeg = radiusPct === 0 ? 0 : startDeg + (360 * i) / items.length;
const rad = (angleDeg * Math.PI) / 180;
const x = radiusPct === 0 ? 50 : 50 + radiusPct * Math.cos(rad);
const y = radiusPct === 0 ? 50 : 50 + radiusPct * Math.sin(rad);
positioned.push({ ...item, x, y, ringIdx });
});
}
function seededShuffle<T>(arr: T[]): T[] {
const out = [...arr];
let seed = 42;
const rand = () => {
seed = (seed * 1664525 + 1013904223) & 0xffffffff;
return (seed >>> 0) / 0xffffffff;
};
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
const shuffled = seededShuffle(sorted);
const totalTags = sorted.length;
const totalPosts = allPosts.length;
const ringStyle = (ringIdx: number) => {
if (ringIdx === 0) return "color: var(--accent-primary); opacity: 1";
if (ringIdx === 1) return "opacity: 1";
if (ringIdx === 2) return "opacity: 0.85";
return "opacity: 0.7";
};
---
<BaseLayout
pageTitle="Tags — Sourcier"
description="Browse all topics and tags from the Sourcier blog."
>
<PageHero
kicker="Browse"
title="Tags"
subtitle="Browse every topic covered on the blog."
/>
<section
class="tag-summary flow-section flow-section--loose"
aria-labelledby="tag-summary-heading"
>
<div class="container is-max-desktop">
<div class="page-panel tag-summary__panel">
<div class="tag-summary__copy">
<p class="tag-summary__eyebrow">Topic map</p>
<h2 class="tag-summary__title" id="tag-summary-heading">
Explore the writing by theme
</h2>
<p class="tag-summary__text">
Jump straight into the ideas that show up most often across the
blog, or skim the full map below if you want to browse more broadly.
</p>
</div>
<ul class="tag-summary__stats" role="list">
<li class="tag-summary__stat">
<span class="tag-summary__label">Published</span>
<span class="tag-summary__value">{totalPosts}</span>
</li>
<li class="tag-summary__stat">
<span class="tag-summary__label">Topics</span>
<span class="tag-summary__value">{totalTags}</span>
</li>
</ul>
</div>
</div>
</section>
<section class="section cloud-section flow-section">
<div class="container is-max-desktop">
<div class="cloud-surface page-panel">
<div class="cloud-intro">
<p class="cloud-intro__text">
Each topic links to a collection of related posts. Larger tags mean
I write about that area more often, so the subjects I return to most
sit closer to the centre.
</p>
<div class="cloud-legend" aria-label="Size guide">
<span class="cloud-legend__example cloud-legend__example--sm"
>fewer posts</span
>
<span class="cloud-legend__arrow" aria-hidden="true">→</span>
<span class="cloud-legend__example cloud-legend__example--lg"
>more posts</span
>
</div>
</div>
<div class="cloud-circle-wrap" aria-hidden="true">
<div class="cloud-circle">
{
positioned.map(({ tag, slug, fontSize, x, y, ringIdx }) => (
<a
href={`/tags/${slug}`}
class="cloud-circle__tag"
style={`left:${x.toFixed(2)}%;top:${y.toFixed(2)}%;font-size:${fontSize.toFixed(3)}rem;${ringStyle(ringIdx)}`}
tabindex="-1"
>
{tag}
</a>
))
}
</div>
</div>
<ul class="cloud-list" role="list" aria-label="Tag cloud">
{
shuffled.map(({ tag, count, slug, fontSize }) => (
<li>
<a
href={`/tags/${slug}`}
class="cloud-list__tag"
style={`font-size:${fontSize.toFixed(3)}rem`}
aria-label={`${tag}${count} post${count === 1 ? "" : "s"}`}
>
{tag}
</a>
</li>
))
}
</ul>
</div>
</div>
</section>
<MailingListCTA />
</BaseLayout>
<style lang="scss">
.tag-summary {
padding: 0 1.5rem;
}
.tag-summary__panel {
display: grid;
gap: 1.25rem;
border-top-color: var(--accent-secondary);
background:
radial-gradient(
circle at top right,
color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
transparent 34%
),
linear-gradient(
150deg,
color-mix(in srgb, var(--accent-secondary) 8%, var(--surface-elevated))
0%,
var(--surface-elevated) 62%
),
var(--surface-elevated);
@media (min-width: 768px) {
grid-template-columns: minmax(0, 1.5fr) auto;
gap: 2rem;
align-items: center;
}
}
.tag-summary__eyebrow {
margin: 0 0 0.65rem;
font-family: "Barlow Condensed", sans-serif;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-secondary);
}
.tag-summary__title {
margin: 0;
font-family: "Barlow Condensed", sans-serif;
font-size: clamp(2rem, 4vw, 2.8rem);
line-height: 0.98;
text-transform: uppercase;
color: var(--text-primary);
}
.tag-summary__text {
margin: 0.85rem 0 0;
max-width: 58ch;
line-height: 1.7;
color: var(--text-muted);
}
.tag-summary__stats {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.tag-summary__stat {
display: grid;
gap: 0.35rem;
min-width: 9rem;
padding: 1rem 1.1rem;
border: 1px solid var(--accent-secondary-alpha-16);
border-radius: var(--radius-soft);
background: color-mix(
in srgb,
var(--accent-secondary) 8%,
var(--surface-elevated)
);
}
.tag-summary__label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
}
.tag-summary__value {
font-family: "Barlow Condensed", sans-serif;
font-size: clamp(1.55rem, 4vw, 2.1rem);
font-weight: 800;
line-height: 1;
text-transform: uppercase;
color: var(--text-primary);
}
.cloud-section {
padding-top: 0;
padding-bottom: 0;
}
.cloud-surface {
overflow: hidden;
padding: clamp(1.5rem, 3vw, 2rem);
background:
radial-gradient(
circle at top right,
color-mix(in srgb, var(--accent-secondary) 10%, transparent) 0%,
transparent 34%
),
linear-gradient(
150deg,
color-mix(in srgb, var(--accent-primary) 3%, var(--surface-elevated)) 0%,
var(--surface-elevated) 66%
),
var(--surface-elevated);
}
.cloud-intro {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-bottom: 2.5rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-subtle);
@media (min-width: 768px) {
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 4rem;
}
}
.cloud-intro__text {
margin: 0;
font-size: 1rem;
line-height: 1.75;
color: var(--text-muted);
max-width: 52ch;
}
.cloud-legend {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 1rem;
}
.cloud-legend__arrow {
color: var(--accent-secondary);
font-size: 1.25rem;
}
.cloud-legend__example {
font-family: "Barlow Condensed", sans-serif;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.cloud-legend__example--sm {
font-size: 0.875rem;
opacity: 0.45;
}
.cloud-legend__example--lg {
font-size: 2rem;
color: var(--accent-secondary);
}
.cloud-circle-wrap {
display: none;
justify-content: center;
margin: 0.5rem 0 2rem;
@media (min-width: 640px) {
display: flex;
}
}
.cloud-circle {
position: relative;
width: min(88vw, 580px);
aspect-ratio: 1;
&::before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid var(--border-subtle);
pointer-events: none;
}
&::after {
content: "";
position: absolute;
inset: 22%;
border-radius: 50%;
border: 1px dashed var(--accent-secondary-alpha-18);
pointer-events: none;
}
}
.cloud-circle__tag {
position: absolute;
transform: translate(-50%, -50%) scale(1);
font-family: "Barlow Condensed", sans-serif;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
text-decoration: none;
color: var(--text-primary);
white-space: nowrap;
transition:
color 0.2s ease,
opacity 0.2s ease,
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
color: var(--accent-secondary) !important;
opacity: 1 !important;
transform: translate(-50%, -50%) scale(1.18);
z-index: 1;
}
}
.cloud-circle:has(.cloud-circle__tag:hover) .cloud-circle__tag:not(:hover) {
opacity: 0.15 !important;
transform: translate(-50%, -50%) scale(0.95);
}
.cloud-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.75rem 1.5rem;
list-style: none;
margin: 1rem 0;
padding: 0;
@media (min-width: 640px) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
}
.cloud-list__tag {
font-family: "Barlow Condensed", sans-serif;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-primary);
text-decoration: none;
opacity: 0.75;
white-space: nowrap;
transition:
color 0.15s ease,
opacity 0.15s ease;
&:hover {
color: var(--accent-secondary);
opacity: 1;
}
}
</style>
src/pages/tags/[tag]/index.astro
---
import { getCollection } from "astro:content";
import { isPublished } from "../../../utils/drafts";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import PageHero from "../../../components/PageHero.astro";
import BlogGrid from "../../../components/BlogGrid.astro";
import MailingListCTA from "../../../components/MailingListCTA.astro";
import { tagSlug } from "../../../utils/tags";
export async function getStaticPaths() {
const allPosts = (await getCollection("posts")).filter(isPublished);
const tagMap = new Map<string, typeof allPosts>();
for (const post of allPosts) {
for (const tag of post.data.tags) {
const slug = tagSlug(tag);
if (!tagMap.has(slug)) tagMap.set(slug, []);
tagMap.get(slug)!.push(post);
}
}
return [...tagMap.entries()].map(([slug, posts]) => {
const label = posts[0].data.tags.find((t) => tagSlug(t) === slug) ?? slug;
return { params: { tag: slug }, props: { label } };
});
}
const PAGE_SIZE = 9;
const { tag } = Astro.params;
const { label } = Astro.props;
const allTagPosts = (await getCollection("posts"))
.filter(
(post) =>
isPublished(post) && post.data.tags.some((t) => tagSlug(t) === tag),
)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
const totalPages = Math.ceil(allTagPosts.length / PAGE_SIZE);
const posts = allTagPosts.slice(0, PAGE_SIZE);
const nextUrl = totalPages > 1 ? `/tags/${tag}/2` : null;
const postCountLabel =
allTagPosts.length === 1 ? "1 post" : `${allTagPosts.length} posts`;
---
<BaseLayout
pageTitle={`${label} — Tags — Sourcier`}
description={`Blog posts tagged with "${label}" on Sourcier.`}
>
<PageHero
kicker="Topic"
title={label}
subtitle={`Every published post tagged ${label}.`}
crumbs={[
{ label: "Blog", href: "/blog" },
{ label: "Tags", href: "/tags" },
{ label },
]}
/>
<section
class="topic-overview flow-section flow-section--loose"
aria-labelledby="topic-overview-heading"
>
<div class="container is-max-desktop">
<div class="page-panel topic-overview__panel">
<div class="topic-overview__copy">
<p class="topic-overview__eyebrow">Topic archive</p>
<h2 class="topic-overview__title" id="topic-overview-heading">
{postCountLabel} about {label}
</h2>
<p class="topic-overview__text">
This archive keeps every article filed under this topic in one
place, newest first, so you can stay with one theme without jumping
around the rest of the site.
</p>
</div>
<div class="topic-overview__actions">
<a href="/tags" class="topic-overview__link">All topics</a>
<a
href="/blog"
class="topic-overview__link topic-overview__link--secondary"
>
Latest writing
</a>
</div>
</div>
</div>
</section>
<BlogGrid
posts={posts}
currentPage={1}
totalPages={totalPages}
prevUrl={null}
nextUrl={nextUrl}
paginationBase={`/tags/${tag}`}
sectionLabel="Latest in this topic"
sectionTitle={`Posts about ${label}`}
sectionDescription={`Newest first${totalPages > 1 ? `, across ${totalPages} pages.` : "."}`}
/>
<MailingListCTA />
</BaseLayout>
<style lang="scss">
.topic-overview {
padding: 0 1.5rem;
}
.topic-overview__panel {
display: grid;
gap: 1.25rem;
border-top-color: var(--accent-secondary);
background:
radial-gradient(
circle at top right,
color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
transparent 34%
),
linear-gradient(
150deg,
color-mix(in srgb, var(--accent-secondary) 8%, var(--surface-elevated))
0%,
var(--surface-elevated) 62%
),
var(--surface-elevated);
@media (min-width: 768px) {
grid-template-columns: minmax(0, 1.45fr) auto;
gap: 2rem;
align-items: center;
}
}
.topic-overview__eyebrow {
margin: 0 0 0.65rem;
font-family: "Barlow Condensed", sans-serif;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-secondary);
}
.topic-overview__title {
margin: 0;
font-family: "Barlow Condensed", sans-serif;
font-size: clamp(1.9rem, 4vw, 2.7rem);
line-height: 0.98;
text-transform: uppercase;
color: var(--text-primary);
}
.topic-overview__text {
margin: 0.85rem 0 0;
max-width: 58ch;
line-height: 1.7;
color: var(--text-muted);
}
.topic-overview__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.topic-overview__link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.85rem;
padding: 0.7rem 1rem;
border: 1px solid var(--accent-secondary-alpha-18);
border-radius: var(--radius-pill);
background: color-mix(
in srgb,
var(--accent-secondary) 8%,
var(--surface-elevated)
);
font-family: "Barlow Condensed", sans-serif;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-secondary);
text-decoration: none;
transition:
border-color 0.15s ease,
transform 0.15s ease;
&:hover {
border-color: var(--accent-secondary-alpha-32);
transform: translateY(-1px);
}
}
.topic-overview__link--secondary {
border-color: var(--accent-primary-alpha-22);
background: color-mix(
in srgb,
var(--accent-primary) 8%,
var(--surface-elevated)
);
color: var(--accent-primary);
}
</style>
src/pages/tags/[tag]/[page].astro
---
import { getCollection } from "astro:content";
import { isPublished } from "../../../utils/drafts";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import PageHero from "../../../components/PageHero.astro";
import BlogGrid from "../../../components/BlogGrid.astro";
import MailingListCTA from "../../../components/MailingListCTA.astro";
import { tagSlug } from "../../../utils/tags";
export async function getStaticPaths() {
const PAGE_SIZE = 9;
const allPosts = (await getCollection("posts")).filter(isPublished);
const tagMap = new Map<string, typeof allPosts>();
for (const post of allPosts) {
for (const tag of post.data.tags) {
const slug = tagSlug(tag);
if (!tagMap.has(slug)) tagMap.set(slug, []);
tagMap.get(slug)!.push(post);
}
}
const paths: object[] = [];
for (const [slug, posts] of tagMap.entries()) {
const totalPages = Math.ceil(posts.length / PAGE_SIZE);
const label = posts[0].data.tags.find((t) => tagSlug(t) === slug) ?? slug;
for (let pageNum = 2; pageNum <= totalPages; pageNum++) {
paths.push({
params: { tag: slug, page: String(pageNum) },
props: { label },
});
}
}
return paths;
}
const PAGE_SIZE = 9;
const { tag, page } = Astro.params;
const { label } = Astro.props;
const currentPage = Number(page);
const allTagPosts = (await getCollection("posts"))
.filter(
(post) =>
isPublished(post) && post.data.tags.some((t) => tagSlug(t) === tag),
)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
const totalPages = Math.ceil(allTagPosts.length / PAGE_SIZE);
const posts = allTagPosts.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
);
const prevUrl =
currentPage === 2 ? `/tags/${tag}` : `/tags/${tag}/${currentPage - 1}`;
const nextUrl =
currentPage < totalPages ? `/tags/${tag}/${currentPage + 1}` : null;
const postCountLabel =
allTagPosts.length === 1 ? "1 post" : `${allTagPosts.length} posts`;
---
<BaseLayout
pageTitle={`${label} — Page ${currentPage} — Tags — Sourcier`}
description={`Blog posts tagged with "${label}" on Sourcier — page ${currentPage}.`}
>
<PageHero
kicker="Topic"
title={label}
subtitle={`Every published post tagged ${label}.`}
crumbs={[
{ label: "Blog", href: "/blog" },
{ label: "Tags", href: "/tags" },
{ label },
]}
/>
<section
class="topic-overview flow-section flow-section--loose"
aria-labelledby="topic-overview-heading"
>
<div class="container is-max-desktop">
<div class="page-panel topic-overview__panel">
<div class="topic-overview__copy">
<p class="topic-overview__eyebrow">Topic archive</p>
<h2 class="topic-overview__title" id="topic-overview-heading">
Page {currentPage} of {totalPages}
</h2>
<p class="topic-overview__text">
{postCountLabel} filed under {label}. This page keeps you inside the
same topic while you move through older entries in the archive.
</p>
</div>
<div class="topic-overview__actions">
<a href={`/tags/${tag}`} class="topic-overview__link">
Topic overview
</a>
<a
href="/blog"
class="topic-overview__link topic-overview__link--secondary"
>
Latest writing
</a>
</div>
</div>
</div>
</section>
<BlogGrid
posts={posts}
currentPage={currentPage}
totalPages={totalPages}
prevUrl={prevUrl}
nextUrl={nextUrl}
paginationBase={`/tags/${tag}`}
sectionLabel="Archive page"
sectionTitle={`Posts on page ${currentPage}`}
sectionDescription={`${postCountLabel} total in this topic archive.`}
/>
<MailingListCTA />
</BaseLayout>
<style lang="scss">
.topic-overview {
padding: 0 1.5rem;
}
.topic-overview__panel {
display: grid;
gap: 1.25rem;
border-top-color: var(--accent-secondary);
background:
radial-gradient(
circle at top right,
color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
transparent 34%
),
linear-gradient(
150deg,
color-mix(in srgb, var(--accent-secondary) 8%, var(--surface-elevated))
0%,
var(--surface-elevated) 62%
),
var(--surface-elevated);
@media (min-width: 768px) {
grid-template-columns: minmax(0, 1.45fr) auto;
gap: 2rem;
align-items: center;
}
}
.topic-overview__eyebrow {
margin: 0 0 0.65rem;
font-family: "Barlow Condensed", sans-serif;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-secondary);
}
.topic-overview__title {
margin: 0;
font-family: "Barlow Condensed", sans-serif;
font-size: clamp(1.9rem, 4vw, 2.7rem);
line-height: 0.98;
text-transform: uppercase;
color: var(--text-primary);
}
.topic-overview__text {
margin: 0.85rem 0 0;
max-width: 58ch;
line-height: 1.7;
color: var(--text-muted);
}
.topic-overview__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.topic-overview__link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.85rem;
padding: 0.7rem 1rem;
border: 1px solid var(--accent-secondary-alpha-18);
border-radius: var(--radius-pill);
background: color-mix(
in srgb,
var(--accent-secondary) 8%,
var(--surface-elevated)
);
font-family: "Barlow Condensed", sans-serif;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-secondary);
text-decoration: none;
transition:
border-color 0.15s ease,
transform 0.15s ease;
&:hover {
border-color: var(--accent-secondary-alpha-32);
transform: translateY(-1px);
}
}
.topic-overview__link--secondary {
border-color: var(--accent-primary-alpha-22);
background: color-mix(
in srgb,
var(--accent-primary) 8%,
var(--surface-elevated)
);
color: var(--accent-primary);
}
</style>

You can browse the rest of the site code in the web-sourcier.uk repository.

Working on something similar?

Need help raising the bar?

I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.

  • Engineering practice review
  • Hands-on delivery
  • Team mentoring

Comments

Loading comments…

Leave a comment

Free · Practical · One email per post

Get practical engineering notes

One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.