guregu

With modern CSS, the dream of the declarative UI is real

I'm very excited for :has(). It might ruffle some feathers, but I am definitely CSS-maxxing my next personal project with DOM as the source of truth. Embracing CSS allows you to eliminate much of the complexity around state management in a simple and declarative way.

DOM is the state

Much pain in the world of frontend frameworks today involves sharing state between components to make trivial UI adjustments: "make the tab header bold if there are any unread messages in it" can involve complex state machinations, invoking questions like "who owns the state?", "how do I re-render the header when the unread state changes?", and "is it possible to have a generic tab component if it needs to be aware of minute details of a deeply nested component?".

Or, you could write 3 lines of CSS:

.tab:has(.message.unread) .tab-title {
    font-weight: bold;
}

You can change a tab's messages in any way you want and the tab title will Just Work. You could delete the unread message element, remove the unread class with JS, replace a tab's contents using htmx, insert the HTML for a new unread message received real-time via SSE, etc., and the tab header will instantly update without any extra effort. No state juggling required; the DOM is the state.

Filtering

Another place that CSS-maxxing shines is filtering. It turns out browsers are very good at applying CSS rules quickly. We can represent certain UI states with data attributes or even directly as checkboxes to take advantage of CSS.

For example, this rule implements an "unread filter" by hiding already read messages and tabs that don't contain any unread messages.

:has(#unread-filter:checked) :is(.message:not(.unread), .tab:not(:has(.message.unread))) {
    display: none; /* or opacity: 0.5, etc. */
}
/* or, no-checkbox version: */
.tabs[data-filter=unread] ...

For the no-checkbox version, a sprinkling of JS can take care of the state transition:

<button onclick="this.closest('.tabs').dataset.filter = 'unread';">Unread only</button>

Of course, it's easy to show and hide appropriate buttons (or change the text within) based on state represented by a data attribute.

A while back I made an example of table filtering using CSS. Attribute selectors are quite powerful: out of the box, they can handle use cases like case-insensitivity, partial matches, and list membership.

Prolog in CSS? Hell yeah

I've seen jokes that :has() is adding Prolog to CSS. That sounds awesome to me. It would be even cooler if we got logical variables with unification:

/* not real, could u imagine tho? */
#tabs[data-status-filter=?Status] {
	& .message[data-status=?Status]			{ display: unset; }
	& .message:not([data-status=?Status])	{ display: none; }
}

Unfortunately we don't yet have such a power, so a bit of reptition is sometimes required.

What's wrong with it?

I imagine a lot of web developers will hate CSS-as-logic. I've worked with frontend developers who said that SSR was "outdated"1 and backend developers who refuse to write HTML. At some point, web developers forgot that their job is ultimately to show stuff on screens and "full stack" became a thing. I think most of the pain of webdev could be eliminated in a full stack, hypertext-driven team with developers who aren't afraid to write CSS.

I do think there are some websites that are truly complex enough to need an SPA with a fancy framework. Some call these "web apps". Your average web developer isn't working on one, but they might have built it that way.

  1. granted, this was at a time before JS frameworks rediscovered SSR

#css #logic