Friday, October 17, 2025
HomeCSSCase Research: Combining Chopping-Edge CSS Options Right into a “Course Navigation” Element

Case Research: Combining Chopping-Edge CSS Options Right into a “Course Navigation” Element


I got here throughout this superior article navigator by Jhey Tompkins:

It solved a UX drawback I used to be dealing with on a venture, so I’ve tailored it to the wants of an internet course — a “course navigator” if you’ll — and constructed upon it. And immediately I’m going to choose it aside and present you the way it all works:

You possibly can see I’m imagining this as some form of navigation that you just may discover in an internet studying administration system that powers an internet 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 characteristic, we gained’t be utilizing JavaScript. You may assume that’s unattainable, however the spate of CSS options which have just lately shipped make all of this doable 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 a disclosure widget (the <particulars> factor) 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 ids 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 additionally 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 gap scrolled, and a darkish mode toggle.

With me thus far?

<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>

Entering 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 */
}

Organising CSS-only darkish mode (the brand new approach)

There are specific situations 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 an unsightly checkbox enter that’s hidden because of 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 holes 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 observe, it may be a good suggestion to position an specific aria-label on this label, simply to be 100% positive that display screen readers announce a label, since implicit labels don’t all the time 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 fitting icons in there, topic to somewhat conditional logic. Moderately than use Font Superior’s HTML courses and should fiddle with CSS overwrites, we’ll use Font Superior’s CSS properties with our rule logic, as follows:

If the <i> 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 in the event 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 somewhat 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;
}

When you toggle the checkbox, your net browser’s UI will already toggle between mild and darkish coloration schemes. Now let’s guarantee 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 possibly can make the most of this perform as a substitute of any coloration information kind (afterward 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 strategy 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 proportion. 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’d like is to show the share and permit it to “rely” because the scroll place modifications. Usually, that is squarely in JavaScript territory. However now that we will outline our personal customized properties, we will set up a variable known as --percentage that’s formatted as an integer that defaults to a price 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 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 factor:

:root {
  animation: updatePercentage;
  animation-timeline: scroll();
  counter-reset: proportion var(--percentage);
}

Discover what we’re doing right here: it is a scroll-driven animation! By setting the animation-timeline to scroll(), we’re now not working the animation primarily based on the doc’s timeline however as a substitute primarily based on the consumer’s scroll place. You possibly can dig deeper into scroll timelines within the CSS-Methods Almanac.

Since we’re coping with an integer, we will goal the ::earlier than pseudo-element and place the share worth inside it utilizing the content material property and somewhat counter() hacking (adopted by the share image):

#progress-percentage::earlier than {
  content material: counter(proportion) "%";
  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 might be positioned utilizing 0% and the scroll proportion! Which means that you’ll want that --percentage variable as an precise proportion, 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; /* Identical 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 strains of code general, two particular resets are very 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 record spacing in CSS:

ol {
  padding-left: 0;
  list-style-position: inside;
}

padding-left: 0 ensures that the dad or mum record and all nested lists snap to the left facet of the disclosure, minus any padding you may wish to add. Don’t fear concerning the indentation of nested lists — we’ve one thing deliberate for these. list-style-position: inside ensures that the record markers snap to the facet, moderately than the textual content, inflicting the markers to overflow.

After that, we slap coloration: clear on the ::markers of nested <li> components 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 special kind of record 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 record gadgets 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 dad or mum <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; /* Easy anchor scrolling */
  scroll-padding-top: 20px; /* A scroll offset, principally */
}

Transitioning the disclosure

Subsequent, let’s transition the opening and shutting of the ::details-content pseudo-element. By default, the <particulars> factor snaps open and closed when clicked, however we would like a clean transition as a substitute. Geoff just lately detailed how to do that in a complete set of notes concerning the <particulars> factor, however we’ll break it down collectively.

First, we’ll transition from peak: 0 to peak: auto. This can be 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 stop 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 have to be fairly particular concerning 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 focusing 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 record them individually as a substitute in a comma-separated record. 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 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, proportion learn, and darkish mode toggle, the <abstract> factor 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 will 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, in the event you’re pro-cursor: pointer for many interactive components, you’ll wish to apply it to the abstract and manually guarantee that the checkbox’s label inherits it, because it doesn’t do this mechanically.

abstract {
  cursor: pointer;
}

label {
  cursor: inherit;
}

Giving the disclosure an auto-closure mechanism

A tiny little bit of JavaScript couldn’t harm although, might it? I do know I stated it is a no-JavaScript deal, however this one-liner will mechanically 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 mechanically

Setting the popular coloration scheme mechanically is definitely helpful, however in the event you wish to keep away from JavaScript wherever doable, I don’t assume customers will probably be too mad for not providing this characteristic. Both approach, the next conditional snippet checks if the consumer’s most popular 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 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 will mix all of those cutting-edge CSS options, not simply into one venture 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 coated within the course of:

  • Scroll-driven animations
  • interpolate-size: allow-keywords for transitioning between 0 and auto
  • 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> factor

Because of Jhey for the inspiration! When you’re not following Jhey on Bluesky or X, you’re lacking out. You too can see his work on CodePen, a few of which he has talked about proper right here on CSS-Methods.

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments