zudo-css-wisdom

Type to search...

to open search from anywhere

Markdown to HTML Conversion Architecture

CreatedApr 13, 2026UpdatedApr 24, 2026Takeshi Takatsudo

Resolving cascade conflicts between container-scoped CSS and utility-first CSS with MDX component overrides

The Problem

Documentation sites and content-driven applications commonly convert Markdown/MDX files into HTML pages. The standard approach is to wrap the rendered HTML in a container element and style native HTML elements with scoped CSS:

/* Container-scoped element styling */
.content :where(h2) {
  font-size: 1.5rem;
  font-weight: 700;
  border-top: 3px solid transparent;
  border-image: linear-gradient(to right, currentColor, transparent) 1;
  padding-top: 0.75rem;
}

.content :where(h3) {
  font-size: 1.2rem;
  font-weight: 700;
  border-top: 2px solid gray;
  padding-top: 0.5rem;
}

.content :where(p) {
  line-height: 1.75;
}

.content :where(a) {
  color: var(--color-accent);
  text-decoration: underline;
}

This pattern works well for the default content pages — every <h2>, <h3>, <p> inside .content gets styled uniformly.

The Breakdown

The approach falls apart when you need to use the same HTML elements outside the content styling context — or when you need different styling for the same elements within the container.

Consider a documentation page that embeds an interactive form component:

// Inside a doc page that renders within .content
<PresetGenerator />

// The component uses h3 for section labels
function SectionHeading({ children }) {
  return (
    <h3 className="text-sm font-semibold">
      {children}
    </h3>
  );
}

The component’s <h3> elements match the container’s heading selectors — picking up border-top gradients, larger font size, extra padding — even though they are form labels, not document headings. The component’s utility classes (text-sm, font-semibold) should override the container styles, but they can’t.

Why Utility Classes Lose

In Tailwind CSS v4, @import "tailwindcss/utilities" places utility classes inside a @layer:

@import "tailwindcss/preflight";
@import "tailwindcss/utilities";

/* This comes AFTER the utility import — it's unlayered */
.content :where(h3) {
  font-size: 1.2rem;
  font-weight: 700;
  border-top: 2px solid gray;
}

Cascade layers follow strict priority: unlayered styles always beat layered styles, regardless of specificity or source order. The .content :where(h3) rule is unlayered, while Tailwind’s .text-sm is inside @layer utilities. Even though :where() has zero specificity, the unlayered rule wins.

This creates an impossible situation:

  • You can’t override container styles with utility classes
  • Adding more specific CSS overrides (.preset-gen :where(h3) { ... }) leads to an arms race
  • Each new context that reuses <h3> needs its own override rules
  • The codebase accumulates context-specific patches that break when contexts change

The root issue isn’t specificity — it’s that two styling systems (container-scoped CSS and utility-first CSS) occupy different cascade layers and cannot negotiate with each other.

The Solution

Replace container-scoped element styling with component overrides at the MDX rendering layer. Instead of a CSS rule that styles all <h2> elements inside a container, create a component that renders <h2> with the styles built in.

Modern frameworks (Astro, Next.js, Remix) support MDX component overrides — a mechanism where you replace the default HTML elements that MDX generates with custom components:

// content-h2.tsx — replaces <h2> in MDX content
export function ContentH2({ id, children, ...props }) {
  return (
    <h2
      id={id}
      className="text-xl font-bold leading-tight pt-3"
      style={{
        '--flow-space': 'var(--spacing-2xl)',
        borderTop: '3px solid transparent',
        borderImage: 'linear-gradient(to right, currentColor, transparent) 1',
      }}
      {...props}
    >
      {children}
    </h2>
  );
}
// content-h3.tsx — replaces <h3> in MDX content
export function ContentH3({ id, children, ...props }) {
  return (
    <h3
      id={id}
      className="text-lg font-bold leading-snug pt-2"
      style={{
        '--flow-space': 'var(--spacing-xl)',
        borderTop: '2px solid transparent',
        borderImage: 'linear-gradient(to right, gray, transparent) 1',
      }}
      {...props}
    >
      {children}
    </h3>
  );
}

Register the overrides at the rendering layer:

// component-map.ts
import { ContentH2 } from './content-h2';
import { ContentH3 } from './content-h3';
import { ContentP } from './content-p';
import { ContentA } from './content-a';

export const htmlOverrides = {
  h2: ContentH2,
  h3: ContentH3,
  p: ContentP,
  a: ContentA,
};

