EmpireUI
Get Pro
← Blog7 min read#tailwind-css#blog-template#typography

Blog Template with Tailwind: Typography, TOC, Author Bio

Build a blog template with Tailwind CSS covering responsive typography, sticky table of contents, author bio components, and dark mode — without fighting the framework.

Laptop on a desk showing a clean blog article layout with text columns and readable typography

Why Blog Typography Is Harder Than It Looks

Honestly, most blog templates look terrible on a 1440px monitor. The line length stretches to 90+ characters, the spacing between headings feels inconsistent, and the code blocks don't stand out from the surrounding text. You end up reading something that was clearly designed on a 13-inch MacBook and never tested at scale.

Tailwind's prose utility from @tailwindcss/typography solves a lot of this, but it's not a magic wand. You still need to think about max-w-prose, clamp-based font sizing, and what your h2 actually looks like after the plugin's resets. People assume 'just add prose' and then wonder why the article feels cramped on mobile.

In this guide we'll build a full blog layout: responsive typography with proper vertical rhythm, a sticky table of contents sidebar, and an author bio card. Real code, real decisions, no hand-waving.

Setting Up the Tailwind Typography Plugin

If you're on Tailwind v4.0.2 or later, add the typography plugin in your CSS config rather than tailwind.config.js. The API shifted slightly — plugins now go in @plugin directives.

Install it first: npm install @tailwindcss/typography. Then in your globals.css: ``css @import "tailwindcss"; @plugin "@tailwindcss/typography"; @layer utilities { .prose-blog { --tw-prose-body: theme(colors.zinc.700); --tw-prose-headings: theme(colors.zinc.900); --tw-prose-code: theme(colors.indigo.600); --tw-prose-pre-bg: theme(colors.zinc.950); font-size: clamp(1rem, 1.5vw, 1.125rem); line-height: 1.75; } } ``

The clamp() on font-size is doing real work here. Between 320px and 1280px viewports that formula produces 16px to 18px — enough range to feel right on both phone and desktop without a media query. And yes, you can override --tw-prose-* CSS variables directly. They're documented but buried. Worth knowing if you want to swap colours without replacing entire modifier classes like prose-zinc.

Building the Blog Post Layout with a Sticky TOC

The classic two-column blog layout: article content on the left (~65%), sticky sidebar on the right (~35%). On mobile it stacks vertically and the TOC appears inline above the article, or you hide it entirely. The grid approach in Tailwind makes this straightforward.

// BlogLayout.tsx
export function BlogLayout({
  children,
  toc,
}: {
  children: React.ReactNode;
  toc: { id: string; label: string; depth: number }[];
}) {
  return (
    <div className="mx-auto max-w-7xl px-4 py-12 lg:grid lg:grid-cols-[1fr_280px] lg:gap-16">
      <article className="prose prose-blog max-w-none dark:prose-invert">
        {children}
      </article>

      <aside className="hidden lg:block">
        <nav className="sticky top-24 rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900">
          <p className="mb-4 text-xs font-semibold uppercase tracking-widest text-zinc-400">
            On this page
          </p>
          <ul className="space-y-2">
            {toc.map((item) => (
              <li
                key={item.id}
                style={{ paddingLeft: `${(item.depth - 2) * 12}px` }}
              >
                <a
                  href={`#${item.id}`}
                  className="text-sm text-zinc-500 hover:text-indigo-600 dark:hover:text-indigo-400"
                >
                  {item.label}
                </a>
              </li>
            ))}
          </ul>
        </nav>
      </aside>
    </div>
  );
}

A few specific decisions in that snippet worth calling out. The top-24 on the sticky nav assumes a 96px header — adjust to match yours. The paddingLeft is calculated at 12px per depth level starting from h2, which gives a clean visual hierarchy without needing separate classes per depth. And notice max-w-none on the article — without it the prose plugin's own max-w-prose kicks in and breaks the grid at wider viewports.

Active TOC Highlighting with IntersectionObserver

A static TOC is fine. An active one that highlights the current section as you scroll is genuinely useful. You don't need a library for this — IntersectionObserver handles it well with about 20 lines of code.

