"Given everything we know about two of math’s most famous constants, 𝜋 and e, it’s a bit surprising how lost we are when they’re added together."
Yeah... I feel the same about CSS layouts sometimes. 🤨
You'd think that since we've been browsing the web on screens of all sizes for decades now the challenge of responsive layouts would have been solved ages ago. Instead, what we've got is a range of "solutions" that work for some cases and fail miserably for others. Often the simplest task becomes a mix of rational and transcendental considerations, just like the afore-quoted mystery of 𝜋+e, a mind-boggling juggle of numerous "what ifs". In most cases UI developers just give up and fall back on constraints, calling their solution "opinionated", which in this case really means "sorry, couldn't figure it out, so this will do".
That's OK for a website, or an app, but it's not OK for a framework like UNA where containers customisation and responsive layout is the lex terrae. We have to wrestle with it until we win, or die trying. Let me explain...
The Goal
We need to be able to position content cards vertically or horizontally, depending on the width of the parent container. In UNA these may be, say, Posts or People or Groups cards used on main browsing pages. Like so...
Simple, right? If you are familiar with CSS you probably already have a few ideas, hoping for quick and easy solution. Now let's kill those hopes and ideas one by one.
The @media Queries // useless
Parent container has nothing to do with browser viewport. It's not just about desktop vs mobile. While on mobile phone viewport is usually small enough to always default to single-column layout, on other devices we may have more than one "cell". Layouts can have 2-3-4 columns and it may be rearranging between tablet to large desktop viewports. Layouts may have different column size combinations, like the "holy grail" layout with narrow sidebar, wide content area and medium-sized aside on the right.
When you place cards in any of those layout cells the available width may be more like mobile viewport, even though you're opening the page on a large desktop browser. In other words - context is variable and viewport is not the context. This rules out @media queries.
In a very controlled environment when the developer knows exactly what will be the width of the block within each @media breakpoint it may be possible pull it off, but UNA Pages builder allows placing any block into any cell regardless of it's size. The content of the block has to be responsive in context to the width of the cell, not viewport.
NB: @media is not totally useless, of course. We need it for components that are instantiated in context to the viewport, like headers, footers and modals.
Flexbox Wrapping and Growing // and failing
Flex is so cool. It really is. It's everywhere now, and for good reason. You can flex-wrap your cards to stack them into neat rows and flex-grow them to adjust sizes, filling the parent container evenly. You can give your cards min-width to let flex box know when to switch to single column, or how many columns to "add". Gaps are nicely controlled with the gap property. Amazing!
So good in fact, that if your cards were of the same fixed width you're golden. Or, if they were responsive and the multiple of rows and columns perfectly matches the number of cards you have to show. Got 6 cards to show? Looks great with 2 rows of 3 cards, wraps nicely to 3 rows of 2 cards in a smaller container, then comes down to one beautiful column when space gets really tight.
Now, what if our server gave us just 5 cards... oh snap!
What's with those two giant cards? Are they more important than others? Ah, right... flex is growing them too much, and if we want to maintain aspect ratio of any media inside them - it'll be growing waaaay too much.
Maybe we can set max-width to each card? That may make things even worse, because you want to allow smaller cards to grow somewhat to fill the container evenly between switching columns. In the end your last few cards would still grow, just maybe not all the way, so you'd have an even uglier mismatch.
In UNA we can set the number of cards to display on each page and paginate the rest, but sometimes your server simply doesn't have enough content to fit the idea. You can set the count to, say, 12 per page and never show more than 4 cards per row (you need to show at least 60 if you want to show 5 or 6 cards per row), but if you have only, say, 10 Featured Articles or 9 Online People the ugly giants would pop up. So, no... it's an L.
JavaScript - ResizeObserver // it's complicated
Now this one is actually pretty good. Read ResizeObserver: it’s like document.onresize for elements or Quick guide to Resize Observer to get an idea of what it does. It's possible to set breakpoints and to watch the element size independent of the viewport. The problem is that it's in JavaScript, not CSS, which means that there are some significant issues:
- Cross-origin limitations: If you're observing an element that's in a different origin (i.e., a different domain, port, or protocol) from your JavaScript code, ResizeObserver may fail due to cross-origin security restrictions.
- Timing issues: If you try to observe an element before it has been added to the DOM, ResizeObserver may fail. Similarly, if you try to observe an element after it has been removed from the DOM, it may also fail.
- Element visibility: If you're observing an element that's not visible (i.e., has a display of "none"), ResizeObserver will not work.
- CSS styles: If an element's size is being controlled by a CSS stylesheet, changes to the stylesheet may not trigger a ResizeObserver callback.
There is more! Now that we are building ReactJS apps for UNA, using NextJS, we need to consider:
- Server-side rendering: Since Next.js performs server-side rendering, the JavaScript that runs on the server may not have access to the ResizeObserver API, causing it to fail. To work around this, we have to use ResizeObserver on the client side.
- Re-rendering: In React, whenever a component re-renders, it unmounts and then remounts, causing the ResizeObserver to stop observing and then start observing again. This can result in the ResizeObserver callback being called multiple times. So, we need to use a stateful component or a state management library, such as Redux or MobX, to ensure that the ResizeObserver only runs once.
- Virtual DOM: React uses a virtual DOM to update the actual DOM. As a result, changes to an element's size may not be immediately reflected in the virtual DOM, causing the ResizeObserver callback to be delayed or not run at all. So, gotta add a useEffect hook to manually update the virtual DOM after the ResizeObserver callback has run.
Enough? Not enough? Let's do more... There are some absolute limitations of ResizeObserver when it comes to using it for responsive layouts:
- Only element size: ResizeObserver can only detect changes to an element's size. It cannot detect changes to other layout-related properties, such as the position or visibility of an element. This means that if you need to respond to changes in these properties, you'll need to use other APIs or techniques.
- Performance overhead: Observing changes to an element's size using ResizeObserver can have a performance impact, especially if you're observing many elements at once (which we do, big time). This is because ResizeObserver runs a layout calculation for each observed element, which can be expensive.
- Potential for missed updates: ResizeObserver updates asynchronously - there's a small window of time between when an element's size changes and when the ResizeObserver callback is called. This means that there's a possibility that the ResizeObserver callback could miss an update if it's executed too quickly.
You get the point. There must be an easier way.
Container Queries //so close, but not there yet
CSS container queries allow you to apply styles based on the size of a containing element, rather than the size of the viewport. The idea behind container queries is to make it easier to create more flexible and modular layouts, especially in responsive design. That's right, our problem is acknowledge and the solution has been built, but container queries require the browser to perform layout calculations for each and every element on the page, rather than just the viewport. This can quickly become a performance bottleneck, especially for complex pages with many elements.
So, we need to use container queries sparingly, at least until all browsers learn how to deal with them efficiently. But we can user them, right!? Right!? Well, not always. 🤷♂️ There is one annoying issue with container queries that we found. It's not documented and is hardly discussed, perhaps because container queries are still relatively new.
The problem is with position:fixed for anything within the @container. Turns out that when you apply @container property to parent element, all its children will treat it as a viewport. That makes some sense, but still is very unfortunate.
In situations when you need to show a modal, or a fixed nav-bar, or a sticky form field that comes from the same element that you styled with @container there is no way to "opt out". Sometimes you can work around it, and sometimes it's a dealbreaker.
Let's Complicate The Problem //we need more pain
Before getting to the solution, let's strong-arm the problem a bit. In UNA we need to do a bit more than children elements positioning based parent size. We also need to change styles (such as margins, paddings, corner radius) of the children elements themselves, as well as change the layout of the content within them. 🤯
For example, a card may need no margins and no rounded corners on mobile viewport inside narrow parent, but still have margins and rounded corners on desktop viewport within narrow parent. We should be able to use the (not so useless anymore) @media queries, of course. Still, it's worth mentioning that making adjustments based only on parent container size may not be enough in some cases.
Furthermore, when we switch breakpoints we sometimes need to reposition elements inside of the child element. For example...
Flex-col on mobile, flex-row when it's inside a wide parent on desktop, flex-col again when it's inside of narrow parent on desktop.
🚀 The Flexbox Holy Albatross Reincarnated
Ultimately the solution for us is based on the ingenious Flexbox trick, known as The Flexbox Holy Albatross - the "reincarnated" version, discovered by Heydon Pickering.
The Flexbox Holy Albatross technique is a way of using flexbox to create equal height columns that are adaptable to different screen sizes. This technique involves nesting a flex container within another flex container, where the inner container has a flex direction of column and the outer container has a flex direction of row. By doing this, the columns will automatically take up equal height within the row, regardless of their content.
Additionally, the technique is useful for manipulating containers based on the size of the parent container, rather than relying on @container queries or @media queries. By changing the flex direction of the outer container, it's possible to switch between a row of columns and a column of rows at different breakpoints, while still maintaining the same width for each column. This ensures that the columns remain equal in height even when wrapped onto a new row.
Here is the playground link of the DEMO implementation
This approach is a perfect fit for UNA, where page layouts include various cell sizes and adapt responsively to various viewports. Operators can place blocks into any cells, so all blocks need to look good no matter where they show up. More of this approach will manifest in future updates!
The next challenge is to implement it as part of ReactNative apps and NextJS web app universally. Stay tuned!
- 457