Container queries with json-react-layouts
Recently we have been working on improving images in our site, with a particular focus on responsive images.
One thing you will notice about most blog platforms is they limit how you can use images and often have image sizes dependent on the viewport, not the container.
How responsive images work
Quick bit of background, responsive images work against the viewport width, not the container width. This often makes it hard to optimise responsive images on sites with quite dynamic layouts.
A simplified way of looking at the way responsive images are declared is this.
You have sizes, these are the queries the browser can use to quickly understand what the width of the image will be. These can use viewport widths (%’s of the viewport)
max-width: 700px 100vw,
(min-width: 700px) and (max-width: 1020px) 50vw,
500px
The above says our image will be 100% of the viewport when the viewport is ≤ 700px, between 700px and 1020px the image will be 50% of the viewport, then above 1020px.
Then you have the sources, which are image urls for different absolute widths.
/images/image-1-100.jpg 100w
/images/image-1-320.jpg 320w
/images/image-1-500.jpg 500w
/images/image-1-800.jpg 800w
/images/image-1-1024.jpg 1024w
With the above example, lets say our viewport width is 750px, the browser will use the sizes to see that the image should be 50vw, or 375px. It will then look for the sources (rounding up) and pick the 500w image because it’s the first image above 375px width.
This is great for when you know how wide the image will be for any viewport, and why many sites have predictable image widths per viewport.
When your layouts are a bit more dynamic this becomes really hard to work out. There are a number of React image components which measure the container width, then build the appropriate responsive image arguments.
What we really need is container queries, but they are not supported yet.
Our json-react-layouts library
Because we are building a website not an application in React we wanted to ensure we could build pages easily, turns out our approach has some really cool side effects.
We declare our pages like this:
[
{
type: 'main-with-sidebar',
contentAreas: {
main: [
{
type: 'collection',
props: { heading: 'Sport' },
data: { type: 'curation', id: 'sport' }
},
],
sidebar: [
{
type: 'collection',
props: { heading: 'Latest News' },
data: { type: 'topic', topic: 'news' },
}
]
},
},
{
type: 'ad',
props: { id: 'footer-banner' }
}
]
main-with-sidebar is a layout component (known as compositions
). Layouts can be nested within each other so with a few layouts like main-with-sidebar, 50–50, three-pack etc we have a lot of flexibility of our site layouts.
The problem is that collections
exist inside layouts and cannot know what % of the viewport they will end up occupying.
Often sidebars will have images as well, and to complicate things further we have a lot of different collection styles which lay the news ‘cards’ out in different ways.
Our layout components are registered using the following code.
This abstraction to React gives us support for middlewares for our components, so data loading is implemented in another library. We also implement feature toggling for our components using another middleware. But that is another blog post.
Solving container queries
Our json-react-layout library actually does the rendering and prop generation for all our React components, this opens up a really cool opportunity. We asked outselves.
What happens if our layouts could tell their child components what % of the viewport they are?
The answer is container queries!
Let’s revisit the above composition registration, and specify the third argument of the registration which allows the layout to provide additional props to all child components.
With this simple change, any component which is inside the main
content area of the will get a containerWidthRatio
prop passed with the ratios of the viewport it is contrained to. It also multiplies it with the same prop if specified so nested containers apply the ratio appropriately.
If our 50–50
layout does the same thing and we nested the 50–50
layout inside our main-with-sidebar
layout, the 50–50 composition will have containerWidthRatio={{ mobile: 1, tablet: 0.66, desktop: 0.66 }}
passed, then all the values will have 0.5 applied to them. Changing the prop to containerWidth={{ mobile: 0.5, tablet: 0.33, desktop: 0.33 }}
.
Consuming containerWidthRatio
The next step is on our image component, we can now specify hints for the responsive sizes. Before contentAreaRatio we would have something like this
responsiveSizes={
mobile: '100vw',
tablet: '50vw',
desktop: '1000px' // at desktop our site has a fixed width
}
Now that our collection knows the ratio of the viewport it is contrained to we can introduce a little helper to be able to apply ratios to px/vw values.
The tests show how this helper works.
Now all we have to do is to update our collection to apply the containerWithRatios prop to each of it’s images
responsiveSizes={applyContainerRatio({
mobile: '100vw',
tablet: '50vw',
desktop: '1000px' // at desktop our site has a fixed width
}, props.containerWidthRatios)}
And with that, we can use these responsive sizes in our media queries. Our original sizes example can become:
max-width: 700px ${responsiveSizes.mobile},
(min-width: 700px) and (max-width: 1020px) ${responsiveSizes.tablet},
${responsiveSizes.desktop}
This approach does not take margins into account so it isn’t perfect, but it has made a large difference to how accurate our responsive images are and over halved the width of requested images without visible loss of quality to the user. Pretty big win.