Markdown to HTML Conversion Architecture
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:
- MDX content headings render through
ContentH3— with border-top gradient, larger font, etc. - Form section headings use plain
<h3 className="text-sm font-semibold">— no container styles interfere - No cascade conflict — each context controls its own styling via the component it chooses to render
- 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:
| Concern | Where it lives | Why |
|---|---|---|
| Heading font/border/weight | Component | Self-contained appearance |
--flow-space value | Component (via style) | Element-level spacing declaration |
> * + * flow spacing | Container CSS | Reads --flow-space from children |
| Heading + heading tightening | Container CSS | Depends on sibling adjacency |
| Heading + content tightening | Container CSS | Depends on sibling adjacency |
| Plugin-injected elements (auto-link anchors) | Container CSS | Cross-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
| Scenario | Recommended approach |
|---|---|
| Content pages only, no reuse conflicts | Container-scoped CSS (simpler) |
| Same elements used in content AND interactive components | Component overrides (eliminates conflicts) |
Tailwind v4 with @layer and unlayered content styles | Component overrides (required to avoid cascade lock) |
| Multiple content contexts with different styling needs | Component overrides (each context gets its own components) |
| Minor elements (li, code, hr) with no reuse conflicts | Container 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
!importantto force utility class values over container-scoped styles - Not realizing that Tailwind v4’s
@import "tailwindcss/utilities"creates a@layerthat 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