Framework Integration

Astro — pass components prop to the MDX <Content> component:

---
import { htmlOverrides } from './component-map';
const { Content } = await entry.render();
---

<article class="content">
  <Content components={{ ...htmlOverrides, Note, Tip, Warning }} />
</article>

Next.js — use useMDXComponents or the components prop in next-mdx-remote:

import { MDXRemote } from 'next-mdx-remote/rsc';
import { htmlOverrides } from './component-map';

export default function DocPage({ source }) {
  return <MDXRemote source={source} components={htmlOverrides} />;
}

Why This Solves the Problem

With component overrides:

  1. MDX content headings render through ContentH3 — with border-top gradient, larger font, etc.
  2. Form section headings use plain <h3 className="text-sm font-semibold"> — no container styles interfere
  3. No cascade conflict — each context controls its own styling via the component it chooses to render
  4. Single source of truth — heading design lives in the component, not in a CSS rule that fights with other rules

The container element (.content) no longer needs element-level styling. It handles only container concerns:

/* Container-level only — no element styling */
.content {
  color: var(--color-fg);
  font-size: var(--text-body);
  line-height: 1.75;
}

/* Flow spacing (vertical rhythm) */
.content > * + * {
  margin-top: var(--flow-space, 1rem);
}

/* Structural rules that depend on sibling adjacency */
.content :where(h2, h3, h4) + :where(:not(h2, h3, h4)) {
  --flow-space: 0.5rem;
}

What Stays in Global CSS

Not everything moves into components. Rules that depend on relationships between elements — sibling adjacency, parent-child structure — belong in global CSS because a component cannot reason about its neighbors:

ConcernWhere it livesWhy
Heading font/border/weightComponentSelf-contained appearance
--flow-space valueComponent (via style)Element-level spacing declaration
> * + * flow spacingContainer CSSReads --flow-space from children
Heading + heading tighteningContainer CSSDepends on sibling adjacency
Heading + content tighteningContainer CSSDepends on sibling adjacency
Plugin-injected elements (auto-link anchors)Container CSSCross-cutting concern from build plugins

Server-Rendered Components = Zero JavaScript

A common concern: “Won’t React/Preact components for every heading add JavaScript overhead?”

No. In modern SSR frameworks, components without client-side interactivity directives are rendered at build time and produce static HTML. They are purely templates:

Astro: Any Preact/React component without client:load or client:visible is server-rendered only — zero JavaScript shipped.

Next.js: Server Components (the default in App Router) render on the server — no client bundle impact.

The components exist only at build time. The browser receives plain <h2>, <h3>, <p> elements with classes and inline styles — indistinguishable from what a CSS-only approach would produce.

When to Use

ScenarioRecommended approach
Content pages only, no reuse conflictsContainer-scoped CSS (simpler)
Same elements used in content AND interactive componentsComponent overrides (eliminates conflicts)
Tailwind v4 with @layer and unlayered content stylesComponent overrides (required to avoid cascade lock)
Multiple content contexts with different styling needsComponent overrides (each context gets its own components)
Minor elements (li, code, hr) with no reuse conflictsContainer CSS (avoid component overhead)

Practical Decision Rule

Start with container-scoped CSS. It is simpler and works for the majority of cases.

Switch to component overrides when you discover that an element used in content pages is also needed in a non-content context (forms, interactive panels, embedded tools) and the container styles create conflicts. This is a reactive decision, not a preemptive one.

Once you switch, convert all primary elements (h2, h3, h4, p, a, blockquote, ul, ol, table) to components. Leaving some as CSS and some as components creates a confusing hybrid that is harder to maintain. Minor elements (h5, h6, li, inline code, hr, img) can stay as CSS because they rarely appear outside content contexts.

Common AI Mistakes

  • Attempting to fix cascade conflicts with more specific CSS overrides instead of addressing the architectural root cause
  • Using !important to force utility class values over container-scoped styles
  • Not realizing that Tailwind v4’s @import "tailwindcss/utilities" creates a @layer that loses to unlayered CSS
  • Creating wrapper-specific CSS overrides (.form :where(h3) { ... }) for every new context — an approach that doesn’t scale
  • Adding border-top: none; border-image: none; font-size: ... reset rules that strip heading design entirely instead of letting each context control its own styling
  • Assuming that React/Preact components for content elements add JavaScript overhead (they don’t — server-rendered only)
  • Mixing some elements as components and others as container CSS without a clear boundary rule

References

Revision History