Friday, March 28, 2025
HomeProgrammingCase Research: Combining Chopping-Edge CSS Options Right into a “Course Navigation” Part

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


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

It solved a UX downside I used to be going through on a challenge, so I’ve tailored it to the wants of an internet course — a “course navigator” if you’ll — and constructed upon it. And at the moment I’m going to select it aside and present you the way it all works:

You possibly can see I’m imagining this as some kind of navigation that you simply would possibly 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 function, we gained’t be utilizing JavaScript. You would possibly assume that’s not possible, however the spate of CSS options which have not too long ago shipped make all of this potential with vanilla CSS, albeit utilizing bleeding-edge methods which can be solely absolutely supported by Chrome on the time I’m penning this. So, crack open the most recent model and let’s do that collectively!

The HTML

We’re a disclosure widget (the <particulars> component) 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> comprises a label (because it features as a toggle-disclosure button), the identify 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 heart; /* y x */
}

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

There are particular 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 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 holes get utilized between all the things.

Functionally, regardless that the checkbox is hidden, it toggles every time its label is clicked. And on that notice, it is perhaps 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 sort="checkbox" hidden>
      <i></i>
      <span>Darkish mode</span>
    </label>
        
  </abstract>
    
  <!-- ... -->
  
</particulars>

Subsequent we have to put the proper icons in there, topic to a little bit conditional logic. Quite 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> component 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 case 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 degree (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;
}

Should you toggle the checkbox, your internet browser’s UI will already toggle between mild and darkish coloration schemes. Now let’s be sure that our demo does the identical factor utilizing the light-dark() CSS operate, which takes two values — the sunshine mode coloration after which the darkish mode coloration. You possibly can make the most of this operate as an alternative of any coloration knowledge sort (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 totally different lightness values, then flipping the lightness values based mostly 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() operate 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 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 proportion and permit it to “depend” because the scroll place modifications. 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 component:

: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 based mostly on the doc’s timeline however as an alternative based mostly on the person’s scroll place. You possibly can 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(proportion) "%";
  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 can be positioned utilizing 0% and the scroll proportion! Which means you’ll want that --percentage variable as an precise proportion, however you’ll be able to 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 traces 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="energetic">
      <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 listing spacing in CSS:

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

padding-left: 0 ensures that the father or mother listing and all nested lists snap to the left aspect of the disclosure, minus any padding you would possibly 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 listing markers snap to the aspect, quite than the textual content, inflicting the markers to overflow.

After that, we slap coloration: clear on the ::markers 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 distinct sort of listing 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 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 coloration */
  coloration: light-dark(hsl(var(--hs) 90%), hsl(var(--hs) 10%));
}

/* <li>s with out .energetic that’re direct descendants of the father or mother <ol> */
ol:has(ol) > li:not(.energetic) {
  /* 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> component snaps open and closed when clicked, however we would like a clean transition as an alternative. Geoff not too long ago detailed how to do that in a complete set of notes concerning the <particulars> component, however we’ll break it down collectively.

First, we’ll transition from top: 0 to top: auto. This can be a brand-new function in CSS! We begin by “opting into” the function on the root degree 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 must 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 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 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 listing them individually as an alternative 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 {
  top: 0;
  opacity: 0;
  padding: 0 42px;
  filter: blur(10px);
  border-top: 0 stable light-dark(hsl(var(--hs) 30%), hsl(var(--hs) 70%));
  transition:
    top 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 {
  top: auto;
  opacity: 1;
  padding: 42px;
  filter: blur(0);
  border-top: 1px stable light-dark(hsl(var(--hs) 30%), hsl(var(--hs) 70%));
  transition: 
    top 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> component 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, in case you’re pro-cursor: pointer for many interactive parts, you’ll wish to apply it to the abstract and manually be sure that the checkbox’s label inherits it, because it doesn’t do 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 definitely helpful, however in case you wish to keep away from JavaScript wherever potential, I don’t assume customers shall be too mad for not providing this function. Both approach, the next conditional snippet checks if the person’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 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 challenge 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 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 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 means of scroll-behavior: clean
  • darkish mode magic utilizing the light-dark() operate
  • a progress chart made with a conic-gradient()
  • styling the ::details-content pseudo-element
  • animating the <particulars> component

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

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

Recent Comments