JavaScript and CSS – Using Parent Selector vs Media Queries

cssjavascriptweb-design

I really don't like media queries in CSS – they have limitations, and on top of that they make the code a lot more confusing.
In addition, the restrictions are so strong that when using CSS preprocessors, it is impossible to use, for example, @extend from SCSS inside a media query:

  @media (max-width: 768px) {
   #element {
     @extend .large-text;
   }
}

Because this will result in an error:

You may not @extend selectors across media queries.
     ╷
251  │ @extend .large-text;
     │ ^^^^^^^^^^^^^^^^^^^^
     ╵

I searched a lot trying to find workarounds, and more often than not it turned out that the solution either did not exist, or it leads to the same confusing code – for example, using mixins, or using JS to directly add/remove element classes.

However, all these solutions did not satisfy me, and I managed to find, in my opinion, an interesting solution, using a little JS and CSS parent selector (&).

JS:

function updateBreakpoint() {
         const breakpoint = window.innerWidth < 576 ? 'on-small-screen' : window.innerWidth < 768 ? 'on-medium-screen' : 'on-large-screen';
        
         // Setting attributes based on screen size (this is very ugly and simplified example, just to understand the idea)
         if (breakpoint === 'on-small-screen') {
             document.documentElement.setAttribute('_underlined', 'true');
         }

         // Setting html element class
         document.documentElement.className = breakpoint;
     }

     // Call updateBreakpoint on page load and whenever the window is resized
     updateBreakpoint();
     // I know it's bad for performance to subscribe to resize without any delay, but again, this is a simplified example to show the idea
     window.addEventListener('resize', updateBreakpoint);

CSS:

.test-class {
    color: red;

   .on-small-screen & {
     color: blue;
   }

   .on-medium-screen & {
     color: green;
   }
  
   [_underlined='true'] & {
     text-decoration: underline;
   }
}

And it really works. And it not only works, but also has a number of advantages:

  1. Shifting responsibility for styles only to CSS.
    Now JS doesn't change element classes directly, instead it changes the "state" of elements – and CSS "reacts" to those states.

Example

SCSS:


.sidebar {
  position: fixed;
  overflow: hidden;
  top: 0;
  left: 0;
  height: 100%;
  width: 270px;
  background: #fff;
  padding: 15px 10px;
  box-shadow: 0 0 2px rgba(0, 0, 0, 0.1);
  transition: all 0.4s ease;

  [_sidebar-locked='false'][_sidebar-collapsed='true'] & {
    @extend .collapsed;
  }

  [_sidebar-disabled='true'] & {
    @extend .disabled;
  }
}

JS:

    const sidebar = document.querySelector(".sidebar");
    const sidebarOpenBtn = document.querySelector("#sidebar-open-button");
    const sidebarCloseBtn = document.querySelector("#sidebar-close-button");
    const sidebarLockBtn = document.querySelector("#sidebar-lock-button");

    const toggleLock = () => {
        toggleAttribute('_sidebar-locked');
    };

const collapseSidebar = () => { setAttribute('_sidebar-collapsed', 'true'); };
const expandSidebar = () => { setAttribute('_sidebar-collapsed', 'false'); };
const toggleSidebar = () => { toggleAttribute('_sidebar-disabled'); };
sidebarLockBtn.addEventListener("click", toggleLock); sidebar.addEventListener("mouseleave", collapseSidebar); sidebar.addEventListener("mouseenter", expandSidebar); sidebarCloseBtn.addEventListener("click", toggleSidebar); sidebarOpenBtn.addEventListener("click", toggleSidebar); function updateBreakpoint() { const breakpoint = window.innerWidth < 768 ? 'on-small-screen' : window.innerWidth < 1512 ? 'on-medium-screen' : 'on-large-screen';
setAttribute('_sidebar-locked', breakpoint === 'on-small-screen' ? 'false' : 'true'); document.documentElement.className = breakpoint; } updateBreakpoint(); window.addEventListener('resize', updateBreakpoint);

Features like these force you to use JS in any case.
However, instead of using JS to change classes directly – which can lead to more confusing code – this approach allows you to change only the "state".
Thus, if in the future I want to change how this feature works, I will intuitively expect that the changes need to be made in JS.
And if I want to change how the sidebar looks in this state, I will intuitively expect that the changes need to be made in CSS.
This is what I mean by separating logic and styling.

For comparison, here’s what the JS code for this functionality would look like using the usual approach:

!const sidebar = document.querySelector(".sidebar");
const sidebarOpenBtn = document.querySelector("#sidebar-open-button");
const sidebarCloseBtn = document.querySelector("#sidebar-close-button");
const sidebarLockBtn = document.querySelector("#sidebar-lock-button");

const toggleLock = () => {
    sidebar.classList.toggle("locked");
    if (!sidebar.classList.contains("locked")) {
        sidebar.classList.add("hoverable");
        sidebarLockBtn.classList.replace("bx-lock-alt", "bx-lock-open-alt");
    } else {
        sidebar.classList.remove("hoverable");
        sidebarLockBtn.classList.replace("bx-lock-open-alt", "bx-lock-alt");
    }
};

const hideSidebar = () => {
    if (sidebar.classList.contains("hoverable")) {
        sidebar.classList.add("close");
    }
};

const showSidebar = () => {
    if (sidebar.classList.contains("hoverable")) {
        sidebar.classList.remove("close");
    }
};

