A smarter heading hierarchy for embeddable React apps
Adaptive React headings that respect any host page. Learn accessible hierarchy, dynamic level mapping, and safe CMS HTML sanitization without brittle, hardcoded levels.

The problem
Embedding React inside arbitrary pages often breaks heading order because components hardcode tags like <h2>
or <h3>
, which may not match the host document's outline and can confuse assistive technology and navigation flows. WAI guidance recommends avoiding skipped heading ranks and keeping headings logical, which is hard to enforce across contexts in componentized UIs.
Goals
- Accessibility-first: Keep headings logical and sequential so screen readers expose a meaningful document map.
- Reusable by design: Let the host page decide the starting level without refactoring internal components.
Approach 1: Dynamic Heading component
Use a single Heading component that derives the final tag from a base level plus an optional offset, clamped to h1–h6 for validity and consistency.
// Heading.jsx
export const BASE_LEVEL = 1; // Let the host set this at mount time.
// Heading component that creates dynamic heading levels based on a base level and an offset.
export default function Heading({ levelOffset, children, ...rest }) {
const level = Math.min(Math.max(BASE_LEVEL + (levelOffset || 0), 1), 6);
const Tag = `h${level}`;
return <Tag {...rest}>{children}</Tag>
}
Example usage:
// With BASE_LEVEL = 2:
<Heading>Section Title</Heading> // <h2>Section Title</h2>
<Heading levelOffset={1}>Subsection</Heading> // <h3>Subsection</h3>
<Heading levelOffset={2}>Nested Title</Heading> // <h4>Nested Title</h4>
Why it works: It aligns to WAI guidance to avoid skipped ranks by deriving levels relative to context and keeps components flexible across host pages.
Approach 2: Normalize HTML you don't control
When ingesting HTML from a CMS or other external source, detect the smallest heading level, compute an offset to a configurable base (e.g., start at h3), and remap all headings while clamping to h1–h6. Then use sanitize-html
to transform heading tags via transformTags
and sanitize the HTML before rendering.
// utils.js
import sanitizeHtml from 'sanitize-html';
const BASE_LEVEL = 3;
// Find minimum heading level present (1, 2, ..., 6)
function getMinHeadingLevel(html) {
const levels = [];
html.replace(/<h([1-6])\b/gi, (_, d) => {
levels.push(Number(d));
return _;
});
if (levels.length === 0) return null;
return Math.min(...levels);
}
// Clamp between BASE_LEVEL and 6
function clampLevel(level) {
return Math.max(BASE_LEVEL, Math.min(6, level));
}
export function normalizeHeadingsAndSanitize(dirtyHtml) {
const minLevel = getMinHeadingLevel(dirtyHtml);
const shift = (minLevel == null) ? 0 : BASE_LEVEL - minLevel; // if no headings, keep as is
const headingTransforms = {};
// Build transformTags functions for h1, h2, ..., h6
for (let L = 1; L <= 6; L++) {
const tag = `h${L}`;
headingTransforms[tag] = function (tagName, attribs) {
const newLevel = clampLevel(L + shift);
return { tagName: `h${newLevel}`, attribs };
};
}
// Sanitize with transforms
return sanitizeHtml(dirtyHtml, {
transformTags: headingTransforms
});
}
Important note
Some HTML sanitizers, including sanitize-html
, reference Node-only modules through their dependencies, which can trigger browser build errors. Add Node polyfills in the bundler, or use a browser‑native sanitizer to avoid them. For Vite, install vite-plugin-node-polyfills
and enable it in the Vite config.
- Share this page