// useActiveHeading.ts
import { useEffect, useState } from "react";

export function useActiveHeading(ids: string[]) {
  const [activeId, setActiveId] = useState<string | null>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            setActiveId(entry.target.id);
            break;
          }
        }
      },
      { rootMargin: "0px 0px -70% 0px", threshold: 0 }
    );

    ids.forEach((id) => {
      const el = document.getElementById(id);
      if (el) observer.observe(el);
    });

    return () => observer.disconnect();
  }, [ids]);

  return activeId;
}

The rootMargin: "0px 0px -70% 0px" is the secret sauce. It shrinks the detection zone to the top 30% of the viewport, so a heading only becomes 'active' when it's near the top of the screen — not just when it's anywhere in view. Without that adjustment the active state jumps around as you scroll at normal speed.

Wire it up in your TOC component: const activeId = useActiveHeading(toc.map(t => t.id)) and then conditionally apply text-indigo-600 font-medium when item.id === activeId. Pair this with a dark mode toggle and you've got a TOC that genuinely feels polished.

Author Bio Card Component

The author bio at the end of an article serves two purposes: it establishes credibility, and it's a low-effort way to add internal links or social proof. Keep it compact. A small avatar, name, one-liner, and maybe a link. Don't try to fit a LinkedIn summary in there.

// AuthorBio.tsx
export function AuthorBio({
  name,
  avatar,
  bio,
  href,
}: {
  name: string;
  avatar: string;
  bio: string;
  href?: string;
}) {
  return (
    <div className="mt-16 flex items-start gap-4 rounded-2xl border border-zinc-200 bg-zinc-50 p-6 dark:border-zinc-800 dark:bg-zinc-900">
      <img
        src={avatar}
        alt={name}
        width={56}
        height={56}
        className="rounded-full object-cover ring-2 ring-white dark:ring-zinc-800"
      />
      <div>
        <p className="font-semibold text-zinc-900 dark:text-white">{name}</p>
        <p className="mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
          {bio}
        </p>
        {href && (
          <a
            href={href}
            className="mt-2 inline-block text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400"
          >
            More articles →
          </a>
        )}
      </div>
    </div>
  );
}

The ring-2 ring-white on the avatar image creates a subtle outline that separates it from any background. At 56px the avatar is big enough to read at a glance without dominating the card. One thing worth checking: make sure your MDX or CMS pipeline actually has author metadata. It's embarrassing to build a nice author card and then discover the author field is always undefined in production.

Code Block Styling Inside Prose

The typography plugin styles pre and code blocks, but the defaults are conservative. Most developer blogs want syntax highlighting, line numbers, or at minimum a dark background with a monospace font that's actually readable. Are you sure your inline code spans have enough contrast against your background?

You've got two routes. First option: use a dedicated syntax highlighter like shiki or rehype-pretty-code. These inject class names or inline styles per token and work well with Next.js MDX pipelines. Second option: lean on the prose CSS variables and style it yourself — more control, zero dependency. For this template we'll do the manual approach so it works anywhere.

