Share options

Links to related pages

Essays about code. Essays about connection. Essays about context. Critiques of coding practices.

Progressive enhancement

Best for accessibility, usability, performance, sustainability.

Time to read

, 1975 words, 4th grade

Now that weʼve seen how semantic HTML alone produces a fully-responsive web page, where do we go next? Our plain HTML page is ugly as sin, so weʼre gonna need some CSS to spiff it up. But today letʼs keep the CSS simple. Weʼll return to good CSS practices soon in a follow-up article.

On this page

  1. Start with the HTML
  2. Add the initial CSS
  3. Enhance the accordion
  4. Putting it all together

Start with the HTML

To begin, letʼs start with some essential HTML:

The <details> element makes a decent accordion component.
<details class="xx-accordion">
  <summary class="xx-accordion-summary">Always visible</summary>
  <div class="xx-accordion-content">
    <p>Hidden content</p>
    <p>More hidden content</p>
  </div>
</details>

This example shows a details element with CSS class xx-accordion (substitute your own namespace for the “xx”). The details element contains a summary element with class xx-accordion-summary and a div element with class xx-accordion-content. The div element contains two paragraphs of text. This text will be hidden until the details block is opened.

You can see an example page here.

No doubt you have spotted the class attributes. We prefer to apply CSS by class. We can fall back to class-and-tag-name if we need more specificity, as youʼll see below.

So whatʼs up with the “xx-” prefix?

Simple. To avoid name clashes with imported CSS, we namespace our classes. For example, the Craft Code site might use “cc-” as in cc-accordion. Of course, if your site is a simple one, that might be overkill.

Add the initial CSS

Letʼs add a bit of CSS to give it that “accordion” look and feel. Here we go:

It is easy to style the <details> element like an accordion.
.xx-accordion {
  background-color: #fafafa;
  border-radius: 5px;
  border: 1px solid #747481;
  color: #2c2c30;
}

.xx-accordion-summary {
  background-color: #0d4872;
  color: #fafafa;
  font-size: 1.25rem;
  margin: 0;
  padding: 1rem;
}

.xx-accordion-content {
  border-top: 1px solid #747481;
  padding: 0 1rem;
}

This example shows our initial CSS for our accordion component.

To the details element we apply via the CSS class values for the properties background-color, border-radius, border, and color.

To the summary element we apply via the CSS class values for the properties background-color, color, font-size, margin, and padding.

Finally, to the div element containing the content we apply via the CSS class values for the properties border-top and padding.

Again, we provide an example page. We never claimed that it would be pretty.

This is already a workable accordion component. But most accordions have more than one expandable element. So letʼs add a few more elements:

Most accordions have more than one element. (Bios: Wikipedia)
<div class="xx-accordion-group">
  <details class="xx-accordion">
    <summary class="xx-accordion-summary">Katherine Johnson</summary>
    <div class="xx-accordion-content">
      <p>
        <a
          href="https://en.wikipedia.org/wiki/Katherine_Johnson"
          rel="external"
          >Creola Katherine Johnson</a
        >
        was an American mathematician whose calculations ....
      </p>
    </div>
  </details>
  <details class="xx-accordion">
    <summary class="xx-accordion-summary">Dorothy Vaughan</summary>
    <div class="xx-accordion-content">
      <p>
        <a
          href="https://en.wikipedia.org/wiki/Dorothy_Vaughan"
          rel="external"
          >Dorothy Jean Johnson Vaughan</a
        >
        was an American mathematician and human computer who ....
      </p>
    </div>
  </details>
  <details class="xx-accordion">
    <summary class="xx-accordion-summary">Mary Jackson</summary>
    <div class="xx-accordion-content">
      <p>
        <a
          href="https://en.wikipedia.org/wiki/Mary_Jackson_(engineer)"
          rel="external"
          >Mary Jackson</a
        >
        was an American mathematician and aerospace engineer at ....
      </p>
    </div>
  </details>
</div>

This example expands on our basic HTML example above. We wrap our single accordion component in a div element. Then we expand that to three individual accordion components to create a full accordion.

We give the outer div element the CSS class xx-accordion-group. In this example, the three inner details elements contain bios for three famous mathematicians.

We have also adjusted the CSS a bit to make it work with multiple elements:

Minor changes to make the group look good.
.xx-accordion-group {
  border-radius: 5px;
  border: 1px solid #0d4872;
  padding: 0;
}

.xx-accordion {
  background-color: #fafafa;
  color: #2c2c30;
}

.xx-accordion-summary {
  background-color: #0d4872;
  border-bottom: 1px solid #fafafa;
  color: #fafafa;
  font-size: 1.25rem;
  margin: 0;
  padding: 1rem;
}

.xx-accordion:last-child summary {
  border-bottom: none;
}

.xx-accordion-content {
  padding: 0 1rem;
}

In this example, we create a CSS class for xx-accordion-group and provide values for three properties: border-radius (5px), border, and padding (0).

Our CSS for our details elements (CSS class .xx-accordion) is reduced to background-color and color.

Then we enhance our summary elements by setting background-color, border-bottom (removing border-top from the content div), color, font-size, margin (0), and padding (1rem).

We remove the border-bottom from the xx-accordion:last-child summary element and add 1rem of padding to the bottom of the content div elements to provide some spacing between the components of the accordion.

  • We added the .xx-accordion-group properties. And moved the outer border from the individual elements to the group. We also changed the border color to match the <summary> background color.
  • We removed the top border from the content block. Then we replaced it with a bottom border on the <summary> element. We also changed the color to the background color of the <details> elements. This makes it appear as a horizontal rule between the accordion elements.
  • We donʼt want that bottom border on the last element in the accordion group. So we used .xx-accordion:last-child summary to remove that one bottom border.

Here is the full accordion example.

Enhance the accordion

Rats! The accordion elements open and close instantly when you click on the summary. It would be nice if we animated the open and close.

We bet that your first thought will be that we can do this with CSS animations. We thought so, too. But it turns out that it is fiendishly difficult to make that work. By that we mean that we couldnʼt. Sigh.

CSS should always be your first choice for effects, but when CSS fails you, JavaScript steps up. We can do it the way we used to.

CSS transitions for the loss

Naturally, the first thing we tried was a CSS transition on the height of the content block. But this only works if you know the height of the block in advance. It wonʼt work (for us, anyway) with height set to auto or fit-content.

OK, fine! Instead we set the open height to a CSS property with a fallback to auto: var(--xx-accordion-height, auto). That oughta do it.

Now we need to set that property for each accordion element separately. Enter JavaScript!

JavaScript to the rescue

The first thing we want to do is write a function that grabs any and all accordion elements on the page. Then weʼll add an event listener to each of them. And we want our function to run once as soon as the DOM has finished loading.

Here, then, is our first pass at the JavaScript:

Our first pass at adding event handlers to the accordion elements.
function enhanceAccordions() {
  const nodes = document.querySelectorAll(".xx-accordion-content")

  for (let node of nodes) {
    node.parentNode.open = true
    node.style = `--xx-accordion-height: ${node.clientHeight}px`
    node.parentNode.open = false
  }
}

globalThis.addEventListener("DOMContentLoaded", enhanceAccordions)

This example shows our enhanceAccordions function. It is called on DOMContentLoaded.

It uses document.querySelectorAll to grab all the accordion content div elements (CSS class xx-accordion-content). Then it loops through them setting a style CSS property --xx-accordion-height to the clientHeight of the content div.

We will improve on this technique below.

And our modified CSS:

Does it not look like this oughta work?
.xx-accordion-content {
  height: 0;
  overflow-y: hidden;
  padding: 0 1rem;
  transition: height 0.5s ease-in-out;
}

.xx-accordion[open] .xx-accordion-content {
  height: var(--xx-accordion-height, auto);
  transition: height 0.5s ease-in-out;
}

In this example, we update our accordion CSS, setting the height of the content div elements to 0, adding overflow-y: hidden, and a CSS transition on height of half a second and ease-in-out.

Then we select the content div elements on open accordion elements using .xx-accordion[open] .xx-accordion-content. We add the same transition and set the height to our --xx-accordion-height property with a fallback to auto.

And it does work. Beautifully … but intermittently. Try it. And then it stops working at all. And it never animates the close. Why not?

Because when the <details> element toggles closed, it sets its “open” value to false. And thatʼs that.

Slam!

OK, fine. Time to show it whoʼs boss.

Kicking butt and taking names

First, letʼs delete the CSS we added. Forget using a transition! Weʼll do this the old-fashioned way.

First issue: when the <details> element toggles shut, it closes the accordion abruptly. We need to prevent that. To do so, we will seize control.

Instead of handling the toggle event, letʼs capture the click on the <summary> element. Then we will prevent the click from bubbling up.

We still need to know the height of the open accordion, but we donʼt need it in our CSS. So letʼs use a property instead. Weʼll call it xxOpenHeight. (Do remember to replace xx with your own namespace.)

We can grab the open height from a property on the summary.
function toggleAccordion(event) {
  console.log(event.target.xxOpenHeight)
}

function enhanceAccordions() {
  const nodes = document.querySelectorAll(".xx-accordion")

  for (let node of nodes) {
    const summary = node.querySelector("summary")
    const content = node.querySelector(".xx-accordion-content")

    if (content) {
      node.open = true
      summary.xxOpenHeight = content.clientHeight
      node.open = false
    }

    summary?.addEventListener("click", toggleAccordion)
  }
}

globalThis.addEventListener("DOMContentLoaded", enhanceAccordions)

In this example, we extend our enhanceAccordions function and add a first pass at a toggleAccordion handler function to run whenever an accordion summary element is clicked. For now, the toggleAccordion function simply logs out the event.target.xxOpenHeight property, which we will add below.

Then we refactor our enhanceAccordions function. Instead of the content div elements, we grab the parent details elements.

Then we loop through the details elements one by one. We use querySelector on each details element to get the summary element and the content div element.

If a content element is found, we set a property of our own called xxOpenHeight on the summary element to the clientHeight of the content element. Remember to replace xx with your own namespace. We no longer need to set the style property.

Finally, we add an event listener to the click event on the summary element and pass it toggleAccordion. Recall that currently the toggleAccordion merely logs that xxOpenHeight property to the console so we can see that it works. And it does.

Check out the enhanced example and open the browser console. Note how clicking on the a summary prints the height of that accordionʼs content block to the console. Weʼre off to a good start.

Now letʼs complete the toggleAccordion function to animate the accordion elements. Weʼll explain this code line by line below.

Looks complicated, but look closer. It is easy.
function toggleAccordion(event) {
  event.preventDefault()

  const DELAY = 10
  const OPEN_INCREMENT = 15
  const SHUT_INCREMENT = 25

  const summary = event.target
  const accordion = summary.closest("details")
  const content = accordion.querySelector(".xx-accordion-content")
  const openHeight = summary.xxOpenHeight

  if (! (summary && accordion && content)) {
    return
  }

  function shutAccordion() {
    const currentHeight = parseInt(content.style.maxHeight, 10)

    if (currentHeight > 0) {
      content.style.maxHeight = currentHeight < SHUT_INCREMENT
        ? "0"
        : `${currentHeight - SHUT_INCREMENT}px`

      setTimeout(shutAccordion, DELAY)

      return
    }

    accordion.open = false
  }

  if (accordion.open) {
    content.style.maxHeight = `${openHeight}px`

    shutAccordion()

    return
  }

  function openAccordion() {
    accordion.open = true

    const currentHeight = parseInt(content.style.maxHeight, 10)

    if (currentHeight < openHeight) {
      content.style.maxHeight = `${currentHeight + OPEN_INCREMENT}px`

      setTimeout(openAccordion, DELAY)
    }
  }

  content.style.maxHeight = "0px"

  openAccordion()
}

The final version is here.

Letʼs take this one segment at a time. When we break bigger items into smaller ones, comprehension becomes easy.

Breaking it down

We named our enhancement event handler toggleAccordion.
function toggleAccordion(event) {
  event.preventDefault()

  // remaining code here
}

We need to prevent the element from toggling until we want it to. We can do this with event.preventDefault() on line #2 above. Now we are responsible for opening and closing the accordion ourselves.

Use of constants makes the meaning of numbers clear.
  const DELAY = 10
  const OPEN_INCREMENT = 15
  const SHUT_INCREMENT = 25

Now we set three constants to control the speed at which the accordion opens and closes. DELAY is the number of milliseconds between calls to our recursive functions (below). The shorter the delay, the more rapidly we call the function recursively.

OPEN_INCREMENT and SHUT_INCREMENT set the number of pixels that the accordion opens or shuts on each recursive call. The larger the number, the bigger the jumps and the faster the accordion opens or closes.

The trick to these is to play with the settings until you get a smooth open/close animation at a rate that feels right.

Grabbing the HTML elements and getting the openHeight value.
  const summary = event.target
  const accordion = summary.closest("details")
  const content = accordion.querySelector(".xx-accordion-content")
  const openHeight = summary.xxOpenHeight

