Progressive enhancement
Time to read
, 1544 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.
Progressive enhancement starts with something that is already functional and usable. Then it adds just enough JavaScript to enhance that functionality.
In this essay weʼll take a simple <details>
element, which will work in any browser, and make it into an accordion.
The <details>
element with its <summary>
is already an accordion. But it doesnʼt look like one.
Start with the HTML
To begin, letʼs start with some essential HTML:
<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.
Adding the initial CSS
Letʼs add a bit of CSS to give it that “accordion” look and feel. Here we go:
<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.
This is already a workable accordion component. But most accordions have more than one expandable element. So letʼs add a few more elements:
<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 of orbital mechanics as a NASA employee
were critical to the success of the first and subsequent
U.S. crewed spaceflights.
</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 worked for the
National Advisory Committee for Aeronautics (NACA), and
NASA, at Langley Research Center in Hampton, Virginia.
</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 the National Advisory Committee
for Aeronautics (NACA), which in 1958 was succeeded by
the National Aeronautics and Space Administration (NASA).
</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:
.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.
Enhancing our 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:
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:
.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.)
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.
function toggleAccordion (event) {
event.preventDefault()
const summary = event.target
const accordion = summary.closest("details")
const content = accordion.querySelector(".xx-accordion-content")
const openHeight = summary.xxOpenHeight
if (accordion.open) {
content.style.maxHeight = `${openHeight}px`
function shutAccordion() {
const maxHeight = parseInt(content.style.maxHeight, 10)
if (maxHeight > 0) {
content.style.maxHeight = maxHeight < 25
? "0"
: `${maxHeight - 25}px`
setTimeout(shutAccordion, 10)
return
}
accordion.open = false
}
shutAccordion()
return
}
content.style.maxHeight = "0px"
function openAccordion() {
const maxHeight = parseInt(content.style.maxHeight, 10)
accordion.open = true
if (maxHeight < openHeight) {
content.style.maxHeight = `${maxHeight + 15}px`
setTimeout(openAccordion, 10)
}
}
openAccordion()
}
In this, our final example, we extend the toggleAccordion
function to animate the opening and closing of our accordion element.
The first thing we do is to call
preventDefault
on the passed event
object.
This prevents the
toggle
event on the details
element from
running and suddenly opening or closing the accordion element.
Then we get the summary
element using the event.target
, its parent details
element using summary.closest("details")
, the content div
element with querySelector(".xx-accordion-conent")
on the details
element, and, finally, the xxOpenHeight
property from the summary
element.
Then we branch our code depending on whether accordion.open
is true or false.
If it is true, then we create a shutAccordion
inner
function. In it we set the style.maxHeight
property on
our content div
element to the xxOpenHeight
in pixels. We are going to
gradually decrement this value to animate the accordion element shut.
Then, while maxHeight
is greater than zero, we decrement
it by 25 pixels every 10 milliseconds by recursively calling our shutAccordion
using setTimeout
.
When the maxHeight
is zero (the accordion is fully shut),
we set accordion.open
to false
. Then we call
shutAccordion
to start the closing animation.
If, conversely, accordion.open
was false, meaning it is
closed and we are now animating its opening, then we begin by setting style.maxHeight
to zero pixels on the content div
element.
Now we create an openAccordion
inner function. We use parseInt
to get the current maxHeight
of the content div
(we will be incrementing this). And we set the accordion.open
value to true
. Remember that maxHeight
is
zero.
Then, while maxHeight
is less than our openHeight
, we increment the maxHeight
by 15 pixels and call the openAccordion
function
recursively with a setTimeout
function every 10
millseconds.
Finally, having created the inner openAccordion
function,
we call it to start the open animation.
We could also do this with animation frames, but this works just fine.
-
We need to prevent the element from toggling. We can
do this with
event.preventDefault()
on line #2 above. Now we are responsible for opening and closing the accordion ourselves. -
Next, we use the
event.target
to get the<details>
(accordion) element, our content block, and thexxOpenHeight
. Lines #4 to #7. - Now we have two possibilities. Either the accordion is open and we want to close it, or vice versa. So we check if the accordion is open on line #9.
-
We need the
maxHeight
property of the content blockʼsstyle
attribute. We will use it to control the height of the block. So if the accordion is open, then we must make sure thatmaxHeight
it is set toxxOpenHeight
. See line #10. -
Now we will use
setTimeout
recursively to reduce themaxHeight
bit by bit. Weʼll call it every few milliseconds until we have shut the accordion. We create ourshutAccordion
function on lines #12 to #26. -
Hmm …
maxHeight
is a string. Urk. So we will convert it to a number withparseInt
to decrement it. Then weʼll recreate the string. We get the integer on line #13. If ourmaxHeight
is still greater than zero, we subtract 25 from it on lines #16 to #18. Then we call ourshutAccordion
function recursively. We set the timeout to 10 milliseconds. Thatʼs a nice frame rate. -
When the
maxHeight
reaches zero, we setaccordion.open
tofalse
(line #25) and weʼre good to go. -
Now that weʼve defined our
shutAccordion
recursive function, we need to call it to start the recursion (animation). We do so on line #28. -
But what if the accordion is closed? Then we set the
maxHeight
to zero on line #33 to be sure. This also covers the initial case. -
We define our
openAccordion
recursive function on lines #35 to #45. -
Again, we begin by parsing the height from
maxHeight
on line #36. And we immediately set the accordion to “open” on line #38. This has no visual effect yet because the height of the content block is currently zero. -
Now, on lines #40 to #44, if the
maxHeight
is still less than the measuredopenHeight
, we add 15 pixels to themaxHeight
. Then we call the function again usingsetTimeout
to delay for 10 milliseconds. When the content block is fully open, this conditional will fail and the recursion will stop. -
And, as with the
shutAccordion
function above, we have to call the function to start the animation. We do this on line #47.
Not that difficult!
Putting it all together
Try the final example above. You might note an issue with the content of the content block becoming visible instantly. This happens even though the height is zero.
That is because we havenʼt hidden the overflow. We can do that by
adding overflow-y: hidden
to the .sb-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 just doesnʼt animate. But 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. But that just means 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.