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:
- 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.
- 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.
- 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.
- (?) 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.
@mixin responsive($breakpoint) { @media (min-width: $breakpoint) { @content; } }
This also does not work and does not allow extending classes inside media queries. And if about this:@mixin mixined-class { color: blue; }
This leads to the fact that I will have to rewrite most classes using mixins. And even though this works with my custom classes, but if I use any CSS framework, there will not be such an option, or it will be complex.