satteri-heading-link adds accessible, keyboard-operable permalinks beside
Markdown headings at build time. It uses a sibling heading-and-link structure.
Experimental: The API, generated HTML, CSS class names, and behavior may change incompatibly between releases.
The plugin only decorates headings that already have an id. Assign IDs in an
earlier processing stage, then run satteriHeadingLink(). This minimal vanilla
Sätteri example enables explicit heading attributes, so the ID exists before
the HAST plugin runs:
import { markdownToHtml } from "satteri";
import { satteriHeadingLink } from "satteri-heading-link";
const result = await markdownToHtml("## Installation {#installation}", {
features: { headingAttributes: true },
hastPlugins: [satteriHeadingLink()],
});For automatically generated slugs, register your heading-ID plugin before this plugin:
hastPlugins: [yourHeadingIdPlugin(), satteriHeadingLink()];Sätteri is the default Markdown and MDX pipeline in Astro 7. Configure the
plugin through @astrojs/markdown-satteri in astro.config.mjs.
Astro provides its own heading-ID plugin. Register it first so every heading
has an ID when satteriHeadingLink() runs:
import { satteri, satteriHeadingIdsPlugin } from "@astrojs/markdown-satteri";
import { defineConfig } from "astro/config";
import { satteriHeadingLink } from "satteri-heading-link";
export default defineConfig({
markdown: {
processor: satteri({
hastPlugins: [() => satteriHeadingIdsPlugin(), satteriHeadingLink()],
}),
},
});The plugin emits class names but does not inject CSS. This page uses the optional preset shipped with the package:
import "satteri-heading-link/preset.css";The preset can be omitted and replaced with site-specific CSS. In that case,
put heading typography, margin, padding, and borders on the
.satteri-heading-link--h1 through .satteri-heading-link--h6 wrappers. Reset
the child .satteri-heading-link__heading to inherit those styles; otherwise a
relative font size can be applied twice and margins or rules can remain on the
wrong element. The preset also gives the child heading a modest scroll offset.
Sites with a fixed header can override it with a larger value.
The headings on this page use the default Starlight-informed link SVG. Move the
pointer over a heading or press Tab to reveal and focus the link. The link’s
accessible name is the visible heading text through aria-labelledby.
A deliberately very long heading demonstrates how the permalink icon wraps together with the final word instead of becoming stranded by itself on a separate line
Resize the viewport to see that the icon does not become an orphaned line by itself. The preset uses logical properties, so the same layout works in RTL content.
Use a string when a simple symbol is enough:
satteriHeadingLink({ icon: "#" });
The icon is decorative (aria-hidden) because the link name comes from the
heading. An SVG HAST node or a callback receiving { id, text, level } can be
used for more control.
The default name follows the heading language. A callback can provide a localized or more descriptive label:
satteriHeadingLink({
accessibleName: ({ text }) => `「${text}」へのリンク`,
});
The default includes h1 through h6, matching rehype-autolink-headings.
Select only the levels you want:
satteriHeadingLink({
levels: [2, 3],
icon: "§",
});Page title
Set icon: false when your own CSS draws the visual icon. The link remains
named and keyboard-operable; only its decorative content is omitted.
satteriHeadingLink({
icon: false,
});.demo-css-owned-icon .satteri-heading-link__link::before {
content: "#";
}- The heading and permalink are sibling elements, so heading navigation stays clean and nested links are avoided.
- The permalink is a native link and remains in the keyboard tab order.
aria-labelledbygives an icon-only link the visible heading as its name.:focus-visibleremains visible even when hover icons are hidden.- The preset expands the pointer target and uses
user-select: noneto avoid copying the icon into a selected heading.
Omit the preset to provide your own styling. The HTML still has its heading semantics, link role, URL, and accessible name.
Headings without IDs are skipped by default. The plugin does not generate slugs, copy text to the clipboard, or run browser JavaScript.