If you're wondering what a headless UI is, or what molecular physics has to do with web development, just know, that I'm just as confused as you are. Let's begin.
You've probably used a UI library before. It's a set of components, usually following some common visual and technical conventions. Some are open source and some are owned and maintained by dedicated teams within tech companies. They aim to achieve the following goals:
- Streamline user experience by providing a common set of visual elements.
- Speed up development by providing a strong set of pre-made components ready to use for various use-cases.
- Improve both DX and UX by taking care of accessibility and usability.
By adopting a solid UI library, projects usually see and improvement in performance, usability and scalability. But how?
There are various performance implications of modern frontends. Bundle size is a key concern, especially for products where the customer impact of spending more time on loading customer assets can be translated to a loss of revenue, such as online stores and social media platforms. One product I worked on several years ago had a new definition for button styles for every page that loaded. The way that project was set up, a Spring Boot controller would determine the assets to send for each individual page.
To improve upon this project, my recommendation and subsequent proof of concept included the creation of a UI library to capture all the common interactions within the application and provide stable and re-usable styles for developers to work with. I've also introduced the team to React and the concept of a framework to be used on the UI. One of our typical pages took 16 seconds to load on average. Converting this page to a single page application, using React and adding components optimised for performance brought this down to 0.4s FCP and the table displayed all the data the user could see in 2.1 seconds on average. Being a frontend engineer, I'm naturally not good at maths, but this seems like a pretty big improvement to me. The UI felt snappier and users were happier.
There are of course, other aspects to performance. A customer facing online video streaming platform is our next example. The core of the product - the streaming client - that generated over 90% of the revenue and provided the experience that users kept coming back for since 2001. This was no ordinary video player. Since its humble beginnings in the early 2000s, it had come very far. Over 20 different features had been integrated into this client over the years. This included things like a live chat feature, token purchase overlay and even a battle mode, where two streams would be played at once, but using the same "virtual room". Of course not all of these features would be visible at all times. Some were seasonal and some depended on various user and site attributes. Did I forget to mention, there was also a whitelabel offering of this product? This complicated things slightly.
One of the key difficulties of creating the right UI tools for this job was performance. This website needed to run "buttery smooth" for millions of people from hundreds of different countries on thousands of different hardware setups. Many of our users had poor internet connection and used low-end mobile devices. We've used a mobile-first approach and scaled it up to meet desktop later on. Each component had to be carefully designed and created with performance in mind. Any mistake in logic, using the wrong data structure or running unnecessary cycles could cost the company millions.
My approach was the following: isolate each feature, turning the library into a core and a set of plugins. The core would authenticate and join the room. The streaming module would show you a video feed. Each feature came as its own module. The MVP was the core. It was so lean that another team even ended up using it for chat bots, running in Node.JS. Then came the best part: the UI layer. There were two types of UI components in this architecture:
- Composers connected the logic to the UI. They translated core and plugin functionality into user interactions and visible state. They were aware of the application state and they used visual primitives to communicate with the user.
- Visual Primitives are lean components. They generally consist of 1 or a few elements and their sole responsibility is styling and visuals. They know nothing about the application that uses them. You could take any of these primitives and re-use them across any other part of the application.
This architecture is overkill for ordinary applications, but this was no ordinary application. Using this architecture allowed a small team to fully recreate the entire feature set in a much more performant library, using state of the art languages and tools in under a year. The usage of visual primitives wasn't as well adopted or accepted back then as it is now, but it ended up the perfect choice for the product at the time.
As sad as it is, neither of the aforementioned products placed a strong emphasis on accessibility and interactivity - two things I'm particularly passionate about. As software engineers, we usually fall under the category of "power users". I use my keyboard to navigate websites whenever possible, but unfortunately, not a lot of companies make this easy for us. I also believe that it's important to make sure that we provide the best user experience to everybody, regardless of their personal circumstances. I consider inaccessible or non-inclusive UI technical debt.
This next project ticked all my boxes of a well-crafted, performant and accessible UI library. It's 2022 now and I'm sitting in my home office, propagating a change to our QA environment. I work for a multinational sports betting company in this story. We're responsible for the back-office product, that our colleagues use to interact with the betting platform. It's a low-latency, high-frequency type of application, with strong emphasis on usability. All users are power users. They need to be able to perform actions at the speed of the most exhilarating sports events and interact with thousands of data points effectively. Accessibility and interactivity are paramount.
I'm propagating this change to our QA environment. It Worked on My Machine™ and I've also tested it on our previous environment, before pushing it to QA. There's two more acceptance rounds, but I'm feeling confident. It's just a small change. Only a line on my screen, it's just upticking a dependency. The dependency in question is called Ant Design. It's an all batteries included UI library for React, created by the AliPay group and powering products that generate gajillions a year. I'm only going from version 4.18.9 to 4.19. What could go wrong?
A couple hours go by and our eager team of some of the best QA engineers on the market go to work. They meticulously scan every element, every feature and each pixel and colour to verify that everything aligns and the bug in question that the change was addressing was in fact fixed. And it was. But a new one also got introduced and this was a dealbreaker. Ant Design components often have an abundance of properties to customise behaviour and style. Table component alone has 98 configuration options. Even still, some things just cannot be implemented without workarounds. One of such workarounds was the addition of a form to add new data to a table. This form could be opened from a column title. We've used the
filterDropdown property of the table to achieve this. Ant Design, and more specifically the underlying library introduced a change that made the dropdown close on
tab key. This meant that the form inside the dropdown could not be properly navigated via tab key. This change silently made it to their latest version, which I then added to our project.
One dreaded Slack message and a new Jira ticket later, this change came to light. I've played around with it, but ultimately had to abandon the update as I could find no maintainable workarounds to mitigate this behaviour. It was easier to manually fix the issue we wanted to address with the update than to deal with the breaking change to our UI that this introduced. This did not spark joy at all. We've had our issues with Ant Design in the past, such as a lack of sensible options for theme customisation and the impact it had on our bundle size, but we powered through it. This straw was the one that broke the camel's back. I've initiated a new project to replace Ant Design completely. But what do we replace it with? There's no real alternative to the vast amount of functionality it comes packed with. It's also very deeply integrated into our codebase. Replacing it would've meant hundreds of hours of strenuous work. We still had no candidates for replacement.
I've proposed the creation of Yet Another UI Library™. This time we could go slow and take all the business needs into consideration. We could create something that addresses all of our issues with Ant Design from day one, while taking inspiration from it and others like it, to speed up development for our growing team of engineers. We also wanted to open ourselves up to the possibility to fully customise our UI. Traders who work for betting companies aren't like your average millennial. They don't want their buttons to look like frosted glass and their morphisms to skeu. When asked about interfaces they enjoyed using, they kept showing us pictures of DOS on CRT monitors. They sent us screenshots of cramped, horribly out of contrast interfaces with hundreds of different buttons and no visual hierarchy.
All they cared about was being able to do what they do as fast as possible. The goal was to enable them to do just that. This library isn't tied to any specific product. Its purpose is to be used by all internal products to standardise the look, feel and UX of all internal products. It needed to promise and deliver a lot in order for it to be worthwhile adopting. Each product team had independent governance over the tech stack that was used. The only way to convince a team of adoption was through value proposition. We had to make them a deal they couldn't refuse. The deal was this: we'll design and develop an entirely new API surface for this library from scratch, based on composition and the lessons I've learnt from the design systems I've worked on previously. Composability was key for us, because it enabled the numerous product teams we had to support to implement key features independently from us. Configuration based APIs, like that of Ant Design's lack the ability to extend without hacks or the addition of further configuration.
I like to talk about composition and configuration using dimensions. Configuration based approach is a two-dimensional one. You can either add or remove properties from your configuration object, factory or builder. This leaves you with limited ability when it comes to scaling your solution. It also often leads to various different types of properties being added using different paradigms, based on who implemented them. The composition based approach is a three dimensional one. You can add or remove features, but you can also extend it by stacking additional components on it. The back-end equivalent would be HTTP middleware. Rather than the server trying to know every single possible feature the user would want to add on top of it in advance, it exposes primitives and allows you - the developer - to add whatever feature you want on top of these primitives. You can even combine and re-use them. Same concept works for UI components.
A visual primitive is also often referred to as an "atom". If visual particles are atoms, then sub-atomic particles are native elements, such as a
<button> on the web. We apply styles on top of native elements to make them look nicer and to iron out inconsistencies between different browsers. A visual primitive can't be broken down into meaningful and sensible components. Not all primitives have to employ a single native element, but they often do. Visual primitives should be simple enough to always work with each other without modifications. E.g.: placing a button inside a cell in a table should be achievable by simply rendering the two components. No workarounds should be necessary to achieve interoperability between visual primitives.
Some libraries provide a set of styles via a stylesheet. These aren't visual primitives. They could be a part of a visual primitive when applied on an element. Picking the element is crucial. Adding button-like styles on a
div won't make it a button. You can add a click handler, but your users won't be able to interact with it the same way. It won't be focused via
tab key, it won't interact natively with form elements and so on. This is why picking an element is crucial in creating visual primitives.
Sometimes elements aren't enough. The HTML spec has loads of elements for us to use, such as
div, but there are some use cases that haven't been added to the spec.
<tab for="one">Tab One</tab>
<tab for="two">Tab Two</tab>
<content id="one">Content of tab one</content>
<content id="two">Content of tab two</content>
Focusing it, the screen reader would read something like:
"tab trigger, opens Tab One". Pressing
tab key would allow users to navigate between the two tabs natively. Pressing
enter key would select the currently active tab. Selecting a tab would make it "active". In the DOM,
content elements would have the following behaviour:
- If a
forattribute that matches the
contentelement is in
- Activating a different
tabin the same
navas the one that currently controls the
contenthides the element.
- Newly activated
contents gain focus, the same way
My point is, HTML native tabs don't (yet) exist. One might want to create a visual primitive for tabs. The way to do that is to replicate the native-like behaviour, some of which I've listed above. We don't want to have to do this every time we want to create a visual primitive for tabs. This is where headless UI libraries come in.
Headless UI libraries are libraries that provide the bare logic behind a certain component. That component can be very complex, like a data table (e.g.: @tannerlinsley's TanStack Table) or simple, such as a button, with things like
aria-disabled attribute applied based on whether a
disabled prop is passed in, because it gives a more inclusive user experience, compared to
disabled attribute. Some headless UI libraries are framework specific, like those made specifically for React or Vue and some are based on Web Components and work across different frameworks.
Decoupling styling from behaviour has advantages and disadvantages. I think the pros far outweigh the cons, but I'll let you decide.
- Increased setup time
- Paradigm shift requires education
- Potentially more lines of code overall
- If the logic and the styles are kept separate, maintainers need to perform the following activities for both:
- Hyper-focused codebase. This allows developers to truly isolate a certain aspect of development, e.g.: logic, performance or visuals and focus on that aspect in isolation
- Polyglot UIs. Separating visuals from logic allows maintainers to simply extend the library to be used with many frameworks. For example: my current company uses Vue and React. We built our UI library with React, but we could take the headless UI library we used, called Radix, install its Vue counterpart and plug in our styles. We'd get identical behaviour and looks, but a truly polyglot system.
- This method introduces certain limitations. Why is this a pro? These limitations make it more straightforward to keep in line with good engineering practices that enable your team to build your library on solid principles. Some of these practices:
- Relying on the browser for as many things as possible
- More scalable codebase, because each module is limited to a very small subset of features, adding to it is almost always linear.
- The chance of a change affecting something unexpectedly is minimal.
I'm sold, what does the architecture look like?
Take a Button for example. We'll have modules to begin with:
- Headless button - this could simply be a HTML button element.
- Button styles - this is the "head" to the headless button. It provides raw styles according to our design guidelines.
- Visual Primitive - this will be a framework specific combination of the headless button and the styles.
Implementing a React and Vue version of the library would yield the following project structure:
I've used countless styling solutions, from CSS & BEM, through CSS Modules, CSS-in-JS - both runtime and statically extracted, inline styles, utility frameworks and everything in-between. When picking a styling solution, you want it to compile fast, produce performant CSS and improve developer experience.
To me CSS-in-JS doesn't tick all of those boxes. More concretely put, it has too many foot-guns, when it comes to performance. Unless you know how to work around limitations, you can end up creating an application that has some serious performance bottlenecks. Most CSS-in-JS solutions also require you to define styles separately and then use them in some way. This means you have to worry about naming - the most difficult thing in programming.
Using plain ol' CSS is fine. In 2023 and beyond I wouldn't use any pre-processors, like SASS and SCSS. Modern CSS is more than capable of covering the majority of use-cases. Using a pre-processor limits you to a set of features implemented by that tool. When these tools go out of fashion and become unmaintained, you'll end up with bugs unfixed and CSS-native features missing. I do, however, recommend post-processing. Ironing out differences between browsers and applying a CSS normaliser or reset will give you a nice base to work off of.
I've had the best results using CSS utility frameworks, like TailwindCSS. The idea is to turn each atomic style into its own rule and apply it via classname. Tailwind isn't the only option, but it's the most popular one today. A cool alternative we use at my current company is UnoCSS. Other notable examples include GitHub's Primer Design System that also comes with a utility framework that's similar to Tailwind. These tools are opinionated, easy to use and set up, quite simple to onboard people into and allow you to move fast.
Another advantage of using utility frameworks like Tailwind, that isn't as talked about yet, is that they play very well with LLM-based code generation tools, like GitHub Copilot. Both the elements and the styles are in the same place, so it's possible to generate parts of UI in one go. It also makes it easier for the AI tools to pick up on the general style and layout of your project. We've gotten the best AI generation results so far by using utility frameworks.
When choosing a headless UI library it's best to take all available popular options out for a spin and see which one works closest to your project's needs. We didn't consider the API of the library, because we knew we'd be able to adapt any library to our needs, using transformations or a compat-layer depending on which one makes sense. Most headless UI libraries come packaged individually per component, which means it's also possible to mix and match. Switching over from one to another can also be done incrementally due to this. We currently use a mixture of Headless UI by TailwindLabs, Radix UI and TanStack Table for data tables.
About a year ago, we've made the commitment to switch over to Vite from Webpack. This gave us improved bundling times and better development experience. Fast refresh blows hot reload out of the water and the plugin system is much more straightforward to work with. In fact I've even ended up creating a custom plugin for our products to enable module federation for our React frontends, and it was a downright pleasant experience, compared to writing Webpack plugins. Bundling applications is just as simple to do with Vite. Using the same tool to build our apps and libraries gives the added benefit of being able to apply the same level of optimisation out of the box on both ends. This leaves us with no unnecessary polyfills or wrong build targets. We can also use the same set of plugins for both, which means we could even expose the UI library as a microfrontend via module federation at some point if we wanted to.
For testing, we're using Vitest, another gem by AntFu. It works with our Vite config directly, so whatever code our tests are running will be the same code our users will be getting in their browsers. This is very important and a huge step up compared to using a Jest based setup that I often relied on in the past. Gone are the days of "why does it work in my browser and not my tests?" and vica versa. Our development environment is powered by Ladle, which is a Storybook alternative, based on Vite (you might notice a pattern here). Using the same build tool to run everything end to end means that we've so far reported 0 issues, caused by the misalignment between different environments. The final piece of the puzzle is integration testing. One of the most important aspects of maintaining a UI library is visual integrity. Introducing unwanted visual and behavioural changes can break trust in a product. Using PlayWright and our Ladle stories, we can set up a suite of tests to compare behaviour changes between branches. We can also take screenshots which PlayWright saves inside the project. This is quite cool, because intended differences are expressed via git commits.
So, there you have it — a whirlwind tour through the quirky and sometimes bewildering world of UI libraries. From battling the behemoths of bundle sizes to choreographing the delicate dance of accessibility and interactivity, it's been quite the journey. We've navigated the seas of visual primitives, tiptoed through the complexities of headless UI, and even dipped our toes into the ever-changing currents of development tools.
What's the takeaway from this techy tale? Whether you're streamlining a sprawling video platform or jazzing up a sports betting interface, the right UI library isn't just a tool; it's a trusty sidekick in your quest for customer and developer satisfaction.
Remember, in the ever-evolving landscape of web development, the only constant is change. So, keep your wits sharp, your code cleaner, and your UI snappier. Until next time, happy coding!