We can get the target from the event (line #1). This is the HTML summary element on which we clicked. From that, we get the parent details element using summary.closest("details") (line #2).

We can use the querySelector method (line #3) on the details element to grab the div containing the accordion content. Finally, on line #4, we get the xxOpenHeight value that we set as a property on the details element previously.

Now we have the elements we need and the open height of the accordion content.

Preventing errors in our toggleAccordion function.
  if (! (summary && accordion && content)) {
    return
  }

Next we use a simple conditional to check that we found the details, summary, and content div elements. If not, we return immediately from the function as there is nothing we can do. We canʼt manipulate elements that donʼt exist.

We are going to need a shutAccordion function to animate the closing of the accordion. We will call this function recursively. Each call will close the accordion by SHUT_INCREMENT pixels after a delay of DELAY.

The recursive function to animate shutting the accordion.
  function shutAccordion() {
    const currentHeight = parseInt(content.style.maxHeight, 10)

    if (currentHeight > 0) {
      content.style.maxHeight = currentHeight < SHUT_INCREMENT
        ? "0"
        : `${currentHeight - SHUT_INCREMENT}px`

      setTimeout(shutAccordion, DELAY)

      return
    }

    accordion && (accordion.open = false)
  }

On line #2, we get the current height of the content block and parse it into an integer.

We are going to continue the recursion only if our height is greater than zero. So we wrap the recursive call in a conditional block, lines #4 to #12. This ensures that the recursion only happens when currentHeight > 0.

Inside the conditional, on lines #5 to #7, we set the height of the content block. If the current height is less than the SHUT_INCREMENT, then we set the height to 0. If not, then we set the height to the current height minus the SHUT_INCREMENT.

We have now shrunk the content block height by SHUT_INCREMENT pixels. Next, we call the shutAccordion function recursively after a delay of DELAY ms.

Then we return immediately from the current function call, so the code on line #14 above is not applied. When the currentHeight equals 0, we skip the conditional block. Thatʼs when the code on line #14 sets the open property to false.

This permits other event handlers to run.

Deciding when to run shutAccordion.
  if (accordion.open) {
    content.style.maxHeight = `${openHeight}px`

    shutAccordion()

    return
  }

We need to decide when to call our shutAccordion function. As this conditional makes clear, we call it when the accordion is open.

Inside the conditional we need to make sure that the content block is fully open. We set the current content block maxHeight to the openHeight we measured earlier. Then we call shutAccordion and immediately return. Returning prevents the openAccordion function from running as well.

The recursive function to animate opening the accordion.
  function openAccordion() {
    accordion.open = true

    const currentHeight = parseInt(content.style.maxHeight, 10)

    if (currentHeight < openHeight) {
      content.style.maxHeight = `${currentHeight + OPEN_INCREMENT}px`

      setTimeout(openAccordion, DELAY)
    }
  }

Speaking of which, we also need a openAccordion function to animate opening the accordion.

First, we set accordion.open to true. Letʼs not forget that. Then, on line #4, we get the current height of the content block.

If the current height is less than the openHeight, then the block is not yet fully open. We increment that height by OPEN_INCREMENT pixels. Then we recursively call openAccordion after DELAY milliseconds.

Note that this code is never executed if the accordion is closed. So we donʼt need to wrap the following code in another conditional or an else block. Letʼs finish up.

Running openAccordion.
  content.style.maxHeight = "0px"

  openAccordion()

First, letʼs ensure that the accordion is fully closed by setting the maxHeight on the content block to 0px (line #1).

Then we call openAccordion() on line #3 to start the open animation.

Easy peasy when you break it all down!

Putting it all together

Try the final example above. You might notice a problem. The content of the content block becomes visible immediately rather than appearing as the accordion opens. This happens even though the height of the block is zero.

This happens because we havenʼt hidden the overflow. We can do that by adding overflow-y: hidden to the .xx-accordion-content CSS.

Once we do that, then our accordion should animate flawlessly. Now disable JavaScript in the DevTools of your browser. Check it out. It still works. It doesnʼt animate. So what? We can live with that, right?

And if you disable both CSS and JavaScript, then it still works fine! It even works on the Lynx text-only browser, which doesnʼt recognize the <details> element. All that means is that the accordion is always open.

Again, we can live with that.

Thatʼs progressive enhancement for you. Our code is simple and responsive and works for everyone. And if you have CSS and JS enabled, then you get a nicer experience.

Note: we have edited this content to improve it on 7 March 2024.

Links to related pages

Essays about code. Essays about connection. Essays about context. Critiques of coding practices.

Get notified form

Get notified of site updates
Button bar

Carbon emissions for this page

Cleaner than 99% of pages tested
0.017g on first visit; then on return visits 0.011g
QR Code

Scan this code to open this page on another device.