Heading links in Astro
When sharing documentation, I love using heading anchors to highlight a specific section of a page. So when I rebuilt my personal website on Astro, I really wanted to add this feature in. Although I did find a few blog posts which provided a solution, none of them worked for me. I hope this post will help the next person. In the process, I learned a lot about Astro and Islands architecture.
Prerequisites
- By default, the header displays with no hash (# symbol)
- When the user hovers over the header, a clickable hash appears
- The user can click on the hash to get the link to that header
- When the user visits a blog post link hash, they immediately scroll to that heading
- I wanted headings of all levels to have this
Using MDX in Astro
There’s a lot of documentation on integrating MDX in Astro. I immediately reached for the Content Collections API because it allowed me to move existing markdown files from my old SvelteKit setup into Astro. I then used the <Content/>
component to render the markdown file’s content onto the page:
Setting up the blog collection in src/content.config.ts
:
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blogPosts = defineCollection({
loader: glob({ pattern: "*.{md,mdx}", base: "./src/blog" }),
schema: z.object({
title: z.string(),
date: z.date(),
}),
});
export const collections = { blogPosts };
Rendering the blog post content in src/pages/blog/[slug]
, with dynamic routes:
---
import { render } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blogPosts");
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { Content } = await render(post);
---
By default, Astro’s MDX plugin generates ids for each heading. So the next step was to add the hover and anchor link functionality.
Custom HTML elements in MDX content
To customise the HTML in the <Content/>
, I created a separate React component in Heading.tsx
and passed it to the components
prop.
---
import { render } from "astro:content";
import { Heading } from '../Heading.tsx';
const { Content } = await render(post);
---
<Content components={{ h2: Heading }} />
I unfortunately got stuck at this point because the <h2>
s were not being transformed into my custom React component. After debugging, I found that it was because my markdown files were still saved as .md
. As of writing, the out-of-the-box components transformation can only happen for .mdx
files. Running a bash command to convert the .md
files to .mdx
solved the issue.
Handling different heading levels
Because I wanted headings of all levels to have a linkable hash, my plan was to create a dynamic React <Heading/>
component, and pass in a level prop to choose whether an h2
or an h3
was rendered. However, I learned here that Astro does not interpret JSX functions in the same way as React. You cannot use functions which return JSX. I think it’s something to do with Astro rendering everything to static HTML by default. The below example did not work for me at all:
// Don't copy this, it will error
<Content
components={{
h2: (props) => <Heading level={2} {...props} />,
h3: (props) => <Heading level={3} {...props} />,
// etc...
}}
/>
Because I couldn’t pass props into the Heading, I needed to make a component for each heading level. However, I could still cleanly share the anchor link functionality by using the dynamic level
prop within the different heading components.
<Content
components={{
h2: Heading2,
h3: Heading3,
// etc...
}}
/>
---
// In Heading2.astro
import { BaseContentHeading } from "./content-heading.tsx";
---
<BaseContentHeading
level={2} // Change number according to level
{...Astro.props}
>
<slot />
</BaseContentHeading>
I made this work less repetitive by using ChatGPT to create a CLI script which created the different Astro heading components for me.
Using React in Astro
I was ready to write my React component. The working solution, complete with Tailwind classes, looked like this:
import React, { type JSX } from "react";
import { cn } from "src/lib/utils";
type ContentHeadingProps = {
className?: string;
children?: React.ReactNode;
level: 1 | 2 | 3 | 4 | 5 | 6;
id?: string;
};
export const ContentHeading = ({
children,
level,
className,
id,
...rest
}: ContentHeadingProps) => {
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
return (
<Tag tabIndex={id ? -1 : undefined} className={cn("group scroll-mt-8", className)} id={id} {...rest}>
{children}
{id && (
<a
className={cn(
"translate-1 opacity-0 transition-[opacity,transform] group-hover:translate-0 group-hover:opacity-100",
)}
href={`#${id}`}
aria-hidden="true"
>
#
</a>
)}
</Tag>
);
};
A few points to note:
scroll-mt-8
controls the scroll offset of the heading. This means that when a page with the heading hash is loaded and the page scrolls to that heading, some margin is added to the top of the heading so that it doesn’t bump against the top edge of the viewport.tabIndex={-1}
enables focus on the heading so when a page with the heading hash is loaded, the heading is focused. This is so that keyboard and screen reader users understand their location following a hash link.- I added some guardrails to make sure that the heading was not linkable if there was no
id
present. aria-hidden="true"
hides the hash from assistive technologies, preventing the heading from being read out with the hash. This, however, does stop assistive technologies from using the hash links. I tried to avoid this by wrapping theh<2>
and hash in a wrapper div, but I ran into issues getting Tailwind typography classes to work.
Final solution: using Astro components in Astro
When reviewing the near-final solution, I noticed that I wasn’t using React’s useEffect
or useState
. A quick search showed me that it was possible to use dynamic tags in Astro. Because I didn’t need React at all, I converted the component into an Astro component. I don’t think there’s much material difference in using Astro instead of React here, since Astro would statically render the React component anyway. But sticking to Astro components might be a good idea to keep client bundles smaller.
---
import { cn } from "src/lib/utils";
export interface Props {
level: 1 | 2 | 3 | 4 | 5 | 6;
id?: string;
class?: string;
}
type Heading = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
const { level, class: className, id, ...rest } = Astro.props;
const Tag = `h${level}` as Heading;
---
<Tag
tabindex={id ? -1 : null}
class={cn("group scroll-mt-8", className)}
id={id}
{...rest}
>
<slot />
{
id && (
<a
class="link inline-block -translate-x-full opacity-0 transition-[opacity,translate] group-hover:translate-x-0 group-hover:opacity-100"
href={`#${id}`}
aria-hidden="true"
>
#
</a>
)
}
</Tag>