There is no box model in CSS — and this is why you struggle with visual layers
JavaScriptUI — DevBlog #8
Last week I discussed why I think the CSS “box model” is an incorrect term and demonstrated that this model is fundamentally broken using nothing more than simple CSS borders. In this episode I am going to present another example to further my argument. This time we are also going to build something useful.
Field day
Let’s say we want to build a simple popup window to prompt the user to log into our web app. Nothing fancy, really, just the basics: some text, some input fields and some buttons. A rough sketch of our idea might look something like this:
We have seven distinct shapes: a light gray background, a welcome title, one text field for the username, another for the password, one button to reset the password, one to sign in, and one in the top right corner to close the popup itself.
So, how do we draw this in a design software? The basic idea is to follow our intuition and compose structures just like we would with tangible objects: everything gets its own layer. First, we create a rectangle that acts as the background for the entire popup, then we create a group that distributes our main elements equally, and finally we create the button that closes the popup as the topmost layer. Conceptually, this is what we are doing:
This looks almost like a box, isn’t it? We have width, height and depth and we literally pack stuff on top of each other, just like in a box. This is how tangible objects work, this is how virtually all design software work and this is what we expect from HTML and CSS, especially since the core tenet of CSS is literally THE BOX MODEL.
So, can we achieve the same on the web? Of course not.
The CSS Pancake Model
Whenever I need to achieve overlapping/overlaying compositions on the web I always cringe. Contrary to the name “box model”, CSS struggles to accomplish proper visual layers and we are forced to resort to unconventional and unintuitive hacks to somehow break out of the main layout plane. In this simple example we already need to use three different tools to kinda-sorta achieve our design:
- using
background
to achieve the base rectangle - using
position: absolute
to achieve the closing button - using
position: fixed
to achieve the popup itself
Let’s go over them and discuss their issues.
#1 — Backgrounds
In CSS, many elements can have a background. Since we cannot simply throw a rectangle into a container, as we would normally do in a design software, we need to rely on this feature in order to achieve our base layer.
Let’s say we use a flex container for our main elements and define a background on this element. This would look something like this using HTML and CSS:
<div id="popup">
<h1>👋 Hi there!</h1>
<input type="text">
<input type="password">
<p><a>Forgot password?</a></p>
<button>Sign in</button>
</div>
#popup {
/* setup container & background */
width: 480px;
padding: 55px 85px 55px 85px;
border-radius: 30px;
background-color: #e6e6e6;
/* setup content layout */
display: flex;
flex-direction: column;
row-gap: 50px;
}
Using background
sounds like a clever solution, but the problem here is that our code will produce two distinct Views on our screen (one rectangle with rounded corners and one vertical distribution on top of it), but now they are merged together in our code. Just look at the style definition of #popup
where we setup both the background “element” and the layout of child elements. Some properties refer to one View (the background) while others refer to the rest (children).
This is the opposite of having a semantic code.
The funny thing about CSS backgrounds is that we can apply multiple, and they are literally called layers. The only problem is, we cannot use the background
property to compose multiple elements on top of each other, only colors and images. But we can position and size them and more importantly we can place them on top of each other.
So, CSS essentially reinvented visual layers (pseudo-shadow DOM? 😅), then reinvented its own position and size system in the form of the background
property, instead of providing a proper construct to compose elements on top of each other. All in the name of simplicity.
#2 — Absolute positioning
Since background
cannot handle elements, we need another solution for our close button. For this, we will use position: absolute
. The downside is that this messes up our code even more.
Note: names like “position”, “static”, “absolute”, “relative” and “fixed” in CSS are quite chaotic, inconsistent and downright incorrect, so don’t worry if you struggle to understand them. I will dedicate an article to explain this mess and propose a better terminology in the future.
If we adjust our previous code to include the close button, we would get something like this:
<div id="popup">
<h1>👋 Hi there!</h1>
<input type="text">
<input type="password">
<p><a>Forgot password?</a></p>
<button>Sign in</button>
<button id="close"><img src="close.svg"></button>
</div>
#popup {
/* setup container & background */
width: 480px;
padding: 55px 85px 55px 85px;
border-radius: 30px;
background-color: #e6e6e6;
/* setup content layout */
display: flex;
flex-direction: column;
row-gap: 50px;
/* setup another content layout */
position: relative;
}
#close {
/* override content layout */
position: absolute;
top: 25px;
right: 25px;
}
This would yield something similar to our original design, albeit with several issues.
First, the #popup
element now essentially renders three different visual layers using three different approaches on top of each other, while still being a single object. There is a significant mismatch between our view tree and what’s rendered on screen, making our layout harder to understand and even harder to work with.
Second, the layout of the children of the #popup
element is ambiguous. When you set a display
property on an element, you define the layout of its children and when you set a position
property, you define the layout of the element itself. In our example, we want to have #popup
to lay out its child elements as a vertical stack (flexbox column), but we also want to rip out the close button from this layout, essentially overriding the flexbox behavior. Even the HTML code is extremely confusing, since we would expect a clear separation between different layout modes, instead of mixing and overriding them randomly.
And third, we need to change the position
of the #popup
element as well, otherwise the close button will be relative to the first positioned ancestor. Even excluding the fact, that position: relative
is actually an absolute origin and position: absolute
is actually relative, and the fact that position
is a form of display
, there is not a single piece in this code that carries even a tiny bit of semantic value. Our view tree now implies a totally different layout than it renders.
As opposed to this mess, this is how JavaScriptUI would tackle the exact same issue:
ZStack(
Rectangle(),
VStack(
Text("👋 Hi there!"),
TextField(),
SecureField(),
Text("Forgot password?"),
Button("Sign in")
),
Image("close.svg")
);
This code correlates 1:1 with our original design. Using a ZStack we can place any kind of Views on top of each other (not just images and colors), each container defines a single layout mode for its children and we don’t override these layouts on any child View. This UI code is inherently semantic and it also eliminates the need for display
and position
properties, as well as the “pseudo-shadow DOM” inside the background
property.
If we want to be completely fair with the HTML/CSS version, we can add all of our styling, layout and interactivity to this code as well:
ZStack(
Rectangle()
.fillColor("#e6e6e6")
.cornerRadius(30),
VStack(
Text("👋 Hi there!"),
TextField(),
SecureField(),
Text("Forgot password?")
.onClick(() => {}),
Button("Sign in")
.onClick(() => {})
)
.gapY(50)
.padding(55, 85, 55, 85),
Image("close.svg")
.top(25)
.right(25)
.onClick(() => {})
)
.width(480);
Or, if you still prefer to work with backgrounds instead of a base rectangle, you can do something like this:
ZStack(
VStack(
Text("👋 Hi there!"),
TextField(),
SecureField(),
Text("Forgot password?")
.onClick(() => {}),
Button("Sign in")
.onClick(() => {})
)
.gapY(50)
.padding(55, 85, 55, 85),
Image("close.svg")
.top(25)
.right(25)
.onClick(() => {})
)
.width(480)
.cornerRadius(30)
.backgroundColor("#e6e6e6");
Note: JavaScriptUI is unopinionated, meaning that there are always multiple ways to achieve the same or similar results.
#3 — Fixed positioning
We have our base layer as a background
, we have our close button as position: absolute
, but we still need to lay out the popup itself. This is the exact same overlapping situation: the popup and the rest of the document have to be on separate layers. Unfortunately, neither background
, nor position: absolute
can solve this layout situation, so we need a third approach: position: fixed
.
I won’t go into details how stupid the implementation of fixed positioning is, but if you are interested I have an entire post about this that you can read here.
Let’s recap
Even though CSS advertises itself as having a box model, it doesn’t provide a single, generalized solution to overlay elements, only makeshift hacks. We need to resort to using the background
property, which cannot handle elements and doesn’t have a separate View in our view tree, position: absolute
, which actually means relative and which breaks UI semantics, and position: fixed
which is an even bigger violation of even more fundamental visual concepts laid down by HTML and CSS. They can solve only specific problems with wildly different approaches and they make our view tree highly ambiguous and confusing.
So, what now?
The thing is, I couldn’t find a single, generalized solution that could enable proper layers to overlay/overlap elements on the web. So far I have found only four different ways to achieve any kind of overlapping, but none of them work as a generalized solution:
- using positioning (absolute, relative, fixed): breaks our view tree, creates conflicting layout modes, no dynamic layout for positioned elements (eg. no ZStack or similar is possible), layout is almost fully manual
- using negative margins: absolute madness, not a scalable/viable solution, still needs some sort of
display
and/orposition
modes - using transforms: not a layout tool, has a different purpose, still needs
display
and/orposition
modes - using grid and putting elements in the same cell: weird solution but kind of interesting, has a ton of quirks (no overflow handling, no padding, no background, no rounded corners on container, difficult to work with both explicit and implicit width/height)
The last one — using a single grid cell — is quite interesting. In raw HTML and CSS, this would look something like this:
<div id="popup">
<div id="background"></div>
<div id="form">
<h1>👋 Hi there!</h1>
<input type="text">
<input type="password">
<p><a>Forgot password?</a></p>
<button>Sign in</button>
</div>
<button id="close"><img src="close.svg"></button>
</div>
#popup {
display: grid;
grid-template-columns: 480px; /* needed, otherwise justify-self doesn't work */
}
#popup > * {
grid-area: 1 / 1 / 1 / 1;
}
This is just the basic idea, but as noted previously, almost no common layout property works on the grid container and alignment is always relative to the parent and not to other siblings which defeats the purpose of using this as a ZStack implementation.
Constraints
Another issue is that even a ZStack implementation wouldn’t solve all of our overlapping needs. The most generic solution I could find is to use constraints instead of containers to lay out our Views. This layout mode is beyond the scope of this article, but in short, the idea is to create layouts by defining arithmetic relationships between layout properties like left
, center
, right
, width
, top
, middle
, bottom
and height
.
Our example could look something like this using constraints:
Group(
Rectangle()
.width(parent.width)
.height(parent.height),
VStack(
Text("👋 Hi there!"),
TextField(),
SecureField(),
Text("Forgot password?"),
Button("Sign in")
)
.width(parent.width)
.height(content.height),
Image("close.svg")
.right(parent.right - 25)
.top(parent.top - 25)
)
.width(480)
.height(content.height);
In this model, the only rule is that each View has to have exactly four layout values (two in each direction), but it can be any two out of the available four (eg.: left + right, or center + width), or we can leave them as default. The rest is calculated automatically. The beauty of this model is that it can constrain any View to any other View, it doesn’t have to be a parent-child relationship (we just have a fairly simple example here). Thus our Views can overlay/overlap each other in any way we want, without losing responsiveness.
A generalized and dynamic constraint system is pretty difficult to achieve on the web right now (mainly, because we can only observe size changes, not position changes), but I aim to implement the parent-child subset of this model in JavaScriptUI.
Update: “But why bother, bro?”
I realized that it is still not clear to some of you what I am trying to achieve with JavaScriptUI, so I’ve made a simple illustration to really drive my point home. As I said several times before, the biggest issue is that HTML and CSS refuse to follow basic graphic design principles, making it unnecessarily difficult to build, modify, and maintain proper layouts, especially from existing mockups created by experienced designers in well-known design software. JavaScriptUI is a solution to translate graphic design into web design without nonsensical HTML and CSS concepts that literally reinvent the wheel. So, take a look at this example and tell me that building layouts is still better using HTML and CSS than using JavaScriptUI:
That’s a wrap
This concludes my entry for this week, I hope you’ve found it interesting. If you like what I am doing please support my work by clapping, commenting and sharing it with others.
Thank you, and have a great week.
⬅️ DevBlog #7 — There is no box model in CSS — and this is why borders are terrible