Dan Christofi – CSS-Tricks https://css-tricks.com Tips, Tricks, and Techniques on using Cascading Style Sheets. Tue, 13 Dec 2022 13:53:57 +0000 en-US hourly 1 https://wordpress.org/?v=6.1.1 https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/07/star.png?fit=32%2C32&ssl=1 Dan Christofi – CSS-Tricks https://css-tricks.com 32 32 45537868 A Few Times Container Size Queries Would Have Helped Me Out https://css-tricks.com/a-few-times-container-size-queries-would-have-helped-me-out/ https://css-tricks.com/a-few-times-container-size-queries-would-have-helped-me-out/#comments Tue, 13 Dec 2022 13:53:56 +0000 https://css-tricks.com/?p=375728 CSS Container Queries are still gaining traction and many of us are getting our hands wet with them, even if it’s for little experiments or whatnot. They’ve got great, but not quite full, browser support — enough to justify using …


A Few Times Container Size Queries Would Have Helped Me Out originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
CSS Container Queries are still gaining traction and many of us are getting our hands wet with them, even if it’s for little experiments or whatnot. They’ve got great, but not quite full, browser support — enough to justify using them in some projects, but maybe not to the extent where we might be tempted to start replacing media queries from past projects with shiny new container size queries.

They sure are handy though! In fact, I’ve already run into a few situations where I really wanted to reach for them but just couldn’t overcome the support requirements. If I had been able to use them, this is how it would have looked in those situations.

All of the following demos will be best viewed in Chrome or Safari at the time of this writing. Firefox plans to ship support in Version 109.

Case 1: Card grid

You kind of had to expect this one, right? It’s such a common pattern that all of us seem to run into it at some point. But the fact is that container size queries would have been a huge time-saver for me with a better outcome had I been able to use them over standard media queries.

Let’s say you’ve been tasked with building this card grid with the requirement that each card needs to keep it’s 1:1 aspect ratio:

A four-by-three grid of card elements as a grayscale mockup.

It’s tougher than it looks! The problem is that sizing a component’s contents on the viewport’s width leaves you at the mercy of how the component responds to the viewport — as well the way any other ancestor containers respond to it. If, for example, you want the font size of a card heading to reduce when the card hits a certain inline size there’s no reliable way to do it.

You could set the font size in vw units, I suppose, but the component is still tied to the browser’s viewport width. And that can cause problems when the card grid is used other in contexts that may not have the same breakpoints.

In my real-world project, I landed on a JavaScript approach that would:

  1. Listen for a resize event.
  2. Calculate the width of each card.
  3. Add an inline font size to each card based on its width.
  4. Style everything inside using em units.

Seems like a lot of work, right? But it is a stable solution to get the required scaling across different screen sizes in different contexts.

Container queries would have been so much better because they provide us with container query units, such as the cqw unit. You probably already get it, but 1cqw is equal to 1% of a container’s width. We also have the cqi unit that’s a measure of a container’s inline width, and cqb for a container’s block width. So, if we have a card container that is 500px wide, a 50cqw value computes to 250px.

If I had been able to use container queries in my card grid, I could have set up the .card component as a container:

.card { 
  container: card / size;
}

Then I could have set an inner wrapper with padding that scales at 10% of the .card‘s width using the cqw unit:

.card__inner { 
  padding: 10cqw; 
} 

That’s a nice way to scale the spacing between the card’s edges and its contents consistently no matter where the card is used at any given viewport width. No media queries required!

Another idea? Use cqw units for the font size of the inner contents, then apply padding in em units:

.card__inner { 
  font-size: 5cqw; 
  padding: 2em;
} 

5cqw is an arbitrary value — just one that I settled on. That padding is still equal to 10cqw since the em unit is relative to the .card__inner font size!

Did you catch that? The 2em is relative to the 5cqw font size that is set on the same container. Containers work different than what we’re used to, as em units are relative to the same element’s font-size value. But what I quickly noticed is that container query units relate to the nearest parent that is also a container.

For example, 5cqw does not scale based on the .card element’s width in this example:

.card { 
  container: card / size; 
  container-name: card; 
  font-size: 5cqw; 
}

Rather, it scales to whatever the nearest parent that’s defined as a container. That’s why I set up a .card__inner wrapper.

Case 2: Alternating layout

I needed yet another card component in a different project. This time, I needed the card to transition from a landscape layout to a portrait layout… then back to landscape, and back to portrait again as the screen gets smaller.

Showing four states of a card element changing between portrait and landscape layouts at various breakpoints.

