I came across this awesome article navigator by Jhey Tompkins:
It solved a UX problem I was facing on a project, so I’ve adapted it to the needs of an online course — a “course navigator” if you will — and built upon it. And today I’m going to pick it apart and show you how it all works:
You can see I’m imagining this as some sort of navigation that you might find in an online learning management system that powers an online course. To summarize what this component does, it:
- links to all course lessons,
- smoothly scrolls to anchored lesson headings,
- indicates how much of the current lesson has been read,
- toggles between light and dark modes, and
- sits fixed at the bottom and collapses on scroll.
Also, while not a feature, we won’t be using JavaScript. You might think that’s impossible, but the spate of CSS features that have recently shipped make all of this possible with vanilla CSS, albeit using bleeding-edge techniques that are only fully supported by Chrome at the time I’m writing this. So, crack open the latest version and let’s do this together!
The HTML
We’re looking at a disclosure widget (the <details>
element) pinned to the bottom of the page with fixed positioning. Behind it? A course lesson (or something of that effect) wrapped in an <article>
with id
s on the headings for same-page anchoring. Clicking on the disclosure’s <summary>
toggles the course navigation, which is wrapped in a ::details-content
pseudo-element. This navigation links to other lessons but also scrolls to the aforementioned headings of the current lesson.
The <summary>
contains a label (since it functions as a toggle-disclosure button), the name of the current lesson, the distance scrolled, and a dark mode toggle.
With me so far?
<details>
<!-- The toggle (flex →) -->
<summary>
<span><!-- Toggle label --></span>
<span><!-- Current lesson + % read --></span>
<label><!-- Light/dark-mode toggle --></label>
</summary>
<!-- ::details-content -->
<!-- Course navigation -->
<!-- /::details-content -->
</details>
<article>
<h1 id="sectionA">Section A</h1>
<p>...</p>
<h2 id="sectionB">Section B</h2>
<p>...</p>
<h2 id="sectionC">Section C</h2>
<p>...</p>
</article>
Getting into position
First, we’ll place the disclosure with fixed positioning so that it’s pinned to the bottom of the page:
details {
position: fixed;
inset: 24px; /* Use as margin */
place-self: end center; /* y x */
}
Setting up CSS-only dark mode (the new way)
There are certain scenarios where dark mode is better for accessibility, especially for the legibility of long-form content, so let’s set that up.
First, the HTML. We have an ugly checkbox input that’s hidden thanks to its hidden
attribute, followed by an <i>
which’ll be a better-looking faux checkbox once we’ve sprinkled on some Font Awesome, followed by a <span>
for the checkbox’s text label. All of this is then wrapped in an actual <label>
, which is wrapped by the <summary>
. We wrap the label’s content in a <span>
so that flexbox gap
s get applied between everything.
Functionally, even though the checkbox is hidden, it toggles whenever its label is clicked. And on that note, it might be a good idea to place an explicit aria-label
on this label, just to be 100% sure that screen readers announce a label, since implicit labels don’t always get picked up.
<details>
<summary>
<!-- ... -->
<label aria-label="Dark mode">
<input type="checkbox" hidden>
<i></i>
<span>Dark mode</span>
</label>
</summary>
<!-- ... -->
</details>
Next we need to put the right icons in there, subject to a little conditional logic. Rather than use Font Awesome’s HTML classes and have to mess around with CSS overwrites, we’ll use Font Awesome’s CSS properties with our rule logic, as follows:
If the <i>
element is followed by (notice the next-sibling combinator) a checked checkbox, we’ll display a checked checkbox icon in it. If it’s followed by an unchecked checkbox, we’ll display an unchecked checkbox icon in it. It’s still the same rule logic even if you don’t use Font Awesome.
/* Copied from Font Awesome’s CSS */
i::before {
font-style: normal;
font-family: "Font Awesome 6 Free";
display: inline-block;
width: 1.25em; /* Prevents content shift when swapping to differently sized icons by making them all have the same width (this is equivalent to Font Awesome’s .fa-fw class) */
}
/* If followed by a checked checkbox... */
input[type=checkbox]:checked + i::before {
content: "f058";
font-weight: 900;
}
/* If followed by an unchecked checkbox... */
input[type=checkbox]:not(:checked) + i::before {
content: "f111";
font-weight: 400;
}
We need to implement the modes at the root level (again, using a little conditional logic). If the root :has
the checked checkbox, apply color-scheme: dark
. If the root does :not(:has)
the unchecked checkbox, then we apply color-scheme: light
.
/* If the root has a checked checkbox... */
:root:has(input[type=checkbox]:checked) {
color-scheme: dark;
}
/* If the root does not have a checked checkbox... */
:root:not(:has(input[type=checkbox]:checked)) {
color-scheme: light;
}
If you toggle the checkbox, your web browser’s UI will already toggle between light and dark color schemes. Now let’s make sure that our demo does the same thing using the light-dark()
CSS function, which takes two values — the light mode color and then the dark mode color. You can utilize this function instead of any color data type (later on we’ll even use it within a conic gradient).
In the demo I’m using the same HSL color throughout but with different lightness values, then flipping the lightness values based on the mode:
color: light-dark(hsl(var(--hs) 90%), hsl(var(--hs) 10%));
background: light-dark(hsl(var(--hs) 10%), hsl(var(--hs) 90%));
I don’t think the light-dark()
function is any better than swapping out CSS variables, but I don’t believe it’s any worse either. Totally up to you as far as which approach you choose.
Displaying scroll progress
Now let’s display the amount read as defined by the scroll progress, first, as what I like to call a “progress pie” and then, second, as a plain-text percentage. These’ll go in the middle part of the <summary>
:
<details>
<summary>
<!-- ... -->
<span>
<span id="progress-pie"></span>
<span>1. LessonA</span>
<span id="progress-percentage"></span>
</span>
<!-- ... -->
</summary>
<!-- ... -->
</details>
What we need is to display the percentage and allow it to “count” as the scroll position changes. Normally, this is squarely in JavaScript territory. But now that we can define our own custom properties, we can establish a variable called --percentage
that is formatted as an integer that defaults to a value of 0
. This provides CSS with the context it needs to read and interpolate the value between 0
and 100
, which is the maximum value we want to support.
So, first, we define the variable as a custom property:
@property --percentage {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
Then we define the animation in keyframes so that the value of --percentage
is updated from 0
to 100
:
@keyframes updatePercentage {
to {
--percentage: 100;
}
}
And, finally, we apply the animation on the root element:
:root {
animation: updatePercentage;
animation-timeline: scroll();
counter-reset: percentage var(--percentage);
}
Notice what we’re doing here: this is a scroll-driven animation! By setting the animation-timeline
to scroll()
, we’re no longer running the animation based on the document’s timeline but instead based on the user’s scroll position. You can dig deeper into scroll timelines in the CSS-Tricks Almanac.
Since we’re dealing with an integer, we can target the ::before
pseudo-element and place the percentage value inside of it using the content
property and a little counter()
hacking (followed by the percentage symbol):
#progress-percentage::before {
content: counter(percentage) "%";
min-width: 40px; display: inline-block; /* Prevents content shift */
}
The progress pie is just as straightforward. It’s a conic gradient made up of two colors that are positioned using 0%
and the scroll percentage! This means that you’ll need that --percentage
variable as an actual percentage, but you can convert it into such by multiplying it by 1%
(calc(var(--percentage) * 1%)
)!
#progress-pie {
aspect-ratio: 1;
background: conic-gradient(hsl(var(--hs) 50%) calc(var(--percentage) * 1%), light-dark(hsl(var(--hs) 90%), hsl(var(--hs) 10%)) 0%);
border-radius: 50%; /* Make it a circle */
width: 17px; /* Same dimensions as the icons */
}
Creating a (good) course navigation
Now for the table contents containing the nested lists of lesson sections within them, starting with some resets. While there are more resets in the demo and more lines of code overall, two specific resets are vital to the UX of this component.
First, here’s an example of how the nested lists are marked up:
<details>
<summary>
<!-- ... -->
</summary>
<ol>
<li class="active">
<a>LessonA</a>
<ol>
<li><a href="#sectionA">SectionA</a></li>
<li><a href="#sectionB">SectionB</a></li>
<li><a href="#sectionC">SectionC</a></li>
</ol>
</li>
<li><a>LessonB</a></li>
<li><a>LessonC</a></li>
</ol>
</details>
Let’s reset the list spacing in CSS:
ol {
padding-left: 0;
list-style-position: inside;
}
padding-left: 0
ensures that the parent list and all nested lists snap to the left side of the disclosure, minus any padding you might want to add. Don’t worry about the indentation of nested lists — we have something planned for those. list-style-position: inside
ensures that the list markers snap to the side, rather than the text, causing the markers to overflow.
After that, we slap color: transparent
on the ::marker
s of nested <li>
elements since we don’t need the lesson section titles to be numbered. We’re only using nested lists for semantics, and nested numbered lists specifically because a different type of list marker (e.g., bullets) would cause vertical misalignment between the course’s lesson titles and the lesson section titles.
ol ol li::marker {
color: transparent;
}
Finally, so that users can more easily traverse the current lesson, we’ll dim all list items that aren’t related to the current lesson. It’s a form of emphasizing something by de-emphasizing others:
details {
/* The default color */
color: light-dark(hsl(var(--hs) 90%), hsl(var(--hs) 10%));
}
/* <li>s without .active that’re direct descendants of the parent <ol> */
ol:has(ol) > li:not(.active) {
/* A less intense color */
color: light-dark(hsl(var(--hs) 80%), hsl(var(--hs) 20%));
}
/* Also */
a {
color: inherit;
}
One more thing… those anchor links scroll users to specific headings, right? So, putting scroll-behavior: smooth
on the root to enables smooth scrolling between them. And that percentage-read tracker that we created? Yep, that’ll work here as well.
:root {
scroll-behavior: smooth; /* Smooth anchor scrolling */
scroll-padding-top: 20px; /* A scroll offset, basically */
}
Transitioning the disclosure
Next, let’s transition the opening and closing of the ::details-content
pseudo-element. By default, the <details>
element snaps open and closed when clicked, but we want a smooth transition instead. Geoff recently detailed how to do this in a comprehensive set of notes about the <details>
element, but we’ll break it down together.
First, we’ll transition from height: 0
to height: auto
. This is a brand-new feature in CSS! We start by “opting into” the feature at the root level with interpolate-size: allow-keywords
`:
:root {
interpolate-size: allow-keywords;
}
I recommend setting overflow-y: clip
on details::details-content
to prevent the content from overflowing the disclosure as it transitions in and out:
details::details-content {
overflow-y: clip;
}
Another option is sliding the content out and then fading it in (and vice-versa), but you’ll need to be quite specific about the transition’s setup.
First, for the “before” and “after” states, you’ll need to target both details[open]
and details:not([open])
, because vaguely targeting details
and then overwriting the transitioning styles with details[open]
doesn’t allow us to reverse the transition.
After that, slap the same transition
on both but with different values for the transition delays so that the fade happens after when opening but before when closing.
Finally, you’ll also need to specify which properties are transitioned. We could simply put the all
keyword in there, but that is neither performant nor allows us to set the transition durations and delays for each property. So we’ll list them individually instead in a comma-separated list. Notice that we’re specifically transitioning the content-visibility
and using the allow-discrete
keyword because it is a discrete property. this is why we opted into interpolate-size: allow-keywords
earlier.
details:not([open])::details-content {
height: 0;
opacity: 0;
padding: 0 42px;
filter: blur(10px);
border-top: 0 solid light-dark(hsl(var(--hs) 30%), hsl(var(--hs) 70%));
transition:
height 300ms 300ms,
padding-top 300ms 300ms,
padding-bottom 300ms 300ms,
content-visibility 300ms 300ms allow-discrete,
filter 300ms 0ms,
opacity 300ms 0ms;
}
details[open]::details-content {
height: auto;
opacity: 1;
padding: 42px;
filter: blur(0);
border-top: 1px solid light-dark(hsl(var(--hs) 30%), hsl(var(--hs) 70%));
transition:
height 300ms 0ms,
padding-top 300ms 0ms,
padding-bottom 300ms 0ms,
content-visibility 300ms 0ms allow-discrete,
filter 300ms 300ms,
opacity 300ms 300ms;
}
Giving the summary a label and icons
Preceding the current lesson’s title, percentage read, and dark mode toggle, the <summary>
element needs a label that helps describe what it does. I went with “Navigate course” and included an aria-label
saying the same thing so that screen readers didn’t announce all that other stuff.
<details>
<summary aria-label="Navigate course">
<span>
<i></i>
<span>Navigate course</span>
</span>
<!-- ... -->
</summary>
<!-- ... -->
</details>
In addition, the summary gets display: flex
so that we can easily separate the three sections with a gap
, which also removes the summary’s default marker, allowing you to use your own. (Again, I’m using Font Awesome in the demo.)
i::before {
width: 1.25em;
font-style: normal;
display: inline-block;
font-family: "Font Awesome 6 Free";
}
details i::before {
content: "f0cb"; /* fa-list-ol */
}
details[open] i::before {
content: "f00d"; /* fa-xmark */
}
/* For older Safari */
summary::-webkit-details-marker {
display: none;
}
And finally, if you’re pro-cursor: pointer
for most interactive elements, you’ll want to use it on the summary and manually make sure that the checkbox’s label inherits it, as it doesn’t do that automatically.
summary {
cursor: pointer;
}
label {
cursor: inherit;
}
Giving the disclosure an auto-closure mechanism
A tiny bit of JavaScript couldn’t hurt though, could it? I know I said this is a no-JavaScript deal, but this one-liner will automatically close the disclosure when the mouse leaves it:
document.querySelector("details").addEventListener("mouseleave", e => e.target.removeAttribute("open"));
Annoying or useful? I’ll let you decide.
Setting the preferred color scheme automatically
Setting the preferred color scheme automatically is certainly useful, but if you like to avoid JavaScript wherever possible, I don’t think users will be too mad for not offering this feature. Either way, the following conditional snippet checks if the user’s preferred color scheme is “dark” by evaluating the relevant CSS media query (prefers-color-scheme: dark
) using window.matchMedia
and matches
. If the condition is met, the checkbox gets checked, and then the CSS handles the rest.
if (window.matchMedia("prefers-color-scheme: dark").matches) {
document.querySelector("input[type=checkbox]").checked = true;
}
Recap
This has been fun! It’s such a blessing we can combine all of these cutting-edge CSS features, not just into one project but into a single component. To summarize, that includes:
- a course navigator that shows the current lesson, all other lessons, and smooth scrolls between the different headings,
- a percentage-scrolled tracker that shows the amount read in plain text and as a conic gradient… pie chart,
- a light/dark-mode toggle (with some optional JavaScript that detects the preferred color scheme), and it is
- all packed into a single, floating, animated, native disclosure component.
The newer CSS features we covered in the process:
- Scroll-driven animations
interpolate-size: allow-keywords
for transitioning between0
andauto
- smooth scrolling by way of
scroll-behavior: smooth
- dark mode magic using the
light-dark()
function - a progress chart made with a
conic-gradient()
- styling the
::details-content
pseudo-element - animating the
<details>
element
Thanks to Jhey for the inspiration! If you’re not following Jhey on Bluesky or X, you’re missing out. You can also see his work on CodePen, some of which he has talked about right here on CSS-Tricks.
Case Study: Combining Cutting-Edge CSS Features Into a “Course Navigation” Component