I got here throughout this superior article navigator by Jhey Tompkins:
It solved a UX drawback I used to be dealing with on a undertaking, so I’ve tailored it to the wants of a web based course — a “course navigator” if you’ll — and constructed upon it. And right this moment I’m going to select it aside and present you the way it all works:
You may see I’m imagining this as some type of navigation that you just may discover in a web based studying administration system that powers a web based course. To summarize what this element does, it:
- hyperlinks to all course classes,
- easily scrolls to anchored lesson headings,
- signifies how a lot of the present lesson has been learn,
- toggles between mild and darkish modes, and
- sits mounted on the backside and collapses on scroll.
Additionally, whereas not a function, we received’t be utilizing JavaScript. You may assume that’s not possible, however the spate of CSS options which have lately shipped make all of this doable with vanilla CSS, albeit utilizing bleeding-edge strategies which can be solely totally supported by Chrome on the time I’m penning this. So, crack open the newest model and let’s do that collectively!
The HTML
We’re a disclosure widget (the <particulars>
ingredient) pinned to the underside of the web page with mounted positioning. Behind it? A course lesson (or one thing of that impact) wrapped in an <article>
with id
s on the headings for same-page anchoring. Clicking on the disclosure’s <abstract>
toggles the course navigation, which is wrapped in a ::details-content
pseudo-element. This navigation hyperlinks to different classes but in addition scrolls to the aforementioned headings of the present lesson.
The <abstract>
accommodates a label (because it features as a toggle-disclosure button), the title of the present lesson, the space scrolled, and a darkish mode toggle.
With me to this point?
<particulars>
<!-- The toggle (flex →) -->
<abstract>
<span><!-- Toggle label --></span>
<span><!-- Present lesson + % learn --></span>
<label><!-- Mild/dark-mode toggle --></label>
</abstract>
<!-- ::details-content -->
<!-- Course navigation -->
<!-- /::details-content -->
</particulars>
<article>
<h1 id="sectionA">Part A</h1>
<p>...</p>
<h2 id="sectionB">Part B</h2>
<p>...</p>
<h2 id="sectionC">Part C</h2>
<p>...</p>
</article>
Moving into place
First, we’ll place the disclosure with mounted positioning in order that it’s pinned to the underside of the web page:
particulars {
place: mounted;
inset: 24px; /* Use as margin */
place-self: finish middle; /* y x */
}
Establishing CSS-only darkish mode (the brand new manner)
There are specific eventualities the place darkish mode is healthier for accessibility, particularly for the legibility of long-form content material, so let’s set that up.
First, the HTML. We’ve got an unpleasant checkbox enter that’s hidden due to its hidden
attribute, adopted by an <i>
which’ll be a better-looking fake checkbox as soon as we’ve sprinkled on some Font Superior, adopted by a <span>
for the checkbox’s textual content label. All of that is then wrapped in an precise <label>
, which is wrapped by the <abstract>
. We wrap the label’s content material in a <span>
in order that flexbox hole
s get utilized between every little thing.
Functionally, despite the fact that the checkbox is hidden, it toggles every time its label is clicked. And on that word, it may be a good suggestion to position an express aria-label
on this label, simply to be 100% certain that display screen readers announce a label, since implicit labels don’t at all times get picked up.
<particulars>
<abstract>
<!-- ... -->
<label aria-label="Darkish mode">
<enter kind="checkbox" hidden>
<i></i>
<span>Darkish mode</span>
</label>
</abstract>
<!-- ... -->
</particulars>
Subsequent we have to put the suitable icons in there, topic to a little bit conditional logic. Moderately than use Font Superior’s HTML lessons and need to fiddle with CSS overwrites, we’ll use Font Superior’s CSS properties with our rule logic, as follows:
If the <i>
ingredient is adopted by (discover the next-sibling combinator) a checked checkbox, we’ll show a checked checkbox icon in it. If it’s adopted by an unchecked checkbox, we’ll show an unchecked checkbox icon in it. It’s nonetheless the identical rule logic even should you don’t use Font Superior.
/* Copied from Font Superior’s CSS */
i::earlier than {
font-style: regular;
font-family: "Font Superior 6 Free";
show: inline-block;
width: 1.25em; /* Prevents content material shift when swapping to otherwise sized icons by making all of them have the identical width (that is equal to Font Superior’s .fa-fw class) */
}
/* If adopted by a checked checkbox... */
enter[type=checkbox]:checked + i::earlier than {
content material: "f058";
font-weight: 900;
}
/* If adopted by an unchecked checkbox... */
enter[type=checkbox]:not(:checked) + i::earlier than {
content material: "f111";
font-weight: 400;
}
We have to implement the modes on the root stage (once more, utilizing a little bit conditional logic). If the basis :has
the checked checkbox, apply color-scheme: darkish
. If the basis does :not(:has)
the unchecked checkbox, then we apply color-scheme: mild
.
/* If the basis has a checked checkbox... */
:root:has(enter[type=checkbox]:checked) {
color-scheme: darkish;
}
/* If the basis doesn't have a checked checkbox... */
:root:not(:has(enter[type=checkbox]:checked)) {
color-scheme: mild;
}
In case you toggle the checkbox, your net browser’s UI will already toggle between mild and darkish coloration schemes. Now let’s ensure that our demo does the identical factor utilizing the light-dark()
CSS perform, which takes two values — the sunshine mode coloration after which the darkish mode coloration. You may make the most of this perform as an alternative of any coloration information kind (in a while we’ll even use it inside a conic gradient).
Within the demo I’m utilizing the identical HSL coloration all through however with completely different lightness values, then flipping the lightness values primarily based on the mode:
coloration: light-dark(hsl(var(--hs) 90%), hsl(var(--hs) 10%));
background: light-dark(hsl(var(--hs) 10%), hsl(var(--hs) 90%));
I don’t assume the light-dark()
perform is any higher than swapping out CSS variables, however I don’t imagine it’s any worse both. Completely as much as you so far as which method you select.
Displaying scroll progress
Now let’s show the quantity learn as outlined by the scroll progress, first, as what I wish to name a “progress pie” after which, second, as a plain-text share. These’ll go within the center a part of the <abstract>
:
<particulars>
<abstract>
<!-- ... -->
<span>
<span id="progress-pie"></span>
<span>1. LessonA</span>
<span id="progress-percentage"></span>
</span>
<!-- ... -->
</abstract>
<!-- ... -->
</particulars>
What we want is to show the share and permit it to “rely” because the scroll place adjustments. Usually, that is squarely in JavaScript territory. However now that we are able to outline our personal customized properties, we are able to set up a variable referred to as --percentage
that’s formatted as an integer that defaults to a worth of 0
. This offers CSS with the context it must learn and interpolate the worth between 0
and 100
, which is the utmost worth we wish to assist.
So, first, we outline the variable as a customized property:
@property --percentage {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
Then we outline the animation in keyframes in order that the worth of --percentage
is up to date from 0
to 100
:
@keyframes updatePercentage {
to {
--percentage: 100;
}
}
And, lastly, we apply the animation on the basis ingredient:
:root {
animation: updatePercentage;
animation-timeline: scroll();
counter-reset: share var(--percentage);
}
Discover what we’re doing right here: it is a scroll-driven animation! By setting the animation-timeline
to scroll()
, we’re not operating the animation primarily based on the doc’s timeline however as an alternative primarily based on the person’s scroll place. You may dig deeper into scroll timelines within the CSS-Tips Almanac.
Since we’re coping with an integer, we are able to goal the ::earlier than
pseudo-element and place the share worth inside it utilizing the content material
property and a little bit counter()
hacking (adopted by the share image):
#progress-percentage::earlier than {
content material: counter(share) "%";
min-width: 40px; show: inline-block; /* Prevents content material shift */
}
The progress pie is simply as easy. It’s a conic gradient made up of two colours which can be positioned utilizing 0%
and the scroll share! Which means you’ll want that --percentage
variable as an precise share, however you possibly 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; /* Similar dimensions because the icons */
}
Making a (good) course navigation
Now for the desk contents containing the nested lists of lesson sections inside them, beginning with some resets. Whereas there are extra resets within the demo and extra traces of code total, two particular resets are important to the UX of this element.
First, right here’s an instance of how the nested lists are marked up:
<particulars>
<abstract>
<!-- ... -->
</abstract>
<ol>
<li class="lively">
<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>
</particulars>
Let’s reset the checklist spacing in CSS:
ol {
padding-left: 0;
list-style-position: inside;
}
padding-left: 0
ensures that the father or mother checklist and all nested lists snap to the left facet of the disclosure, minus any padding you may wish to add. Don’t fear in regards to the indentation of nested lists — we’ve one thing deliberate for these. list-style-position: inside
ensures that the checklist markers snap to the facet, moderately than the textual content, inflicting the markers to overflow.
After that, we slap coloration: clear
on the ::marker
s of nested <li>
parts since we don’t want the lesson part titles to be numbered. We’re solely utilizing nested lists for semantics, and nested numbered lists particularly as a result of a unique kind of checklist marker (e.g., bullets) would trigger vertical misalignment between the course’s lesson titles and the lesson part titles.
ol ol li::marker {
coloration: clear;
}
Lastly, in order that customers can extra simply traverse the present lesson, we’ll dim all checklist objects that aren’t associated to the present lesson. It’s a type of emphasizing one thing by de-emphasizing others:
particulars {
/* The default coloration */
coloration: light-dark(hsl(var(--hs) 90%), hsl(var(--hs) 10%));
}
/* <li>s with out .lively that’re direct descendants of the father or mother <ol> */
ol:has(ol) > li:not(.lively) {
/* A much less intense coloration */
coloration: light-dark(hsl(var(--hs) 80%), hsl(var(--hs) 20%));
}
/* Additionally */
a {
coloration: inherit;
}
Yet another factor… these anchor hyperlinks scroll customers to particular headings, proper? So, placing scroll-behavior: clean
on the basis to permits clean scrolling between them. And that percentage-read tracker that we created? Yep, that’ll work right here as nicely.
:root {
scroll-behavior: clean; /* Clean anchor scrolling */
scroll-padding-top: 20px; /* A scroll offset, mainly */
}
Transitioning the disclosure
Subsequent, let’s transition the opening and shutting of the ::details-content
pseudo-element. By default, the <particulars>
ingredient snaps open and closed when clicked, however we wish a clean transition as an alternative. Geoff lately detailed how to do that in a complete set of notes in regards to the <particulars>
ingredient, however we’ll break it down collectively.
First, we’ll transition from peak: 0
to peak: auto
. It is a brand-new function in CSS! We begin by “opting into” the function on the root stage with interpolate-size: allow-keywords
`:
:root {
interpolate-size: allow-keywords;
}
I like to recommend setting overflow-y: clip
on particulars::details-content
to stop the content material from overflowing the disclosure because it transitions out and in:
particulars::details-content {
overflow-y: clip;
}
Another choice is sliding the content material out and then fading it in (and vice-versa), however you’ll must be fairly particular in regards to the transition’s setup.
First, for the “earlier than” and “after” states, you’ll want to focus on each particulars[open]
and particulars:not([open])
, as a result of vaguely concentrating on particulars
after which overwriting the transitioning types with particulars[open]
doesn’t enable us to reverse the transition.
After that, slap the identical transition
on each however with completely different values for the transition delays in order that the fade occurs after when opening however earlier than when closing.
Lastly, you’ll additionally must specify which properties are transitioned. We might merely put the all
key phrase in there, however that’s neither performant nor permits us to set the transition durations and delays for every property. So we’ll checklist them individually as an alternative in a comma-separated checklist. Discover that we’re particularly transitioning the content-visibility
and utilizing the allow-discrete
key phrase as a result of it’s a discrete property. because of this we opted into interpolate-size: allow-keywords
earlier.
particulars:not([open])::details-content {
peak: 0;
opacity: 0;
padding: 0 42px;
filter: blur(10px);
border-top: 0 strong light-dark(hsl(var(--hs) 30%), hsl(var(--hs) 70%));
transition:
peak 300ms 300ms,
padding-top 300ms 300ms,
padding-bottom 300ms 300ms,
content-visibility 300ms 300ms allow-discrete,
filter 300ms 0ms,
opacity 300ms 0ms;
}
particulars[open]::details-content {
peak: auto;
opacity: 1;
padding: 42px;
filter: blur(0);
border-top: 1px strong light-dark(hsl(var(--hs) 30%), hsl(var(--hs) 70%));
transition:
peak 300ms 0ms,
padding-top 300ms 0ms,
padding-bottom 300ms 0ms,
content-visibility 300ms 0ms allow-discrete,
filter 300ms 300ms,
opacity 300ms 300ms;
}
Giving the abstract a label and icons
Previous the present lesson’s title, share learn, and darkish mode toggle, the <abstract>
ingredient wants a label that helps describe what it does. I went with “Navigate course” and included an aria-label
saying the identical factor in order that display screen readers didn’t announce all that different stuff.
<particulars>
<abstract aria-label="Navigate course">
<span>
<i></i>
<span>Navigate course</span>
</span>
<!-- ... -->
</abstract>
<!-- ... -->
</particulars>
As well as, the abstract will get show: flex
in order that we are able to simply separate the three sections with a hole
, which additionally removes the abstract’s default marker, permitting you to make use of your individual. (Once more, I’m utilizing Font Superior within the demo.)
i::earlier than {
width: 1.25em;
font-style: regular;
show: inline-block;
font-family: "Font Superior 6 Free";
}
particulars i::earlier than {
content material: "f0cb"; /* fa-list-ol */
}
particulars[open] i::earlier than {
content material: "f00d"; /* fa-xmark */
}
/* For older Safari */
abstract::-webkit-details-marker {
show: none;
}
And at last, should you’re pro-cursor: pointer
for many interactive parts, you’ll wish to apply it to the abstract and manually ensure that the checkbox’s label inherits it, because it doesn’t try this routinely.
abstract {
cursor: pointer;
}
label {
cursor: inherit;
}
Giving the disclosure an auto-closure mechanism
A tiny little bit of JavaScript couldn’t damage although, might it? I do know I mentioned it is a no-JavaScript deal, however this one-liner will routinely shut the disclosure when the mouse leaves it:
doc.querySelector("particulars").addEventListener("mouseleave", e => e.goal.removeAttribute("open"));
Annoying or helpful? I’ll allow you to resolve.
Setting the popular coloration scheme routinely
Setting the popular coloration scheme routinely is actually helpful, however should you wish to keep away from JavaScript wherever doable, I don’t assume customers will likely be too mad for not providing this function. Both manner, the next conditional snippet checks if the person’s most well-liked coloration scheme is “darkish” by evaluating the related CSS media question (prefers-color-scheme: darkish
) utilizing window.matchMedia
and matches
. If the situation is met, the checkbox will get checked, after which the CSS handles the remaining.
if (window.matchMedia("prefers-color-scheme: darkish").matches) {
doc.querySelector("enter[type=checkbox]").checked = true;
}
Recap
This has been enjoyable! It’s such a blessing we are able to mix all of those cutting-edge CSS options, not simply into one undertaking however right into a single element. To summarize, that features:
- a course navigator that reveals the present lesson, all different classes, and clean scrolls between the completely different headings,
- a percentage-scrolled tracker that reveals the quantity learn in plain textual content and as a conic gradient… pie chart,
- a light-weight/dark-mode toggle (with some elective JavaScript that detects the popular coloration scheme), and it’s
- all packed right into a single, floating, animated, native disclosure element.
The newer CSS options we lined within the course of:
- Scroll-driven animations
interpolate-size: allow-keywords
for transitioning between0
andauto
- clean scrolling by the use of
scroll-behavior: clean
- darkish mode magic utilizing the
light-dark()
perform - a progress chart made with a
conic-gradient()
- styling the
::details-content
pseudo-element - animating the
<particulars>
ingredient
Because of Jhey for the inspiration! In case you’re not following Jhey on Bluesky or X, you’re lacking out. It’s also possible to see his work on CodePen, a few of which he has talked about proper right here on CSS-Tips.
Case Research: Combining Reducing-Edge CSS Options Right into a “Course Navigation” Element initially revealed on CSS-Tips, which is a part of the DigitalOcean household. You must get the publication.
Subscribe to MarketingSolution.
Receive web development discounts & web design tutorials.
Now! Lets GROW Together!