DEV Community

Roger Rajaratnam
Roger Rajaratnam

Posted on • Originally published at sourcier.uk

Building a tag system in Astro

Original post: Building a tag system in Astro

Series: Part 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

Diagram fallback for Dev.to. View the canonical article for the original SVG: https://sourcier.uk/blog/tag-system-astro

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, "");
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 },
];
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

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;
}
Enter fullscreen mode Exit fullscreen mode

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) }));
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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, "");
}
Enter fullscreen mode Exit fullscreen mode

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();
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 &gt;= 0.66) return 3;
  if (ratio &gt;= 0.33) return 2;
  return 1;
}

const tags = [...tagCounts.entries()]
  .sort(([, a], [, b]) =&gt; b - a)
  .map(([tag, count]) =&gt; ({
    tag,
    count,
    slug: tagSlug(tag),
    tier: tier(count),
  }));
---






          <p>Topics</p>
          <h2 id="tag-cloud-heading">
            Browse by topic
          </h2>
          <p>
            Jump straight into the subjects you care about without paging
            through the full archive first.
          </p>

        <a href="/tags">
          All topics →
        </a>

      <ul>
        {
          tags.map(({ tag, count, slug, tier }) =&gt; (
            <li>
              <a href="{`/tags/${slug}`}">
                {tag}
                <span>
                  {count}
                </span>
              </a>
            </li>
          ))
        }
      </ul>





  .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;

    &amp;: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;

    &amp;: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);
    }

    &amp;--tier-1 {
      font-size: 0.74rem;
      padding: 0.3rem 0.6rem;
    }

    &amp;--tier-2 {
      font-size: 0.9rem;
      padding: 0.35rem 0.72rem;
    }

    &amp;--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 &amp; {
      background-color: var(--accent-secondary-alpha-16);
      color: var(--accent-secondary);
    }
  }

Enter fullscreen mode Exit fullscreen mode

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();
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]) =&gt; b - a)
  .map(([tag, count]) =&gt; ({ tag, count, slug: tagSlug(tag) }));
---


  <p id="tags-sidebar-heading">Browse topics</p>
  <ul>
    {
      tags.map(({ tag, count, slug }) =&gt; (
        <li>
          <a href="{`/tags/${slug}`}">
            <span>{tag}</span>
            <span>
              {count}
            </span>
          </a>
        </li>
      ))
    }
  </ul>
  <a href="/tags">All tags →</a>



  .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;

    &amp;: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 &amp; {
      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;

    &amp;:hover {
      color: var(--accent-primary);
    }
  }

Enter fullscreen mode Exit fullscreen mode

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();
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]) =&gt; b - a)
  .map(([tag, count]) =&gt; ({
    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 &lt; ringConfig.length; ringIdx++) {
  if (si &gt;= sorted.length) break;
  const { radiusPct, max, startDeg } = ringConfig[ringIdx];
  const items = sorted.slice(si, si + max);
  si += items.length;
  items.forEach((item, i) =&gt; {
    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(arr: T[]): T[] {
  const out = [...arr];
  let seed = 42;
  const rand = () =&gt; {
    seed = (seed * 1664525 + 1013904223) &amp; 0xffffffff;
    return (seed &gt;&gt;&gt; 0) / 0xffffffff;
  };
  for (let i = out.length - 1; i &gt; 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) =&gt; {
  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";
};
---








          <p>Topic map</p>
          <h2 id="tag-summary-heading">
            Explore the writing by theme
          </h2>
          <p>
            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>

        <ul>
          <li>
            <span>Published</span>
            <span>{totalPosts}</span>
          </li>
          <li>
            <span>Topics</span>
            <span>{totalTags}</span>
          </li>
        </ul>








          <p>
            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>

            <span>fewer posts</span>
            <span>→</span>
            <span>more posts</span>





            {
              positioned.map(({ tag, slug, fontSize, x, y, ringIdx }) =&gt; (
                <a href="{`/tags/${slug}`}">
                  {tag}
                </a>
              ))
            }



        <ul>
          {
            shuffled.map(({ tag, count, slug, fontSize }) =&gt; (
              <li>
                <a href="{`/tags/${slug}`}">
                  {tag}
                </a>
              </li>
            ))
          }
        </ul>








  .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;

    &amp;::before {
      content: "";
      position: absolute;
      inset: 0;
      border-radius: 50%;
      border: 1px solid var(--border-subtle);
      pointer-events: none;
    }

    &amp;::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);

    &amp;: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;

    &amp;:hover {
      color: var(--accent-secondary);
      opacity: 1;
    }
  }

Enter fullscreen mode Exit fullscreen mode

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();
  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]) =&gt; {
    const label = posts[0].data.tags.find((t) =&gt; 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) =&gt;
      isPublished(post) &amp;&amp; post.data.tags.some((t) =&gt; tagSlug(t) === tag),
  )
  .sort((a, b) =&gt; 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 &gt; 1 ? `/tags/${tag}/2` : null;
const postCountLabel =
  allTagPosts.length === 1 ? "1 post" : `${allTagPosts.length} posts`;
---








          <p>Topic archive</p>
          <h2 id="topic-overview-heading">
            {postCountLabel} about {label}
          </h2>
          <p>
            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>


          <a href="/tags">All topics</a>
          <a href="/blog">
            Latest writing
          </a>





   1 ? `, across ${totalPages} pages.` : "."}`}
  /&gt;





  .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;

    &amp;: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);
  }

Enter fullscreen mode Exit fullscreen mode

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();
  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) =&gt; tagSlug(t) === slug) ?? slug;
    for (let pageNum = 2; pageNum &lt;= 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) =&gt;
      isPublished(post) &amp;&amp; post.data.tags.some((t) =&gt; tagSlug(t) === tag),
  )
  .sort((a, b) =&gt; 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 &lt; totalPages ? `/tags/${tag}/${currentPage + 1}` : null;
const postCountLabel =
  allTagPosts.length === 1 ? "1 post" : `${allTagPosts.length} posts`;
---








          <p>Topic archive</p>
          <h2 id="topic-overview-heading">
            Page {currentPage} of {totalPages}
          </h2>
          <p>
            {postCountLabel} filed under {label}. This page keeps you inside the
            same topic while you move through older entries in the archive.
          </p>


          <a href="{`/tags/${tag}`}">
            Topic overview
          </a>
          <a href="/blog">
            Latest writing
          </a>











  .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;

    &amp;: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);
  }

Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)