In your globals.css, inside the prose-blog layer: ``css .prose-blog pre { background-color: #0e0e10; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 0.75rem; padding: 1.25rem 1.5rem; overflow-x: auto; font-size: 0.875rem; line-height: 1.7; } .prose-blog :not(pre) > code { background-color: rgba(99, 102, 241, 0.1); color: #818cf8; padding: 0.15em 0.4em; border-radius: 0.3em; font-size: 0.875em; } ``

That rgba(255,255,255,0.08) border on the pre block is subtle — just enough to show the block boundary in dark mode without a harsh line. If you're working with Tailwind v4 features and OKLCH colours, you could replace the hardcoded hex values with oklch() references instead. Worth it on a design system; probably overkill for a single blog.

Responsive Reading Width and Vertical Rhythm

Horizontal line length is the single biggest readability factor most devs ignore. Research consistently points to 50-75 characters per line as optimal for body text. At 18px in your typical sans-serif, that's roughly 600px to 720px container width. The max-w-prose Tailwind class resolves to 65ch — pretty close to ideal.

Vertical rhythm is trickier. You want headings to feel visually separated from surrounding paragraphs, but not so spaced they look like a different document. The typography plugin uses a 1.75 line-height for body text and adds margin-top: 2em before headings. If your design calls for tighter spacing, override with prose-headings:mt-6 instead of fighting the CSS directly.

One pattern worth knowing from Tailwind component patterns: use prose-img:rounded-xl prose-img:shadow-md to consistently style article images without adding classes to every image tag in your MDX. Those modifier classes are genuinely underused. Same idea applies to prose-a:decoration-indigo-400 prose-a:underline-offset-2 for styled links throughout the entire article body.

Generating a TOC from MDX Headings in Next.js

All that TOC work is useless if you can't actually extract headings from your MDX files. In a Next.js App Router setup with next-mdx-remote or contentlayer, you'll typically have access to the raw MDX string. Parse it with a regex or with remark — the regex approach is 4 lines and accurate enough for most blogs.

// lib/toc.ts
export type TocItem = { id: string; label: string; depth: number };

export function extractToc(content: string): TocItem[] {
  const headingRegex = /^(#{2,4})\s+(.+)$/gm;
  const items: TocItem[] = [];
  let match: RegExpExecArray | null;

  while ((match = headingRegex.exec(content)) !== null) {
    const depth = match[1].length; // 2 = h2, 3 = h3, 4 = h4
    const label = match[2].replace(/`[^`]+`/g, (m) => m.slice(1, -1));
    const id = label
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-|-$/g, "");
    items.push({ id, label, depth });
  }

  return items;
}

Call extractToc(rawMdxString) in your page's generateStaticParams or directly in the Server Component, then pass the result down to BlogLayout. The id you generate here must match the id attribute on the heading elements in the rendered HTML — if you're using rehype-slug it'll generate matching slugs automatically. If you're not, you may need to add the id manually via a custom MDX component for headings.

For a more visual approach to the sidebar, check out how glassmorphism effects can add depth to the TOC card — a backdrop-blur-md with rgba(255,255,255,0.15) background makes it float cleanly over long article content.

FAQ

Do I need @tailwindcss/typography to build a blog with Tailwind, or can I style it manually?

You don't need it, but skipping it means writing a lot of prose resets yourself — handling p, ul, ol, blockquote, pre, code, table spacing from scratch. The plugin is well-maintained and the CSS variables it exposes make customisation straightforward. For most projects, using it and overriding what you need is faster than starting from zero.

How do I stop the prose plugin from overriding my custom component styles inside MDX?

Wrap your custom components in a div with the not-prose class. Anything inside not-prose is excluded from the typography plugin's selectors. Alternatively, use Tailwind's prose-* modifier classes to fine-tune individual element types rather than fighting the cascade.

What's the best way to handle dark mode in a blog prose layout?

Add the dark:prose-invert class alongside prose on your article element. This flips the CSS variables to light-on-dark automatically. You'll still need to manually handle any custom colours you've set via --tw-prose-* variables — those won't invert automatically unless you define the dark variants yourself in a @layer block.

My sticky TOC overlaps the footer on short pages. How do I fix that?

Use position: sticky with a max-height and overflow-y scroll rather than a completely fixed position. In Tailwind: sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto. This keeps it scrollable on long sidebars and prevents it from running into the footer on short pages.

Is there a performance cost to IntersectionObserver for the active TOC heading?

Minimal. IntersectionObserver is browser-native and runs off the main thread. The only thing to watch is making sure you call observer.disconnect() in the useEffect cleanup — otherwise you'll accumulate observers across client-side navigations in Next.js, which can cause memory leaks and stale state bugs.

How should I handle code block line wrapping in the prose layout on mobile?

Set overflow-x: auto on the pre element (not code) and avoid white-space: pre-wrap — that breaks indentation in code samples. On mobile, horizontal scrolling inside the code block is acceptable and expected. Users understand this. Wrapping code lines to fit a 320px screen is worse than the alternative.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Admin Panel Template with Tailwind: Full CRUD UI LayoutTailwind Breadcrumbs with Schema: SEO Navigation ComponentSwiss Design in React: International Typographic Style for UIImage Gallery with Lightbox: Accessible Photo Viewer in React