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
Was this useful?
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.
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:
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:
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.
import BlogTagCloud from "../../components/BlogTagCloud.astro";
// ...
<BlogTagCloud />Inside BlogTagCloud.astro, counts are converted into three visual tiers:
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:
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:
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.astrohandles page 1.src/pages/tags/[tag]/[page].astroemits pages 2 and above.
The second file builds its paths manually:
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:
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:
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 → </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
If this has been useful, you can also back the writing with a tip.
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.
Comments
Loading comments…
Leave a comment