When creating a component-based, front-end infrastructure, one of the biggest pain points I’ve personally encountered is making components that are both reusable and responsive when there are nested components within components.
Take the following “call to action” (<CTA />
) component, for example:
On smaller devices we want it to look like this:
This is simple enough with basic media queries. If we’re using flexbox, a media query can change the flex direction and makes the button go the full width. But we run into a problem when we start nesting other components in there. For example, say we’re using a component for the button and it already has a prop that makes it full-width. We are actually duplicating the button’s styling when applying a media query to the parent component. The nested button is already capable of handling it!
This is a small example and it wouldn’t be that bad of a problem, but for other scenarios it could cause a lot of duplicated code to replicate the styling. What if in the future we wanted to change something about how full-width buttons are styled? We’d need to go through and change it in all these different places. We should be able to change it in the button component and have that update everywhere.
Wouldn’t it be nice if we could move away from media queries and have more control of the styling? We should be using a component’s existing props and be able to pass different values based on the screen width.
Well, I have a way to do that and will show you how I did it.
I am aware that container queries can solve a lot of these issues, but it’s still in early days and doesn’t solve the issue with passing a variety of props based on screen width.
Tracking the window width
First, we need to track the current width of the page and set a breakpoint. This can be done with any front-end framework, but I’m using a Vue composable here as to demonstrate the idea:
// composables/useBreakpoints.js
import { readonly, ref } from "vue";
const bps = ref({ xs: 0, sm: 1, md: 2, lg: 3, xl: 4 })
const currentBreakpoint = ref(bps.xl);
export default () => {
const updateBreakpoint = () => {
const windowWidth = window.innerWidth;
if(windowWidth >= 1200) {
currentBreakpoint.value = bps.xl
} else if(windowWidth >= 992) {
currentBreakpoint.value = bps.lg
} else if(windowWidth >= 768) {
currentBreakpoint.value = bps.md
} else if(windowWidth >= 576) {
currentBreakpoint.value = bps.sm
} else {
currentBreakpoint.value = bps.xs
}
}
return {
currentBreakpoint: readonly(currentBreakpoint),
bps: readonly(bps),
updateBreakpoint,
};
};
The reason we are using numbers for the currentBreakpoint
object will become clear later.
Now we can listen for window resize events and update the current breakpoint using the composable in the main App.vue
file:
// App.vue
<script>
import useBreakpoints from "@/composables/useBreakpoints";
import { onMounted, onUnmounted } from 'vue'
export default {
name: 'App',
setup() {
const { updateBreakpoint } = useBreakpoints()
onMounted(() => {
updateBreakpoint();
window.addEventListener('resize', updateBreakpoint)
})
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoint)
})
}
}
</script>
We probably want this to be debounced, but I’m keeping things simple for brevity.
Styling components
We can update the <CTA />
component to accept a new prop for how it should be styled:
// CTA.vue
props: {
displayMode: {
type: String,
default: "default"
}
}
The naming here is totally arbitrary. You can use whatever names you’d like for each of the component modes.
We can then use this prop to change the mode based on the current breakpoint:
<CTA :display-mode="currentBreakpoint > bps.md ? 'default' : 'compact'" />
You can see now why we’re using a number to represent the current breakpoint — it’s so the correct mode can be applied to all breakpoints below or above a certain number.
We can then use this in the CTA component to style according to the mode passed through:
// components/CTA.vue
<template>
<div class="cta" :class="displayMode">
<div class="cta-content">
<h5>title</h5>
<p>description</p>
</div>
<Btn :block="displayMode === 'compact'">Continue</Btn>
</div>
</template>
<script>
import Btn from "@/components/ui/Btn";
export default {
name: "CTA",
components: { Btn },
props: {
displayMode: {
type: String,
default: "default"
},
}
}
</script>
<style scoped lang="scss">
.cta {
display: flex;
align-items: center;
.cta-content {
margin-right: 2rem;
}
&.compact {
flex-direction: column;
.cta-content {
margin-right: 0;
margin-bottom: 2rem;
}
}
}
</style>
Already, we have removed the need for media queries! You can see this in action on a demo page I created.
Admittedly, this may seem like a lengthy process for something so simple. But when applied to multiple components, this approach can massively improve the consistency and stability of the UI while reducing the total amount of code we need to write. This way of using JavaScript and CSS classes to control the responsive styling also has another benefit…
Extensible functionality for nested components
There have been scenarios where I’ve needed to revert back to a previous breakpoint for a component. For example, if it takes up 50% of the screen, I want it displayed in the small mode. But at a certain screen size, it becomes full-width. In other words, the mode should change one way or the other when there’s a resize event.
I’ve also been in situations where the same component is used in different modes on different pages. This isn’t something that frameworks like Bootstrap and Tailwind can do, and using media queries to pull it off would be a nightmare. (You can still use those frameworks using this technique, just without the need for the responsive classes they provide.)
We could use a media query that only applies to middle sized screens, but this doesn’t solve the issue with varying props based on screen width. Thankfully, the approach we’re covering can solve that. We can modify the previous code to allow for a custom mode per breakpoint by passing it through an array, with the first item in the array being the smallest screen size.
<CTA :custom-mode="['compact', 'default', 'compact']" />
First, let’s update the props that the <CTA />
component can accept:
props: {
displayMode: {
type: String,
default: "default"
},
customMode: {
type: [Boolean, Array],
default: false
},
}
We can then add the following to generate to correct mode:
import { computed } from "vue";
import useBreakpoints from "@/composables/useBreakpoints";
// ...
setup(props) {
const { currentBreakpoint } = useBreakpoints()
const mode = computed(() => {
if(props.customMode) {
return props.customMode[currentBreakpoint.value] ?? props.displayMode
}
return props.displayMode
})
return { mode }
},
This is taking the mode from the array based on the current breakpoint, and defaults to the displayMode
if one isn’t found. Then we can use mode
instead to style the component.
Extraction for reusability
Many of these methods can be extracted into additional composables and mixins that can be reuseD with other components.
Extracting computed mode
The logic for returning the correct mode can be extracted into a composable:
// composables/useResponsive.js
import { computed } from "vue";
import useBreakpoints from "@/composables/useBreakpoints";
export const useResponsive = (props) => {
const { currentBreakpoint } = useBreakpoints()
const mode = computed(() => {
if(props.customMode) {
return props.customMode[currentBreakpoint.value] ?? props.displayMode
}
return props.displayMode
})
return { mode }
}
Extracting props
In Vue 2, we could repeat props was by using mixins, but there are noticeable drawbacks. Vue 3 allows us to merge these with other props using the same composable. There’s a small caveat with this, as IDEs seem unable to recognize props for autocompletion using this method. If this is too annoying, you can use a mixin instead.
Optionally, we can also pass custom validation to make sure we’re using the modes only available to each component, where the first value passed through to the validator is the default.
// composables/useResponsive.js
// ...
export const withResponsiveProps = (validation, props) => {
return {
displayMode: {
type: String,
default: validation[0],
validator: function (value) {
return validation.indexOf(value) !== -1
}
},
customMode: {
type: [Boolean, Array],
default: false,
validator: function (value) {
return value ? value.every(mode => validation.includes(mode)) : true
}
},
...props
}
}
Now let’s move the logic out and import these instead:
// components/CTA.vue
import Btn from "@/components/ui/Btn";
import { useResponsive, withResponsiveProps } from "@/composables/useResponsive";
export default {
name: "CTA",
components: { Btn },
props: withResponsiveProps(['default 'compact'], {
extraPropExample: {
type: String,
},
}),
setup(props) {
const { mode } = useResponsive(props)
return { mode }
}
}
Conclusion
Creating a design system of reusable and responsive components is challenging and prone to inconsistencies. Plus, we saw how easy it is to wind up with a load of duplicated code. There’s a fine balance when it comes to creating components that not only work in many contexts, but play well with other components when they’re combined.
I’m sure you’ve come across this sort of situation in your own work. Using these methods can reduce the problem and hopefully make the UI more stable, reusable, maintainable, and easy to use.
I am struggling to comprehend the value of this approach and would like to understand more about your thought process of why JavaScript is a better choice than CSS media queries.
When I look at this approach it feels like a step backward… You’re advocating a method that was the de-facto means of achieving responsive design before the adoption of
@media
breakpoints. Which means essentially you’re promoting a JavaScript solution that is now a decade out-of-date.Are there performance issues that you feel make JavaScript a better solution here? The examples in your article don’t really offer insight as to why
@media
was insufficient to your needs. Is it an issue of offering a JavaScript first approach to those users who are maybe less comfortable writing CSS?Modern CSS can help with most of these issues. Container queries are coming soon and until then we have a solid polyfill for them.
Clamp() is also extremely useful. As are minmax(), :where(), :is(), and :has() (only in Safari atm but soon to be adopted by Chrome and Firefox.
Personally I’d rather use these methods along with some smart/brief media queries if needed rather than adding more JavaScript (particularly for styles.)
I love this, I think the example in question isn’t the greatest. But I get the jist and can see this being extremely useful in cases where designers give you drastically different designs for each breakpoint and the props for each component all need to completely change. Or where the UI splits into pages/tabs on smaller screens.
Clean DRY CSS for the win :)
I don’t get it. Surely the JavaScript should be measuring the width of the containing element, not the width of the whole viewport?
If this were polyfilling container queries, it would make sense. But right now, it’s a polyfill for media queries …which we have already.
What am I missing?
Good article. I think this approach will make most sense to people who have dealt with large applications with hundreds of variants of the same component. Yes – on a smaller projects it may be easier to make use of media queries, but when components are reused over and over inside other components in different ways, this to me seems like good approach and turns a load of messy media queries into nice, clean and reusable code.
Another way of achieving something similar is to use css variables in the components to set the “displayMode”, and use media queries on the top level component to set those in descendants children…
I need this in Angular2 please someone help me?
I suggest, using ResizeObserver API for that–at least until fullsupport of ContainerQueries. Main benefit is, that you can listen to the direct parent container instead of window. :)
Was hoping for more here when I heard “in a Design System”. Design System to me is more expansive than just the vue/react/angular implementation. That’s just a slice of the system. A pattern is not a design system.
At the same time even as a pattern it’s not clear to me what specific advantages switching back to using JS from CSS is for these things, aside from maybe some small reduction of CSS output that would likely be negated when the output is gzipped.
Echoing others here, use intrinsic CSS sizes and container queries:
Use cq-prolyfill to gain container queries for all CSS properties: height, colour, text alignment, etc, not just container width. And can work with data attributes if you don’t want to use a pre-processor. [https://ausi.github.io/cq-prolyfill/demo/]
Use latest polyfill for newer browsers CSS-Tricks: container query polyfill that just works
CQfill was mentioned in CSS-Tricks article, but I found CQ-prolyfill was a much better solution, and works for all old browsers (IE9+, Safari7+).
Isn’t this easier by using grid with grid areas maped for different media queries?
I’m just asking.
It looks like not a good idea to resolve CSS-tasks using JS. I think it could couse perfomance issues and some strange bugs/behavior in future.
You might have used an example that’s too simple, making your method seem over engineered.
Modern CSS alone can make this work without media queries. Why do you need JS?
I don’t find much usefulness from this method. It’s convoluted and messy to me. I’d rather use container queries which have a solid polyfill already.
This strikes me as rather complex and a JS only solution for something that could be done in CSS. What I would do (and am currently doing in a huge Angular project) is use CSS custom properties (the name says it all: these are props for CSS) in the child component
<Button />
and define these in the parent component<CTA />
. This is also one of the few ways to style web components from the outside its shadow dom.I echo the above about preferring to use CSS, media queries and container queries for this use case.
Something else to consider when using a JS first approach like this for anything that is statically rendered is that if the window width needs to be known then the styles will not be computed for the server side render so you could see layout shift or flashes of changing styles if the window width does not fall into whatever the default style you’ve set for the component is.