const toggleSidebar = () => {
    sidebar.classList.toggle("disabled");
};

const onBreakpointUpdate = () => {
    if (window.innerWidth < 800) {
        sidebar.classList.add("close");
        sidebar.classList.remove("locked");
        sidebar.classList.add("hoverable");
    }
};
window.addEventListener('resize', updateBreakpoint);

sidebarLockBtn.addEventListener("click", toggleLock);
sidebar.addEventListener("mouseleave", hideSidebar);
sidebar.addEventListener("mouseenter", showSidebar);
sidebarCloseBtn.addEventListener("click", toggleSidebar);
sidebarOpenBtn.addEventListener("click", toggleSidebar);

Of course, this does not eliminate the need for JS completely, but at least it eliminates the dependence on class names, mixing functional logic code with styling code, etc.

  1. Thanks to the fact that all responsibility for styles now lies with CSS, such code is easy to move between frameworks – styles will work the same in vanilla HTML, Angular, React, and anywhere else. The only thing that will need to be rewritten is the way in which the class and attributes of the html element are dynamically changed.
  2. The code is much easier to read, understand, and extend. Now all responsive styles are no longer placed in a separate block of code inside the media query.
  3. (?) There is not much difference in performance compared to the traditional approach with media queries and/or JS. Since only custom attributes are changed, and the class does not directly affect the html element, (I think) this should not cause reflows and repaintings more often than it would happen when using the traditional approach.

However, it looks so good that I feel there is a “catch” – but I don’t know if there really is one.
So my question is – is this really a good approach for writing styles? Does it have any disadvantages that would convince me that I SHOULD NOT use this approach, but instead continue to use "traditional" media queries and/or JS?

I understand that this is definitely not a solution that will work 100% of the time, and in some places you will have to use JS or media queries anyway. But I’m asking about usage in general, without going into such specific cases.

Best Answer

I do think you have something very valuable in your hands. The idea to have CSS to handle state having as base HTML properties is very useful, and if combined with accessibility, it can help you both with better CSS code and more accessible sites/web apps.

About accessibility, you should definitly check this, which basically adds accessibility to the web and helps you write semantic CSS -- which is something close to what you seem to have developed. Also, examples like this can help a lot for creating accessible components which have the advantadge of allowing CSS to control layouting based on the component's state.

Take a look on this vue component for expandables. Check how I toggle the value of aria-expanded using JS/Vue:

<template>
  <div class="ui-expandable">
    <button @click="toggleDetails" type="button">
      <slot name="activator" />
    </button>
    <div :aria-expanded="is_open" class="ui-expandable__wrapper">
      <div class="ui-expandable__content">
        <slot name="expandable_content" :toggle="toggleDetails" />
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import type { Ref } from 'vue'

let is_open: Ref<boolean> = ref<boolean>(false)

function toggleDetails(): void {
  is_open.value = !is_open.value
}
</script>

As you can see, part of my styling is based on the aria-expandable property

.ui-expandable {
  display: flex;
  flex-direction: column;
  width: 100%;
  &:not(:has(.ui-expandable__wrapper[aria-expanded])):has(+ #{&}) {
    margin-bottom: 10px;
  }
  &:has(.ui-expandable__wrapper[aria-expanded]):has(+ #{&}) {
    margin-bottom: 20px;
  }
  & > button {
    display: flex;
    padding: unset;
    @include s.transition($property: margin-bottom);
    &:not(:has(+ [aria-expanded])) {
      transition-delay: 0.5s;
    }
    &:has(+ [aria-expanded]) {
      margin-bottom: 10px;
      transition-delay: 0s;
    }
  }
  &__content {
    overflow: hidden;
  }
  &__wrapper {
    --_rows: 0fr;

    display: grid;
    grid-template-rows: var(--_rows);
    @include s.transition($property: grid-template-rows);
    &[aria-expanded] {
      --_rows: 1fr;
    }
  }
}

Now, about the medias. I guess you're going in the wrong direction with this. You're taking in consideration only media queries for screen size. But what about medias for color, printing, themes? They are also somewhat messy, but you'll still have to deal with them. So I believe you should find another way to organize yor code with the medias! What I like to do is: use CSS variables and BEM notation, like this:

.the-banner {
  --_image_height: 580px;
  --_button_size: #{f.rem(25)};
  --_text_size: #{f.rem(20)};
  --_margin_bottom: 50px;
  --_padding: 15px;

  & h2 {
    margin-bottom: var(--_margin_bottom);
    width: 100%;
  }
  ...
  &__image {
    @include s.background-image;
    height: var(--_image_height);
  }
  @include m.mobile-down {
    --_image_height: calc(100vh - var(--menu-height));
    --_button_size: #{f.rem(18)};
    --_text_size: #{f.rem(16)};
    --_margin_bottom: 25px;
    --_padding: 10px;
  }
}

My @include m.mobile-down is my media query, just behind some SASS mixins; the only thing it does is change the values, without touching the properties. If you name your variables well enough, you can declare all of them in the root of your element (in BEM notation, it's the one withouth any "_[something]") and change them inside the medias, so you won't have a lot of nesting inside your medias and can also use all of them after all your element's been declared, what makes the medias less messy.

Also, I'd prefer to use custom HTML attributes using data- attributes.