markdown-it-anchor
markdown-it-anchor is a markdown-it plugin that adds stable id attributes to Markdown headings and can insert configurable permalink links. It selects heading levels, creates unique slugs, reuses IDs set by earlier plugins, adds tabindex=-1 by default for focus after fragment navigation, and exposes several permalink layouts with different accessibility tradeoffs. It also supports custom slugging, state-aware slugs, callbacks for collecting a heading index, and both CommonJS and ESM builds with TypeScript declarations.
markdown-it-anchor is the sensible markdown-it choice when heading fragments are part of the product, especially if you use its accessible permalink renderers. Decide slug and accessibility policies up front; defaults are intentionally minimal, and changing them later breaks URLs.
Use it if
- Your renderer already uses markdown-it and needs heading IDs that match a table of contents or deep links
- You want accessible permalink layouts rather than hand-inserting a visible hash into every heading
- You need custom slug rules, per-document state, explicit author IDs, or a callback that collects heading metadata
- You are willing to treat slug output as a permanent URL contract and test it across content changes
- You do not use markdown-it: this is a plugin with markdown-it and @types/markdown-it declared as peer dependencies, not a generic HTML heading processor
- Your headings come from raw HTML blocks: the README says those tokens are ignored and recommends parsing the final HTML instead
- You expect GitHub-style pretty slugs by default: the built-in slugger only trims, lowercases, replaces whitespace with hyphens, and applies encodeURIComponent, so punctuation remains encoded rather than removed
- You need author-defined duplicate IDs to be repaired silently: generated duplicates receive numeric suffixes, but duplicate IDs supplied by another plugin deliberately throw an error
- Your organization does not accept public-domain-style licensing: version 9.2.1 uses the Unlicense rather than MIT, Apache-2.0, or a BSD license
Setup reality
Install markdown-it-anchor alongside markdown-it. The npm metadata also declares @types/markdown-it as a peer, even for JavaScript consumers, so modern npm may install or warn about it depending on your dependency policy. The package has no runtime dependencies, native code, credentials, or configuration files and ships CommonJS, ESM, browser UMD, source maps, and its own declarations. The important setup decision is slug policy. Default slugs are lowercase, whitespace-collapsed, URI-encoded heading text; changing slugify later breaks incoming bookmarks and table-of-contents links. Supply a Unicode-aware slugger if readable non-ASCII and punctuation behavior matters, and lock examples in tests. Generated duplicates become slug-1, slug-2, starting at uniqueSlugStartIndex. Existing IDs from markdown-it-attrs are reused, but load that plugin before markdown-it-anchor; duplicate explicit IDs throw. Only text and inline-code tokens contribute to a slug by default, so images, raw inline HTML, and custom plugin tokens can make a visible heading differ from its fragment. Customize getTokensText if that mismatch matters. Heading elements receive tabindex=-1 unless tabIndex is false, a deliberate accessibility behavior that may affect DOM snapshots or styling. Permalinks are opt-in. Old boolean and legacy permalink options emit a deprecation warning; use anchor.permalink.headerLink, linkAfterHeader, linkInsideHeader, or ariaHidden. Each has real accessibility consequences. headerLink is simple but cannot contain an existing link and may need safariReaderFix. linkAfterHeader's recommended visually-hidden style requires both assistiveText and a CSS class that your stylesheet actually hides accessibly. The plugin mutates markdown-it's token stream, so plugin ordering and custom permalink code need integration tests.
Patterns
Add IDs to every Markdown headingadd-heading-ids
const MarkdownIt = require('markdown-it');
const anchor = require('markdown-it-anchor');
const md = new MarkdownIt().use(anchor);
const html = md.render('# Hello world');
// <h1 id="hello-world" tabindex="-1">Hello world</h1>Permalinks are not inserted by default. The plugin adds an ID and tabindex=-1 to matching headings.
Anchor only selected heading levelsselect-heading-levels
const md = new MarkdownIt().use(anchor, {
level: [2, 3],
});A number means that level and deeper, while an array means exactly those levels. level: 2 includes h2 through h6.
Use a stable custom sluggercustomize-slugs
import slugify from '@sindresorhus/slugify';
import anchor from 'markdown-it-anchor';
md.use(anchor, {
slugify: (title) => slugify(title),
});The slugger is a URL compatibility decision. Pin its package version and test representative Unicode, punctuation, and duplicate headings before publishing links.
Prefix slugs with document stateslug-with-document-state
md.use(anchor, {
slugifyWithState: (title, state) => {
const local = title.trim().toLowerCase().replace(/\s+/g, '-');
return `${state.env.documentId}-${local}`;
},
});
md.render(source, { documentId: 'api-v2' });slugifyWithState takes precedence over slugify. Ensure documentId is already URL-safe or encode it explicitly.
Let authors set a stable heading IDreuse-explicit-heading-id
const attrs = require('markdown-it-attrs');
const md = new MarkdownIt()
.use(attrs)
.use(anchor);
md.render('# Translated title {#account-settings}');Load markdown-it-attrs first so anchor sees and reuses the ID. Duplicate explicit IDs throw instead of receiving automatic suffixes.
Wrap heading contents in a permalinkadd-header-link
const md = new MarkdownIt().use(anchor, {
permalink: anchor.permalink.headerLink({
safariReaderFix: true,
}),
});headerLink is accessible and simple, but a heading cannot legally contain another link. The Safari fix adds a span around heading contents.
Place an accessible permalink after each headingadd-accessible-permalink
const md = new MarkdownIt().use(anchor, {
permalink: anchor.permalink.linkAfterHeader({
style: 'visually-hidden',
assistiveText: (title) => `Permalink to ${title}`,
visuallyHiddenClass: 'sr-only',
symbol: '#',
}),
});You must define an sr-only CSS class using an accessible visually-hidden technique. The renderer throws if required options are missing.
Collect titles and final slugs during renderingcollect-heading-index
const headings = [];
const md = new MarkdownIt().use(anchor, {
callback: (_token, info) => headings.push(info),
});
const html = md.render(source);
// headings contains { title, slug } entries in render orderThe callback sees the final unique slug after duplicate handling, making it safer than independently recreating IDs for a table of contents.
Include custom inline token text in slugscustomize-heading-text
md.use(anchor, {
getTokensText(tokens) {
return tokens
.filter((token) => !['html_inline', 'image'].includes(token.type))
.map((token) => token.content)
.join('');
},
});The default includes only text and code_inline. Broadening token types can change old URLs when another markdown-it plugin changes its token output.
Leave tabindex off generated headingsdisable-heading-tabindex
md.use(anchor, { tabIndex: false });The default -1 helps screen readers announce a heading after fragment navigation. Disable it only after checking the accessibility behavior you want.
Start duplicate slug suffixes at twocustomize-duplicate-suffix
md.use(anchor, { uniqueSlugStartIndex: 2 });
md.render('## Repeat\n\n## Repeat');
// IDs: repeat and repeat-2This affects generated IDs only. Explicit duplicate IDs are treated as author errors and stop rendering.
Route permalinks through a document URLcustomize-permalink-url
md.use(anchor, {
permalink: anchor.permalink.headerLink({
renderHref: (slug, state) =>
`/docs/${encodeURIComponent(state.env.docSlug)}#${slug}`,
renderAttrs: () => ({ 'data-anchor': 'heading' }),
}),
});renderAttrs values become HTML attributes through markdown-it tokens. Keep user input out of attribute names and encode URL path components.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| rehype-slug | npm | Your Markdown pipeline uses unified and rehype rather than markdown-it |
| markdown-it-named-headings | npm | You only need IDs on markdown-it headings and prefer a smaller, older feature set |
| markdown-it-headinganchor | npm | You need classic named anchor elements in an existing markdown-it integration |
| markdown-it-toc-done-right | npm | Your real requirement is a matching table of contents; it is designed as this plugin's companion rather than a permalink replacement |