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 raceEach new context that reuses
<h3>needs its own override rulesThe 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 interfereNo 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 stylesNot realizing that Tailwind v4's
@import "tailwindcss/utilities"creates a@layerthat loses to unlayered CSSCreating wrapper-specific CSS overrides (
.form :where(h3) { ... }) for every new context — an approach that doesn't scaleAdding
border-top: none; border-image: none; font-size: ...reset rules that strip heading design entirely instead of letting each context control its own stylingAssuming 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