I got here throughout this superior article navigator by Jhey Tompkins:
It solved a UX downside I used to be dealing with on a challenge, 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 now I’m going to choose it aside and present you the way it all works:
You’ll be able to see I’m imagining this as some kind of navigation that you simply may discover in a web-based studying administration system that powers a web-based course. To summarize what this part 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 gentle and darkish modes, and
- sits fastened on the backside and collapses on scroll.
Additionally, whereas not a characteristic, we received’t be utilizing JavaScript. You may assume that’s unattainable, however the spate of CSS options which have lately shipped make all of this attainable with vanilla CSS, albeit utilizing bleeding-edge methods which might be solely totally supported by Chrome on the time I’m scripting this. So, crack open the most recent model and let’s do that collectively!
The HTML
We’re taking a look at a disclosure widget (the
with id
s on the headings for same-page anchoring. Clicking on the disclosure’s
toggles the course navigation, which is wrapped in a ::details-content
pseudo-element. This navigation hyperlinks to different classes but additionally scrolls to the aforementioned headings of the present lesson.
The
With me to date?
...
Part B
...
Part C
...
Entering into place
First, we’ll place the disclosure with fastened positioning in order that it’s pinned to the underside of the web page:
particulars {
place: fastened;
inset: 24px; /* Use as margin */
place-self: finish middle; /* y x */
}
Establishing CSS-only darkish mode (the brand new approach)
There are particular eventualities the place darkish mode is best 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 which’ll be a better-looking fake checkbox as soon as we’ve sprinkled on some Font Superior, adopted by a
for the checkbox’s textual content label. All of that is then wrapped in an precise
, which is wrapped by the
in order that flexbox hole
s get utilized between all the pieces.
Functionally, though the checkbox is hidden, it toggles each time its label is clicked. And on that observe, it is likely to be a good suggestion to position an express aria-label
on this label, simply to be 100% certain that display readers announce a label, since implicit labels don’t all the time get picked up.
Subsequent we have to put the correct 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 factor 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 in a different way 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 foundation :has
the checked checkbox, apply color-scheme: darkish
. If the foundation does :not(:has)
the unchecked checkbox, then we apply color-scheme: gentle
.
/* If the foundation has a checked checkbox... */
:root:has(enter[type=checkbox]:checked) {
color-scheme: darkish;
}
/* If the foundation doesn't have a checked checkbox... */
:root:not(:has(enter[type=checkbox]:checked)) {
color-scheme: gentle;
}
Should you toggle the checkbox, your net browser’s UI will already toggle between gentle and darkish colour schemes. Now let’s be sure that our demo does the identical factor utilizing the light-dark()
CSS perform, which takes two values — the sunshine mode colour after which the darkish mode colour. You’ll be able to make the most of this perform as a substitute of any colour information sort (afterward we’ll even use it inside a conic gradient).
Within the demo I’m utilizing the identical HSL colour all through however with totally different lightness values, then flipping the lightness values based mostly on the mode:
colour: 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 strategy you select.
Displaying scroll progress
Now let’s show the quantity learn as outlined by the scroll progress, first, as what I prefer to name a “progress pie” after which, second, as a plain-text share. These’ll go within the center a part of the
1. LessonA
What we want is to show the proportion 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 gives CSS with the context it must learn and interpolate the worth between 0
and 100
, which is the utmost worth we wish to help.
So, first, we outline the variable as a customized property:
@property --percentage {
syntax: "";
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 foundation factor:
:root {
animation: updatePercentage;
animation-timeline: scroll();
counter-reset: share var(--percentage);
}
Discover what we’re doing right here: this can be a scroll-driven animation! By setting the animation-timeline
to scroll()
, we’re not operating the animation based mostly on the doc’s timeline however as a substitute based mostly on the consumer’s scroll place. You’ll be able to 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 proportion worth within it utilizing the content material
property and a little bit counter()
hacking (adopted by the proportion 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 simple. It’s a conic gradient made up of two colours which might be positioned utilizing 0%
and the scroll share! Which means that you’ll want that --percentage
variable as an precise share, however you may 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 very important to the UX of this part.
First, right here’s an instance of how the nested lists are marked up:
Let’s reset the listing spacing in CSS:
ol {
padding-left: 0;
list-style-position: inside;
}
padding-left: 0
ensures that the mum or dad listing and all nested lists snap to the left aspect of the disclosure, minus any padding you may wish to add. Don’t fear in regards to the indentation of nested lists — we’ve got one thing deliberate for these. list-style-position: inside
ensures that the listing markers snap to the aspect, fairly than the textual content, inflicting the markers to overflow.
After that, we slap colour: clear
on the ::marker
s of nested
ol ol li::marker {
colour: clear;
}
Lastly, in order that customers can extra simply traverse the present lesson, we’ll dim all listing gadgets that aren’t associated to the present lesson. It’s a type of emphasizing one thing by de-emphasizing others:
particulars {
/* The default colour */
colour: light-dark(hsl(var(--hs) 90%), hsl(var(--hs) 10%));
}
/* s with out .lively that’re direct descendants of the mum or dad */
ol:has(ol) > li:not(.lively) {
/* A much less intense colour */
colour: light-dark(hsl(var(--hs) 80%), hsl(var(--hs) 20%));
}
/* Additionally */
a {
colour: inherit;
}
Yet one more factor… these anchor hyperlinks scroll customers to particular headings, proper? So, placing scroll-behavior: easy
on the foundation to permits easy scrolling between them. And that percentage-read tracker that we created? Yep, that’ll work right here as nicely.
:root {
scroll-behavior: easy; /* 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
factor, however we’ll break it down collectively.
First, we’ll transition from peak: 0
to peak: auto
. It is a brand-new characteristic in CSS! We begin by “opting into” the characteristic 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 forestall the content material from overflowing the disclosure because it transitions out and in:
particulars::details-content {
overflow-y: clip;
}
An alternative 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 permit us to reverse the transition.
After that, slap the identical transition
on each however with totally different values for the transition delays in order that the fade occurs after when opening however earlier than when closing.
Lastly, you’ll additionally have to 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 listing them individually as a substitute in a comma-separated listing. 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. that is why 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 stable 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 stable 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
aria-label
saying the identical factor in order that display readers didn’t announce all that different stuff.
Navigate course
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 personal. (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 eventually, should you’re pro-cursor: pointer
for many interactive components, you’ll wish to apply it to the abstract and manually be sure that the checkbox’s label inherits it, because it doesn’t try this robotically.
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 stated this can be a no-JavaScript deal, however this one-liner will robotically 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 determine.
Setting the popular colour scheme robotically
Setting the popular colour scheme robotically is definitely helpful, however should you prefer to keep away from JavaScript wherever attainable, I don’t assume customers will likely be too mad for not providing this characteristic. Both approach, the next conditional snippet checks if the consumer’s most popular colour 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 remainder.
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 challenge however right into a single part. To summarize, that features:
- a course navigator that reveals the present lesson, all different classes, and easy scrolls between the totally 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 non-obligatory JavaScript that detects the popular colour scheme), and it’s
- all packed right into a single, floating, animated, native disclosure part.
The newer CSS options we lined within the course of:
- Scroll-driven animations
interpolate-size: allow-keywords
for transitioning between0
andauto
- easy scrolling by means of
scroll-behavior: easy
- darkish mode magic utilizing the
light-dark()
perform - a progress chart made with a
conic-gradient()
- styling the
::details-content
pseudo-element - animating the
factor
Due to Jhey for the inspiration! Should you’re not following Jhey on Bluesky or X, you’re lacking out. You may as well see his work on CodePen, a few of which he has talked about proper right here on CSS-Tips.