Building a Declarative State Machine in CSS, or: “How do I get two divs of unknown height to toggle?”
I build and run an indie social network called “Intro”, which is founded on the idea that it is possible for an online social space to operate with an affirmative consent paradigm. So one of the differentiating features of Intro (after “absolutely no data selling or paid ads, ever; the monetization model is a direct subscription”) is that posts, replies, and messages all have the ability to have a “content note”, which is basically a title card that sits in front of the post/reply/message card. These are nice for spoilers or trigger warnings, or for anything you want to ensure that the reader has opted-in to with informed consent. They’re fun for jokes where you don’t want to give away the punchline, and for sending asynchronous sexy messages. And they’re fun because explaining how they work to the folks in your life who like to say “pure logic programming” and “monotonically decreasing” may cause them to say “pretty please write this up”.
I needed content notes:
- To be as unobtrusive as possible; they should work without requiring a click when hover is available.
- To work on mobile; they should be able to open on a click, and toggle closed again with another click within the viewport.
- To always be the exact same size as the underlying card.
- To prevent the user from clicking things under it that they can’t see.
- To not prevent the user from clicking things they can see.
- To work entirely without JavaScript, in case the user has it turned off.
This is a lot of requirements! And I could not find anything* on the internet that could explain to me, without Javascript, how to make two divs toggle with repeated clicks in the same place, so I thought I’d share. You can follow along with the CodePen as we go through, but n.b. that code is a superset of what I’ll show here.
(*Shoutout, though, to Jen Simmons, without whose twitter I would not know about CSS grid, and to Trys Mudford, for his post which unlocked a lot of this by introducing me to the pointer-events property, sibling selectors, and transitions.)
Most of my career as a software engineer has been spent writing server-side code, and like many primarily-back-end engineers, I had very superficial understanding of how to work with CSS. I have learned:
- Like a Terraform config, CSS is is declarative. You describe how the world should work, and the browser engine makes it so.
- Like in SQL, CSS operations are logical set operations. (Your sub-sets are sub-trees.)
- Like many fast data structures, you can’t backtrack up the tree. CSS rules start at the root and flow down the tree, applying until the declared conditions are not true.
To think about the way my CSS will operate on my HTML, it’s important to deeply understand that HTML is a tree structure. I picture a web page turned “face down”, with all of the child elements hanging off of their parent elements. Imagine a CSS rule flowing down every branch towards every leaf: when it arrives at any given node, it has accumulated information about everything above it and nothing below it. This is why we can’t declare any rules for parent elements based on anything about their children.
One more special thing about the HTML tree that our CSS will operate on is that the child nodes are ordered (based on the order of appearance in the text of the html). So as a rule flows down the tree, it can pick up information about sibling nodes that are before the one it is at, but it can’t know things about siblings nodes that are after it. This is why you can only declare sibling rules that apply to subsequent siblings of a given element.
The CSS technologies involved in this project are:
- CSS Grid, which allows us to position elements on top of one another without using absolute positioning.
- z-index, which governs element positioning in the “z” space, where “x” is left-right on the page, “y” is is top-bottom, and “z” is between the page and your face. Z-index resolution is GPU accelerated, and therefore setting this property constrains all of its descendants.
- pointer-events, which governs whether the browser (CSS and JavaScript both) will pay any attention to anything your mouse does over your element. Can be changed on descendant elements.
- opacity, or “if this element is between my face and some other element, how much can I see the element behind it?” Also GPU-accelerated and constrains all its descendants.
- transition, which declares a rule about what should happen when a state is changed. We’ll use this with opacity to create a “fade-in”.
- *, wildcard selection; ~, subsequent sibling selection
- :not(), :focus, and :focus-within pseudo selectors
- @media query for hover capability
First, we need the actual HTML (embedded gist below). We have a container, .a-post-card
, with two children that can take focus, the .cn-body
as .mask
and the .post-inner
that’s being .masked
. We have two siblings, a header and a footer, that should show regardless if the .mask
is front or not. Text content of .masked
and siblings are nested as inline elements, so that the browser’s idea of what’s being clicked aligns with where the user sees text — this allows the user to select text and interact with links, but retain the intuitive toggling behavior on any part of the card that is visually blank.
The .post-inner
card, header, and footer are subsequent to the .mask
in the DOM, which means the default z-ordering will place them visually on top of the .mask
. It also means we can apply CSS rules to them based on the state of the mask, while CSS grid allows us to visually position them where we want.
Keyboard navigation presents an additional reason to place the .mask
prior to.masked
. If a user is navigating with a keyboard or screen reader instead of a mouse, hitting the content note after the content doesn’t help them.
I put two sets of classes on the container, mask, and inner card to be masked because I have reused the masking behavior elsewhere, and I want to visual style (color, text alignment, padding, ect) the post card specifically, separate from the masking behavior. Important points from the post styling CSS:
- Container isn’t intended to be visible at all, has no background or border. Container exists for the grid.
- You can use
1 / -1;
for “all” grid columns or rows. By positioning both the.cn-body
and the.post-inner
to cover all rows and all columns, we ensure that the container will grow to contain whichever is bigger. Whichever card has shorter content will simply have more blank space. - The content note card
.cn-body
is a saturated color and requires white text for contrast.
Next, let’s look at the CSS for the actual masking behavior without hover. The full explanation is below, as is the styling for the hover behavior. State machine diagram and CodePen link at the bottom!
At the top of the file, I note some assumptions that this code relies on: we don’t want some instances of .a-post-card
to be on the page with a positive z-index and some not, but we also don’t want the .post-inner
aka .masked
card to be invisible in an unfocused state if there’s no content note .mask
. We set up the .masked-container
with a z-index that elevates it above whatever its own parent element is so that it can have children with a negative z-index which are clickable, provided .masked-container
itself doesn’t take clicks.
We alter the default z-ordering of .masked-container
's children. Default z-ordering puts subsequent DOM elements visually on top of earlier ones (like laying down cards), but by assigning explicit z-indexes to .mask
and .masked
, we send .masked
below .mask
, out of sight. We’ll call this State 1, when neither .mask
nor .masked
are focused. In this state, .mask
is visible, but we tell it not to take pointer events so if we click within the post card, our click will focus on .masked
.
In State 2, when .masked
is focused (or when one of its children is), we declare that .masked
should come up to a neutral z-index, which places it visually on top of the .mask
but below the header and footer. The z-index on .masked
changes immediately, but it doesn’t appear immediately, because we put a transition on opacity. To best serve content notes’ core use case, .masked
content will become visible gradually but be hidden immediately. We say now the .masked
card itself should not take clicks, but that the children of .masked
should — this allows the user to do things like select text and click on links while still allowing clicks on blank parts of the masked element to toggle the .mask
back on.
Because we can’t apply CSS rules to .mask
based on the state of .masked
(the latter follows the former in DOM order and we can only apply rules based on what comes before), we use the :focus-within
property of .masked-container
(see the image below for why this doesn’t violate the no-backtracking rule), combined with the :not(:focused
) state of the .mask
itself to know that one of .mask
's siblings is focused; in this state .mask
will take clicks. Thus, clicking on a blank part of the .masked
card while it is focused will “fall through” and focus on .mask
, bringing us into State 3, where the .mask
is again visually in front of .masked
. Subsequent clicks on blank parts of either the content note card (.mask
)or the inner post card (.masked
) will toggle between them.
State 3 has the same z-index and pointer-event properties as State 1, and technically, the .mask
itself taking focus isn’t a requirement to set up the click toggle on a non-hover device. But .mask
taking focus is required for keyboard navigation and for the hover behavior.
We explicitly restrict the hover rules to devices that have hover capability, because sometimes mobile devices will make up their own rules about what constitutes hover state.
State 4 is characterized by hover with no focus. Because the .masked
card should be revealed on hover , we give it a neutral z-index. This puts all the children of .masked-container
back to their default z-index ordering, with .mask
on the bottom because it’s first in the HTML. But we still want the first click to pin the post “open”, with the .masked
card revealed, so .masked
will take pointer events in State 4.
The first click while hovered, which lands on .masked
, takes us into State 5, which has the same z-index and pointer-event properties as State 2. The .masked
card is visible and will stay visible if we unhover (which would put us in State 2. The .mask
will take clicks.
The second click while hovered will, finally, take us State 6, which has the same pointer-event properties as State 3. It also has the same *relative* z-indexes, though everything is shifted up 1 to account for the fact that .masked:hover:not(:focus)
's z-index is 0, instead of -1.
One of the cooler things about this setup is that you can put it at multiple levels of the same tree and it works just fine.
I hope you have enjoyed reading about declarative programming with CSS! This project changed my understanding of what it is possible to do in CSS, and the code I’ve shown is actually my third version.
The first version also used opacity and pointer events, but it had the :focus
backwards, with the focused card not-visible. This was a pretty big problem, because users could not select text (such as the intro codes that are required to connect/”friend” someone) and they couldn’t click links, if the post had a content note. (Edited to add: The first version was also not laid out with grid! Which made a lot of things difficult and some things impossible.) So the second version was a complete re-write, and I incorporated better pointer-events rules to get concordance between the visually-front card and the focused card. But in the second version I used a lot of explicit positive z-indexes, which became a problem when I wanted to be able to click on an image and have it get bigger. Clicking on an image to make it get bigger is such intuitive behavior that I would do it on my own website, despite knowing very well it wouldn’t work! So the third version makes much better use of the default z-ordering rules to remove explicit positive z-indexes from things the user isn’t directly interacting with. Additional explicit positive z-indexes come into play on the focus path so that “click on the image to make it get bigger” can also be a pure CSS behavior, but that is another post. :)
See this CodePen for a live demo. (It has a superset of the code from this post for somewhat better styling.) Thanks for reading!