The following refers to the DEVONthink Markdown renderer! Make sure to select it in the global DT preferences if you want to play around with the TOC as described here.
Edit: I modified the CSS and JS code so that they work with both MD renderers now.
You can add a table of contents to your rendered Markdown document with the {{TOC}} instruction. This will produce a bare-bones list of links to all the headings in your document. As it stands, the TOC is not only very detailed, it’s also placed at the top of the content where it scrolls out of sight when you scroll the content. The following sections will explore several ways to modify the default TOC.{{TOC}}does not take any options, so you will get all headings from # down to ###### in the table of contents (TOC).
Keep the TOC fixed when the document scrolls
If you want to keep the TOC always in sight, even when your document scrolls, here’s some CSS to achieve that.
body {
margin-left: 2rem;
width: 40rem;
}
.table-of-contents, .TOC {
left: 50rem;
position: fixed;
}
These lines limit the width of the document (body) to 40rem, which stands for 40 times the size of the body's font. If your body element’s font size is 16px, the document will be 640px wide. Alternatively, you could use a percentage value like 80% for the body’s width property.
Now, the .table-of-contents rule address the TOC (which is identified by the class name table-of-contents). It pushes the TOC 50rem to the right and sets its position to fixed. The document itself will always be to the left of the TOC because it is only 40rem wide and the TOC leaves a 50rem wide empty space to its left.
When you scroll an MD document with a TOC and the CSS as shown above, the TOC will stay at the right upper corner so that it is always visible.
Add a heading to the TOC
While the list of links in the TOC might indicate their purpose, a heading would perhaps be nice, too. Again, CSS can easily help with that:
.table-of-contents::before, .TOC::before {
content: "Table of content";
font-size: 1.5rem;
font-weight: bold;
display: inline-block;
}
Here, ::before creates a pseudo-element right before the TOC itself. It can be styled as any other element (here with a bold font that is 1.5rem tall). More importantly, you can add a text to this pseudo-element with the content property. The display is set to inline-block to ensure a line break after the heading.
Exclude headings from the TOC
Edit: While the following works, it is not needed at all. @MsLogica shows a far simpler solution below.
To limit the headings that end up in the TOC, you can again use CSS. Let’s first take a look at the rendered TOC. If you inspect it in a browser’s developer tools, you will see something like this:
<ul>
<li>First level heading</li>
<ul>
<li>Second level heading</li>
<ul>
<li>Third level heading</li>
...
</ul>
</ul>
</ul>
If you wanted to exclude all headings including and below the third level, you’d have to do something like
.table-of-contents ul ul ul, .TOC ul ul` {
display: none;
}
in your CSS.
Note: This is a crude way to handle it. The MMD6 and the DT renderer differ subtly in what they do with missing heading levels: DT inserts and empty ul element, MMD6 does not. Therefore, the selectors in the above CSS look slightly different for both cases. A more robust solution would have to be written in JavaScript and check which heading levels are present in the document before setting the display property of the unwanted ones to none. Or remove them altogether from the TOC.
IMO, the It does, as @MsLogica pointed out below.{{TOC}} should accept parameters to select the maximum heading level appearing in the TOC.
Highlight the current section in the TOC
If you have a long document, it would be nice if the TOC indicates the section you are currently seeing even if the corresponding heading is not visible currently. However, this is not possible with CSS alone. Some JavaScript is needed, too:
document.addEventListener('DOMContentLoaded', e => {
const highlightClass = 'highlight-entry'
const toc = document.querySelector('.table-of-contents, .TOC');
if (toc) {
const callback = (entries, observer) => {
entries.forEach((entry) => {
const id = entry.target.id;
const tocLink = toc.querySelector(`a[href="#${id}"]`);
if (entry.isIntersecting) {
const lastHeading = toc.querySelector(`.${highlightClass`);
lastHeading?.classList.remove(highlightClass);
tocLink.classList.add(highlightClass);
}
});
};
// Configure observer options
const options = {
root: null, // Use the viewport as the root
rootMargin: '0px', // No margin around the root
threshold: 0.1, // Trigger when 10% of the target is visible
};
const observer = new IntersectionObserver(callback, options);
// Start observing the target
document.querySelectorAll('h1, h2').forEach((heading) => {
observer.observe(heading);
});
}
})
I’m not going to explain all of the code here. Its main purpose is to install an IntersectionObserver for all the headings in the document that are visible in the TOC. This happens at the very end with
document.querySelectorAll('h1, h2').forEach((heading) => {
observer.observe(heading);
});
Here, we are only interested in first and second level headings (h1, h2). Note that you should not include headings in this list that you have removed from the TOC via CSS.
The callback handling IntersectionObserver events is quite straightforward: If an observed heading scrolls into view
- a previously highlighted TOC entry is un-highlighted, and
- the current TOC entry is highlighted
To highlight an entry, we add a class to it. Defining the attributes of this class in the CSS like so
.highlight-entry {
font-width: bold;
}
would make the heading of the currently visible section bold in the TOC.

