Determining the most efficient way of managing state can be a challenging issue in CSS, but thankfully there are many OOCSS-based methodologies out there which provide some good solutions.
Article Series:
- Managing State in CSS with Reusable JavaScript Functions (You are here!)
- Follow up on the ideas in this article
My preferred comes from SMACSS (Scalable and modular architecture for CSS) and involves stateful classes. To quote SMACSS’s own documentation, stateful classes are:
A state is something that augments and overrides all other styles. For example, an accordion section may be in a collapsed or expanded state. A message may be in a success or error state.
States are generally applied to the same element as a layout rule or applied to the same element as a base module class.
One of my most-used stateful classes is is-active
. Taking the accordion example from the prior quote, is-active
in this instance would apply all the required CSS styles to represent an expanded state. As seen in the example below:
See the Pen #1) Accordion Component w Stateful Class by Luke Harrison (@lukedidit) on CodePen.
You will notice there’s some JavaScript which toggles the is-active
class on the component when a click event is detected:
var accordion = document.querySelectorAll(".c-accordion");
for(var i = 0; i < accordion.length; i++) {
var accordionHeader = accordion[i].querySelector(".c-accordion__header"),
accordionCurrent = accordion[i];
accordionHeader.addEventListener("click", function(){
accordionCurrent.classList.toggle("is-active");
});
}
Whilst valid JavaScript, this would have to be repeated again and again for any other components which leverage the is-active
stateful class via a click event, leading to many duplicates of what is essentially the same code snippet.
Not very efficient and certainly not very DRY.
A better approach would be instead to write a single function which performs the same task and can be reused over and over again with different components. Let’s do that.
Creating a simple reusable function
Let’s start off by building a simple function which accepts an element as a parameter and toggles is-active
:
var makeActive = function(elem){
elem.classList.toggle("is-active");
}
This works fine, but if we slot it into our accordion JavaScript there’s a problem:
var accordion = document.querySelectorAll(".c-accordion"),
makeActive = function(elem){
elem.classList.toggle("is-active");
}
for(var i = 0; i < accordion.length; i++) {
var accordionHeader = accordion[i].querySelector(".c-accordion__header"),
accordionCurrent = accordion[i];
accordionHeader.addEventListener("click", function(){
makeActive(accordionCurrent);
});
}
Although the makeActive
function is reusable, we still need to first write code to grab our component and any of its inner elements, so there’s certainly lots of room for improvement.
To make these improvements, we can leverage HTML5 custom data attributes:
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">My Accordion Component</div>
<div class="c-accordion__content-wrapper">
<div class="c-accordion__content">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce laoreet ultricies risus, sit amet congue nulla mollis et. Suspendisse bibendum eros sed sem facilisis ornare. Donec sit amet erat vel dui semper pretium facilisis eget nisi. Fusce consectetur vehicula libero vitae faucibus. Nullam sed orci leo. Fusce dapibus est velit, at maximus turpis iaculis in. Pellentesque ultricies ultrices nisl, eu consequat est molestie sit amet. Phasellus laoreet magna felis, ut vulputate justo tempor eu. Nam commodo aliquam vulputate.
</div>
</div>
</div>
A data-active
attribute has been added to the element which previously triggered the is-active
toggle when clicked. This attribute’s value represents the element where the is-active
toggle should take place, which as before is the top-level c-accordion
element. Note the addition of a new js-accordion
class rather than hooking into the existing c-accordion
class. This is to decouple functional aspects of the component from it’s styling.
Let’s take a look at the JavaScript:
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-active]");
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab linked elements
var linkedElement = document.querySelectorAll("." + this.getAttribute("data-active"));
// Toggle linked element if present
for(var i = 0; i < linkedElement.length; i++) {
linkedElement[i].classList.toggle("is-active");
}
});
}
This has certainly improved things as we no longer have to write code to grab any elements, just attach a data-active attribute to our trigger element and specify a target element. As it stands, this function can be used for any other component where a click-based is-active
class is required without any additional coding. Full example below:
See the Pen #2) Accordion Component w reusable is-active function by Luke Harrison (@lukedidit) on CodePen.
Improving our reusable function
This reusable function works, but when scaled, we have to take care to make sure trigger and target element classes don’t conflict with one another. In the example below, clicking one accordion would trigger is-active
on all of them.
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">First Accordion</div>
[...]
</div>
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">Second Accordion</div>
[...]
</div>
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">Third Accordion</div>
[...]
</div>
Adding number suffixes to each js-accordion
reference does solve the problem, but it’s a hassle which we can do without. A good solution would be to instead implement scoping to our reusable function which would enable us to encapsulate our toggles so they only effect the elements we want.
To implement scoping, we’ll need to create a separate custom attribute called data-active-scope
. Its value should represent the parent element which the toggle should be encapsulated within, which in this instance is the parent js-accordion
element.
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion" data-active-scope="js-accordion">First Accordion</div>
[...]
</div>
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">Second Accordion</div>
[...]
</div>
Using the above HTML, the following behaviour should happen:
- When you click the first accordion, because it has a scope set to
js-accordion
, onlydata-active
elements which match or are children of thisjs-accordion
instance will haveis-active
toggled. - When you click the second accordion, which doesn’t have a scope,
is-active
would be toggled on all instances ofjs-accordion
.
Provided data-active-scope
is correctly set, any class toggles within each js-accordion
element should be encapsulated regardless of any conflicting classnames.
Here’s the modified Javascript and a working example showing accordions with and without a data-active-scope
attribute:
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-active]"),
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab scope if defined
if(this.getAttribute("data-active-scope")) {
var scopeElement = closestParent(this, this.getAttribute("data-active-scope"));
}
if(scopeElement) {
// Grab scoped linked element
var linkedElement = scopeElement.querySelectorAll("." + this.getAttribute("data-active"));
// Convert to array
linkedElement = Array.prototype.slice.call(linkedElement);
// Check if our scope matches our target element and add to array if true.
// This is to make sure everything works when data-active matches data-active-scope.
if(scopeElement.classList.contains(this.getAttribute("data-active"))) {
linkedElement.unshift(scopeElement);
}
}
else {
// Grab linked element
var linkedElement = document.querySelectorAll("." + this.getAttribute("data-active"));
}
// Toggle linked element if present
for(var i = 0; i < linkedElement.length; i++) {
linkedElement[i].classList.toggle("is-active");
}
});
}
See the Pen #3) Accordion Component w Improved reusable is-active function by Luke Harrison (@lukedidit) on CodePen.
Moving beyond is-active
Our reusable function is now working nicely and is an efficient way of setting up is-active
toggles on all kinds of components. However what if we need to set up a similar toggle for another stateful class? As it stands we would have to duplicate the function and change all references of is-active
to the new stateful class. Not very efficient.
We should improve our reusable function to accept any class by refactoring our data-attributes. Instead of attaching the data-active
attribute to our trigger element, let’s replace it with the following:
data-class
– The class we wish to add.data-class-element
– The element we wish to add the class to.data-class-scope
– The scope attribute performs the same function, but has been renamed for consistency.
This requires a few minor tweaks to our JavaScript:
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-class][data-class-element]");
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab scope if defined
if(this.getAttribute("data-class-scope")) {
var scopeElement = closestParent(this, this.getAttribute("data-class-scope"));
}
if(scopeElement) {
// Grab scoped linked element
var linkedElement = scopeElement.querySelectorAll("." + this.getAttribute("data-class-element"));
// Convert to array
linkedElement = Array.prototype.slice.call(linkedElement);
// Check if our scope matches our target element and add to array if true.
// This is to make sure everything works when data-active matches data-active-scope.
if(scopeElement.classList.contains(this.getAttribute("data-class-element"))) {
linkedElement.unshift(scopeElement);
}
}
else {
// Grab linked element
var linkedElement = document.querySelectorAll("." + this.getAttribute("data-class-element"));
}
// Toggle linked element if present
for(var i = 0; i < linkedElement.length; i++) {
linkedElement[i].classList.toggle(this.getAttribute("data-class"));
}
});
}
It would be set up in the HTML like so:
<button class="c-button" data-class="is-loading" data-class-element="js-form-area">Submit</button>
In the example below, clicking the c-button
component toggles the is-loading
class on the js-form-area
component:
See the Pen #4) Form Component w Improved reusable any class function by Luke Harrison (@lukedidit) on CodePen.
Handling multiple toggles
So we have a reusable function which toggles any class on any element. These click events can be set up without having to write any additional JavaScript through the use of custom data attributes. However, there’s still ways to make this reusable function even more useful.
Coming back to our previous example of the login form component, what if when the c-button
element is clicked, in addition to it toggling is-loading
on js-form-area
, we also want to toggle is-disabled
on all instances of c-input
? At the moment this isn’t possible as our custom attributes only accept a single value each.
Let’s modify our function, so instead of each custom data attribute only accepting a single value, it accepts a comma separated list of values – with each item value in data-class
linking with the value of a matching index in data-class-element
and data-class-scope
.
Like so:
<button class="c-button" data-class="is-loading, is-disabled" data-class-element="js-form-area, js-input" data-class-scope="false, js-form-area">Submit</button>
Assuming the above is used, the following would happen once c-button
is clicked:
is-loading
would be toggled onjs-form-area
.is-disabled
would be toggled onjs-input
and be scoped within the parentjs-form-area
element.
This requires more changes to our JavaScript:
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-class][data-class-element]");
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab classes list and convert to array
var dataClass = this.getAttribute('data-class');
dataClass = dataClass.split(", ");
// Grab linked elements list and convert to array
var dataClassElement = this.getAttribute('data-class-element');
dataClassElement = dataClassElement.split(", ");
// Grab data-scope list if present and convert to array
if(this.getAttribute("data-class-scope")) {
var dataClassScope = this.getAttribute("data-class-scope");
dataClassScope = dataClassScope.split(", ");
}
// Loop through all our dataClassElement items
for(var b = 0; b < dataClassElement.length; b++) {
// Grab elem references, apply scope if found
if(dataClassScope && dataClassScope[b] !== "false") {
// Grab parent
var elemParent = closestParent(this, dataClassScope[b]),
// Grab all matching child elements of parent
elemRef = elemParent.querySelectorAll("." + dataClassElement[b]);
// Convert to array
elemRef = Array.prototype.slice.call(elemRef);
// Add parent if it matches the data-class-element and fits within scope
if(dataClassScope[b] === dataClassElement[b] && elemParent.classList.contains(dataClassElement[b])) {
elemRef.unshift(elemParent);
}
}
else {
var elemRef = document.querySelectorAll("." + dataClassElement[b]);
}
// Grab class we will add
var elemClass = dataClass[b];
// Do
for(var c = 0; c < elemRef.length; c++) {
elemRef[c].classList.toggle(elemClass);
}
}
});
}
And here’s another working example:
See the Pen #5) Form Component w Improved reusable + multiple any class function by Luke Harrison (@lukedidit) on CodePen.
Moving beyond toggle
Our reusable function is quite useful now, but it makes a presumption that toggling classes is the desired behavior. What if when clicked we want the trigger to remove a class if it’s present and do nothing otherwise? Currently, that’s not possible.
To round the function off let’s integrate a bit of extra logic to allow for this behaviour. We’ll introduce an optional data-attribute called data-class-behaviour
which accepts the following options:
toggle
– Togglesdata-class
ondata-class-element
. This should also be the default behaviour which happens ifdata-class-behaviour
isn’t defined.add
– Addsdata-class
ondata-class-element
if it isn’t already present. If it is, nothing happens.remove
– Removesdata-class
ondata-class-element
if it’s already present. If it isn’t, nothing happens.
As with previous data attributes, this new optional attribute will be a comma-separated list to allow for different behaviours for each action. Like so:
<button class="c-button" data-class="is-loading, is-disabled" data-class-element="js-form-area, js-input" data-class-behaviour="toggle, remove">Submit</button>
Assuming the above HTML is used, the following would happen once c-button
is clicked:
is-loading
would be toggled onjs-form-area
is-disabled
would be removed fromjs-input
if present.
Let’s make the necessary JavaScript changes:
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-class][data-class-element]");
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab classes list and convert to array
var dataClass = this.getAttribute('data-class');
dataClass = dataClass.split(", ");
// Grab linked elements list and convert to array
var dataClassElement = this.getAttribute('data-class-element');
dataClassElement = dataClassElement.split(", ");
// Grab data-class-behaviour list if present and convert to array
if(this.getAttribute("data-class-behaviour")) {
var dataClassBehaviour = this.getAttribute("data-class-behaviour");
dataClassBehaviour = dataClassBehaviour.split(", ");
}
// Grab data-scope list if present and convert to array
if(this.getAttribute("data-class-scope")) {
var dataClassScope = this.getAttribute("data-class-scope");
dataClassScope = dataClassScope.split(", ");
}
// Loop through all our dataClassElement items
for(var b = 0; b < dataClassElement.length; b++) {
// Grab elem references, apply scope if found
if(dataClassScope && dataClassScope[b] !== "false") {
// Grab parent
var elemParent = closestParent(this, dataClassScope[b]),
// Grab all matching child elements of parent
elemRef = elemParent.querySelectorAll("." + dataClassElement[b]);
// Convert to array
elemRef = Array.prototype.slice.call(elemRef);
// Add parent if it matches the data-class-element and fits within scope
if(dataClassScope[b] === dataClassElement[b] && elemParent.classList.contains(dataClassElement[b])) {
elemRef.unshift(elemParent);
}
}
else {
var elemRef = document.querySelectorAll("." + dataClassElement[b]);
}
// Grab class we will add
var elemClass = dataClass[b];
// Grab behaviour if any exists
if(dataClassBehaviour) {
var elemBehaviour = dataClassBehaviour[b];
}
// Do
for(var c = 0; c < elemRef.length; c++) {
if(elemBehaviour === "add") {
if(!elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.add(elemClass);
}
}
else if(elemBehaviour === "remove") {
if(elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.remove(elemClass);
}
}
else {
elemRef[c].classList.toggle(elemClass);
}
}
}
});
}
And finally, a working example:
See the Pen #6) Form Component w Improved reusable + multiple any class + behaviours function by Luke Harrison (@lukedidit) on CodePen.
Closing
What we’ve created is a powerful function which can be reused over and over again without writing any extra code. It allows us to quickly assign add, remove or toggle logic for multiple stateful classes on click and lets us scope these changes to the desired area.
There’s still many ways in which this reusable function can be improved even further:
- Support for using different events other than click.
- Swipe support for touch devices.
- Some form of simple validation which allows you to declare JavaScript variables which must be truthy before a class change goes ahead.
In the meantime, if you have any ideas for your own improvements or even a completely different method of managing stateful classes altogether, then be sure to let me know in the comments below.
Article Series:
- Managing State in CSS with Reusable JavaScript Functions (You are here!)
- Follow up on the ideas in this article
I see you’re using the classList.toggle function which means you’re using the latest version of the spec, in which case you can remove the classList.contains functions as classList.add only adds the class if it’s not already there and classList.remove only removes it if it IS already there.
You could also use a tiny switch statement instead of the if…else if…else statement but that’s personal preference ;)
Let me squeeze in one more tiny nitpick, the if (elems.length) around the for (…) shouldn’t be necessary as 0 < 0 and 0 < undefined are both false, if I’m not mistaken.
Thanks, I’m sure there’s plenty of ways better developers can make this more efficient, as long as the point of the article comes across haha.
Another tweak which someone made me aware of on twitter was the removal of the unneeded length checks here and there! Have removed those now though.
This is pretty neat! What are your thoughts on the fine line between something like this and a complete front-end application framework, especially when it comes to the additional features that you brought up, like JS variable validation?
I think it would depend on the context.
On a new build, with a dev who knows their way around a front-end framework like React, that would always be the best option.
For projects where using a framework like React isn’t an option – for example if a dev is working on an existing codebase, or if the dev just doesn’t possess enough know-how to properly leverage something like React, then this is where a simpler solution like the above would come into play.
I think this is interesting if you were building a framework. But for the use case of a smaller project where you’re not using a framework, it’s likely this would add more boilerplate than you save. Always interesting to see new ways of thinking. One small issue I found with the closestParent helper function is it starts searching for a parent on itself. Maybe you’d have a reason for wanting this, but I don’t think that’s how jquery’s version works (correct me if I’m wrong, haven’t used jquery in awhile) and it would be a problem if you wanted for example the parent DIV and the child was also in a DIV (would return itself). Easy solution would be:
Although the class name
is-active
makes me cringe a little with its redundancy ;-), my main critique point would be that you are only toggling class names here.For accessibility purposes, examples such as your accordion should be using the appropriate aria attributes. And once you start toggling the
aria-expanded
attribute to signal which accordion item is active, that class nameis-active
becomes redundant not only in name, but completely – because styling of the items can then simply use the attribute selector to apply different formatting in the expanded state.I admit I’m not at all particularly schooled up about aria attributes, so you may be onto something. I’ll make sure to read up on them. :)
I agree, I started out with the SMACSS approach myself but switched to ARIA when able. There are some minor gothas though. More on my approach to state here: http://ecss.io/chapter6.html
I think this would have some issues for elements added to the DOM at a later point. In that case you would need to repeat assigning the click event… Am I correct?
If DOM elements are added later there must be a JS method doing so. Which means you have a trigger for registration of new elements.
How about data-click-registered=”1″ ?
Shouldnt’ be too hard to filter…
Thanks Phil. Yes, that’s a neat approach indeed. :) I asked just to confirm I got it correctly.
One often neglected aspect is about separation of concerns and debugging: Consider using the browsers’ live DOM debugging view. Using data attributes for states makes it much easier to understand.
Is it a data attribute? Then it must be a javascript driven state.
Is it a class name or an inline style? Then it can only originate from HTML.
Which module did set the state? Just find the attribute name inside your js/modules directory.
Tweak the state for testing? Just change the data attribute value.
This leads to strict rules:
Javascript never sets classnames.
Javascript sets only generic styles like “display” or “visibility”.
Javascript writes states as data attributes.
State names are as unique as possible. Even better: Implement a naming scheme as glue between JS modules and their data attributes.
Set state-defining data attributes as close as possible to their effecting elements.This means: Don’t write all application states into the body element. Instead set them container-wise. Some browser situations (e.g. IE with complex DOM) will benefit in performance.
CSS visualizes states.
Different concerns use different source code folders.
Of course, some pre-built frameworks/libraries (e.g. iScroll) set styles from JS, but that can easily be separated from own code.
Hi Luke,
Thanks for the article.
Looking at the current state of the web, all the techniques like BEM, SMACSS, OOCSS are kind of deprecated if you come to write some javascript. There are bunch of CSS in JS techniques, like https://github.com/css-modules/css-modules or https://github.com/cssinjs/jss and many more, which aim to help with handling any difficulties with CSS.
Also the code, which you have shown, is good for a presentation of idea, but it is very hard to maintain in production (keep it neat and clear). At this point everyone who wants to get use this, needs some kind of library to control the View (html, which is being used for showing some state). I would suggest well known React or you could take new hype called Vue. With that said, I am sure you have mentioned this vital point.
I’ve used a similar technique for a while (albeit with different attribute names). Over the years I ran into a few headaches:
If your content is loaded dynamically (e.g. ReactJS), you’re going to have to constantly query the DOM and reapply those event listeners.
The HTML starts to feel very cluttered/messy. Especially after you start dreaming up and adding other seemingly awesome data-* HTML APIs.
If you decide that your implementation needs tweaking, you’ll need to modify every single HTML file that this exists in. This can become a nightmare if it’s spread across hundreds of HTML files in multiple locations.
I’ve since moved back to abstracting this logic into a single reusable JavaScript function. My OCD likes the clutter free HTML.