I did the dirty work of making this component go to portrait at those two specific viewport ranges (shout out to the new media query range syntax!), but again, the problem is that it is then locked to the media queries set on it, its parent, and anything else that might respond to the viewport’s width. We want something that works in any condition without worrying about wondering where the content is going to break!

Container queries would have made this a breeze, thanks to the @container rule:

.info-card {
  container-type: inline-size;
  container-name: info-card;
}

@container info-card (max-width: 500px) {
  .info-card__inner {
    flex-direction: column;
  }
}

One query, infinite fluidity:

But hold on! There’s something you might want to watch out for. Specifically, it could be difficult to use a container query like this within a prop-based design system. For example, this .info-card component could contain child components that rely on props to change their appearance.

Why’s that a big deal? The card’s portrait layout might require the alternate styling but you can’t change JavaScript props with CSS. As such, you risk duplicating the required styles. I actually touched on this and how to work around it in another article. If you need to use container queries for a significant amount of your styling, then you may need to base your entire design system around them rather than trying to shoehorn them into an existing design system that’s heavy on media queries.

Case 3: SVG strokes

Here’s another super common pattern I’ve recently used where container size queries would have resulted in a more polished product. Say you have an icon locked up with a heading:

<h2>
  <svg>
    <!-- SVG stuff -->
  </svg> 
  Heading
</h2>

It’s pretty straightforward to scale the icon with the title’s size, even without media queries. The problem, though, is that the SVG’s stroke-width might get too thin to be noticed all that well at a smaller size, and perhaps catch too much attention with a super thick stroke at a larger size.

I’ve had to create and apply classes to each icon instance to determine its size and stroke width. That’s OK if the icon is next to a heading that’s styled with a fixed font size, I guess, but it’s not so great when working with fluid type that constantly changes.

A lockup of a hexagon icon and heading at three different sizes, from large to small.

The heading’s font size might be based on the viewport’s width, so the SVG icon needs to adjust accordingly where its stroke works at any size. You could make the stroke width relative to the heading’s font-size by setting it in em units. But if you have a specific set of stroke sizes that you need to stick to, then this wouldn’t work because it otherwise scales linearly — there’s no way to adjust it to a specific stroke-width value at certain points without resorting to media queries on the viewport width.

But here’s what I would have done if I had the luxury of container queries at that time:

.icon {
  container: icon / size; 
  width: 1em; 
  height: 1em; 
}

.icon svg {
  width: 100%; 
  height: 100%; 
  fill: none; 
  stroke: #ccc; 
  stroke-width: 0.8; 
}

@container icon (max-width: 70px) {
  .icon svg {
    stroke-width: 1.5; 
  }
}
@container icon (max-width: 35px) {
  .icon svg {
    stroke-width: 3;
  }
}

Compare the implementations and see how the container query version snaps the SVG’s stroke to the specific widths I want based on the container’s width.

Bonus: Other types of container size queries

OK, so I haven’t actually run into this on a real project. But as I was combing through information on container queries, I noticed that there are additional things we can query on a container that are related to the container’s size or physical dimensions.

Most examples I’ve seen query the width, max-width, and min-width, height, block-size, and inline-size as I’ve been doing throughout this article.

@container info-card (max-width: 500px) {
  .info-card__inner {
    flex-direction: column;
  }
}

But MDN outlines two more things we can query against. One is orientation which makes perfect sense because we use it all the time in media queries. It’s no different with container queries:

@media screen (orientation: landscape) { 
  .info-card__inner {
    /* Style away! */
  }
} 

@container info-card (orientation: landscape) { 
  .info-card__inner {
    /* Style away! */
  }
} 

The other? It’s aspect-ratio, believe it or not:

@container info-card (aspect-ratio: 3/2) { 
  .info-card__inner {
    /* Style away! */
  }
} 

Here’s an editable demo to play around with both examples:

I haven’t really found a good use case for either of these yet. If you have any ideas or feel like it could’ve helped you in your projects, let me know in the comments!


A Few Times Container Size Queries Would Have Helped Me Out originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/a-few-times-container-size-queries-would-have-helped-me-out/feed/ 2 375728
Avoiding the Pitfalls of Nested Components in a Design System https://css-tricks.com/nested-components-in-a-design-system/ https://css-tricks.com/nested-components-in-a-design-system/#comments Tue, 26 Apr 2022 14:30:20 +0000 https://css-tricks.com/?p=365359 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 />


Avoiding the Pitfalls of Nested Components in a Design System originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
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.

Showing three versions of a call-to-action components with nested components within it.

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.


Avoiding the Pitfalls of Nested Components in a Design System originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/nested-components-in-a-design-system/feed/ 16 365359