Over the past year, I’ve had the task of creating layout components for an organization. This is something that was initially confused about. My initial assumption was that everyone knows about flexbox! It’s our lord and layout savior! Jokes about centering content should have vanished away now that we can use flexbox! Oh, how naïve I was.
Realistically, the developer landscape doesn’t hone these skills well, and instead hides them behind APIs; even if those very APIs are identical to what a person would write in CSS. It’s true, people will do anything to avoid writing code in a .css
file.
Honestly, I stumbled all over making a robust layout component. The following is considerations I take into account and how I might build a new component if I was given another opportunity.
Layout
The first thing I’d take into consideration is layout using flexbox. I’m avoiding introducing grid configurations here. I’ve found that in the years I’ve been working with CSS, grid is the lesser used layout technique. It certainly has benefits over flexbox in many cases, but the majority of layout needs revolve around aligning items on a single axis in a container.
Much of the confusion about flexbox comes from the alignment properties; align-items
and justify-content
. They are very easy to get mixed up and, the results can be unexpected when you change the flex-direction
. I believe an improvement here would be to simplify the API.
<Box stretch stack>Hello world!</Box>
The first thing I’d introduce is the boolean properties of stretch
and stack
. The stretch
flag would switch between inline-flex
and flex
so the container can fill its parent. The stack
flag would toggle on flex-direction: column
so the children stack in a single column. I’d also include wrap
to align directly to flex-wrap: wrap
.
With that out of the way, now we need to address the alignment properties. Generally, the expectation for someone manipulating layout is to place the content in one of 9 locations within the container. For example, take the following CSS:
.box {
display: flex;
align-items: start;
justify-content: end;
}
This code will place the content in the top-right corner of an LTR container. But with one line of code, it’ll completely flip the position to the bottom-left corner:
.box {
display: flex;
+ flex-direction: column;
align-items: start;
justify-content: end;
}
In my opinion, this is unexpected. The intention to show the items in a row versus a column shouldn’t have an effect on the alignment in this case. Instead, I’d like to see an approach where someone could configure the items such that the stack
has no effect.
Furthermore, I’d like to lean into CSS Logical Properties, such that configuration inputs have aliases; left
/ top
would transform to start
and right
/ bottom
transforms to end
, of course allowing these values directly as well. I could imagine some flag that turns off (eg., logical=false
) this translation in cases where we want to force the direction regardless of the writing mode.
<Box logical={ false }>
Media player timeline
</Box>
Importantly, we’d need to inform what axis to apply the start
or end
. Logical properties describe that the horizontal axis is the “inline” direction, and the vertical axis is the “block” direction. As we have seen, when we update the flex-direction
, these axes flip where I’d argue this is largely unexpected. So instead of using align
and justify
, I’d suggest inline
and block
, similar to how padding
and margin
would be modernly applied.
With this all in mind, we’d have the following:
<Box
stack
inline="left"
block="bottom">
Hello world!
</Box>
Regardless if the stack
was applied or not, the children of this LTR container would be placed in the bottom-left corner. In an RTL container, the items would be placed in the bottom-right corner because logical
defaults to true
.
The final element to consider here is handling the concept of “self”. The flexbox ecosystem allows an item to essentially override the placement given by the parent through properties such as align-self
and justify-self
. Knowing this, we can also see a potential problem with the current API. The words inline
and block
aren’t clear as to what they are affecting. A better API naming convention would be more clear about what these are doing. That is why the flexbox APIs include the words items
and content
; they are affecting the elements inside this container, not the container itself.
This means that we should have similar but separate APIs for the concept of affecting the elements inside versus the element itself. We can communicate that layout is affecting the interior children with inset
(ie., interior setup) and the exterior self with outset
:
<Box
inset={{ inline: 'start', block: 'end' }}
outset={{ /* Similar options */ }}>
Hello world!
</Box>
We could also consider shorthands that handle these with a single value; affecting both inline
and block
in the same way. The following would place the text horizontally and vertically within the box:
<Box inset='center'>
Hello world!
</Box>
One more thing to mention is the more nuianced alignment properties, such as justify-content: space-between
. Since we’re hiding implementation details to match some new expectations, I’d consider an option that would effectively override the given inline
or block
(depending on the stack
flag) to distribute the children.
<Box stack inset="center" distribute="between">
<Box>Child 1</Box>
<Box>Child 2</Box>
<Box>Child 3</Box>
</Box>
In this example, the justify-content
would be set as space-between
instead of center
. I’m avoiding the word “space” here to not confuse with other spacing properties such as padding
or gap
which we’ll introduce later.
Appearance
By default, it makes sense to allow this container to be invisible when a configuration is omitted. This helps with general layout approaches, allowing boxes to be arranged without explicitly showing boundaries. However, in some cases we may want to have a box appear segregated from other boxes.
Aligning with the idea of Mise en Mode, appearance properties would be informed by the use of intents (ie., strict semantic tokens) where we’d consider the priority that this box is meant to convey. So the box with the highest priority would be presented in a way that was clear to a user that they should pay attention here first before looking at other elements. This suggests that there is a priority
setting that could either be numeric (1
, 2
, 3
, and so on) or Latinate ordinal numbers (primary
, secondary
, etc.). I’d recommend the latter and having only default
, secondary
, and primary
. This reduces the decision-making paralysis; making it more clear which to use. Having a number system of significant size makes it challenging to determine the most appropriate number. I also suggest using the term default
over including tertiary
as if you need an additional level, you can include it later.
Continuing with the Mise en Mode concept, this allows any box to present itself with any color combination as provided by the current mode. This means, if you need a purple gradient to convey the supreme intelligence of AI, you can do it by informing the intents with new values, not by providing new tokens directly to a component.
I believe the priority of any box is more closely tied to the concept of surfaces. We could imagine that the default
priority to be the base level of the page where most UI elements would live. Next, the secondary
level could be reserved for elements that are meant to appear on top of the UI but are contextual to some anchor. You can think of flyouts like context menus or tooltips. These are introduced because there is some more important information for you to take in before continuing, but they aren’t typically the most important thing to do. Finally, the primary
level would be a full disruption like a modal, which is meant to appear on top of everything blocking further progress.
<Box aria-modal="true" inset="center" priority="primary">
Hello world!
</Box>
Any one of these priorities would apply additional presentational styles to the element to allow it to convey the priority consistently. This is so our users can subconsciously learn the hierarchical order of the content and make better decisions.
Spacing
Continuing with Mise en Mode, more specifically its origins of Complementary Space, I believe we’ll only need two spacing options for box: padding
and gap
. These would be flags and not expecting values like T-shirt sizing. This is because the mode would inform the size.
/* Example 1 */
<Box padding gap>
<Box>Child 1</Box>
<Box>Child 2</Box>
</Box>
/* Example 2 */
<Box padding gap data-mode="density-shift">
<Box>Child 1</Box>
<Box>Child 2</Box>
</Box>
The data-mode
in the second example indicates that a mode changes occurs here. This would affect all intents of this element and below new values. The density-shift
value would refer to a mode that expects the density to shift down one level, such that padding
and gap
are smaller than if this mode wasn’t applied; as in the first example.
You might consider simply adding mode
as the option instead. I’m specifically using data-mode
to indicate that this isn’t specific to <Box/>
but more related to Mise en Mode. If this element is meant to be the base of all other elements, then mode
could be appropriate.
If Complementary Space is too radical of an idea, feel free to wade in the depths of applying T-shirt sizing values.
Loading
Some work that was being done in parallel to the layout component was that of a skeleton loader. For many systems, this is a separate component than any others in the system for the specific purpose of displaying a placeholder while work is done in the background. This has the perception that the system is processing, as opposed to having a blank area waiting for content to appear.
It was at this time that I realized that if the skeleton component represented content, and the box component was meant to arrange content, what if the box could represent placeholder content until it was provided? In other words, we can mark <Box/>
elements with an attribute that shows a skeleton loading presentation if the element has no content. Consider the following simple card layout:
<Box stack padding gap>
<Media src={ src } />
<Box stack padding gap data-mode="density-shift">
<Box standby>{ title }</Box>
<Box standby>{ description }</Box>
<Box standby>{ actions }</Box>
</Box>
</Box>
The standby
flag would show a skeleton loading presentation to elements if no content is provided. That could be done with the CSS :empty
selector.
[data-standby]:empty {
/* Skeleton loading presentation */
}
Once content is added, the selector would no longer match and the skeleton styles would be removed through CSS. We could also consider being explicit about when the loader should be shown by using the value of data-standby="true"
.
The purpose of embedding the ability to display a skeleton in any layout is powerful; assuming performance cannot be improved. This means that you can have a layout reused between the expected result and the lack of information with a single composition; just add content. No need to create a separate layout for when the content is missing and another for when the content is finally resolved.
Semantics
Something I’ve glossed over is how to assign the element that this component represents. In some systems, this is done with another prop that informs the HTML tag name to use (<Box as="section"/>
). Instead, I’d recommend trying a different approach.
<box.section>
Hello world!
</box.section>
In this way, it is more clear that the section
part of the element is related to the element that will be rendered. Using as
could be more ambiguous to what the prop is meant to do, such as the word variant
. You could also consider a more generic default export as Box
that refers to a simple box.div
.
Demo
Here’s a demo of the concept, missing all the mode changing.
EDIT (2024-09-26): Devon Govett recently posted a similar approach where a <Skeleton/>
wrapper is added to any component in the system which will cause media and text elements inside to shimmer. Based on what I’ve written above, of course I really resonate with this. I like how the wrapping component is the trigger because you can also add accessibility attributes in there to markup the loading state within.
There’s some feedback in the replies that challenges how the system is supposed to know what the content looks like. The fact is that we don’t need to know what the content looks like, we need to know what kind of content it is.
This is why thinking about design as intention over aesthetics is the key to systems thinking and how we could support this approach. For example, the intention for a heading is commonly for introducing a short concept or label. Therefore, the loading presentation for the area where a heading should appear could always be displayed as a single block instead of considering several lines. Certainly, it is entirely possible that the final content does create multiple lines however, the purpose of the skeleton is not to match one-for-one with the incoming content. It is meant to indicate perceived progress using a loose blueprint of the expected content.
We already know the styles for the text that will appear once the content comes through, including the font size. We can use that font size to set the minimum height of the skeleton placeholder, perhaps using the new lh
unit. Also know that any styles that we use for the skeleton do not need to remain for when the content is applied. If the lh
unit isn’t appropriate for the incoming font, then no need to set a minimum height when the element is :not(:empty)
.
For paragraphs, you can pick a number of lines to display when we expect a paragraph. You could even be more considerate and wire in a container query that changes the number of lines based on the size of the paragraph. Wider paragraph containers larger than 60ch
in width can display 2 lines, while smaller widths can display 3. This could imitate line wrapping for the same repeatable placeholder content. This way, a paragraph in a loading hero section would show 2 lines, while a more narrow card would show 3 lines.
The easiest one of these content elements to consider is the image or really any media. Best practice for loading media is to set the dimensions in the HTML to help with content shifts which would clearly dictate the size of the container for the skeleton. In the event you couldn’t do this, you could have predetermined expectations for that media; square, 4:3, 16:9, and even a circle for avatars. These styles are already most likely applied to the media element already, so we shouldn’t need to use too much imagination about how this skeleton might look.