Building a Declarative State Machine in CSS, or: “How do I get two divs of unknown height to toggle?”

Christina L Austin
10 min readAug 19, 2020

--

an Intro post, with its content note mask (left) and unmasked (right).

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.

Minimal HTML for a .a-post-card as .masked-container. Some elements (like links) can inherently take focus; here, a tabindex tells the browser to add .mask and .masked to the collection of focus-able elements.

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.

DOM ordering visualized as boxes and as a tree . In the boxes, view CSS rules flow top to bottom. In the tree, CSS rules flow top to bottom and left to right.

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.
abridged CSS for styling a-post-card

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!

CSS for styling for masking States 1–3

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.

diagram of z-index inheritance, starting with the parent container, and showing .masked and its descendants above it
Declaring an explicit z-index on an element affects all descendants. Because .masked-container is given a positive z-index, none of its descendants can ever be underneath the parent container. Because the siblings are subsequent to .mask in the html tree, they will be visually on top of the .mask while having the same z-index.

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.

The browser indexes the user focus path from the focused node to the root. So while we can’t make the state of a node depend on information about its descendants, a given node always knows whether or not it itself is on the focus path and we can declare state based on that.

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.

CSS for hover behavior. In combination with the non-hover styling above, declares States 4–6

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.

State diagram of .masked-container and its children, as viewed from the “side”, with increasing z-index towards the top of the page. Solid lines will take pointer-events, and dashed lines indicate pointer-events: none. Inset boxes represent the web view of the cards, with the header and footer siblings in blue and always visible, and either the orange/hi-contrast .mask or purple/low-contrast .masked visible to the user.

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.

Rough diagram from my notebook of the masking scheme being applied to both a-post-card and to a-post-card’s sibling’s children in the same tree.

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!

--

--