Real-world CSS custom props
While, this talk is about custom properties, it's also about moving on from popular abstractions and embracing native web functionality.
And the story really starts in 2018.
When I took over Quantium's teams, UI library called Qbit.
And before I joined, not long before, it had been started off as a set of design tokens in JSON and Saas variables.
And that was a really good place to start given the projects they had at the time.
But I was looking at the future and what our future requirements going to be, and really started thinking about how we wanted to handle style regardless of how we were doing it, but how we wanted to handle style long-term.
A big thing about Qbit is it's got to be very unusually framework and platform agnostic.
Our teams have a lot of technical autonomy.
Even at the start we were already talking about Angular and React.
Some people were talking about Vue and then outside the front end developer world, we had analysts talking about our R Shiny and Dash because they live in Python and R and that's where they wanted to do the visualisations.
So we needed the style payload to be as portable as possible, being tied to Sass compilation really wasn't great.
And the incoming trend for CSS in JS wasn't going to help us all that much because we weren't going to be using JavaScript.
On top of that, Qbit supports 10 color schemes out of the box.
They're mostly used separately, but we do have scenarios where they've got to be used out of the same payload.
And from experience, I was anticipating that Qbit would end up needing to support custom themes for co-branding, joint ventures and other partnerships.
It's not a big jump from there to imagine that we would need dark mode, that we would need to be able to swap our theme easily at runtime.
So I took stock of our experience with Saas imposing the stack choice was a problem, uh, being compile time only really limited our customization, the experience wasn't so great.
It did give some really nice error handling though.
And I was thinking about the overall experience that came out quite highly.
Saas supports themes really well, but unless you are truly intense about maintaining a color style sheet, you do tend to end up generating a whole payload for every single theme.
And Saas does enable customization via the default flag, but being compile time, that does mean that consuming projects have to handle it during the build and heaven forfend you configured a default flag because now you've got to go all the way back upstream, release your UI your library all over again, and in the meantime, people are back to brute forcing your styles and the old way until that new version arrives.
Not much good the day before launch.
So I wanted a better away.
I wanted a payload that I could use anywhere and update it at runtime.
I wanted consumers to be able to easily customize things, no matter what stack that we're using.
So all things considered I decided to bet big on plain old CSS, native CSS shipped as CSS.
And we set up an API system that I referred to as a layered API, a private API for Qbit developers, which is the HTML and CSS patterns and a little bit of ES6, not a lot.
And then we had a really much more strictly defined public API, which is the tokens and the templates.
We phased out the Saas variables and no one really minded.
We did retain Saas in our build because the implementation was already set up to do things like pulling tokens out of JSON and a few things like that that was convenient to keep them.
We didn't truly need it for the styling anymore.
The core of the system is to set common variables on the root selector, and then we use a data attribute to choose the theme that worked fine for us, a class would work fine, but this helps us differentiate sort of the theme switcher from the other styles.
From this consumers get a single payload, which enables all of the themes.
Since the variables don't add up to much extra weight, really that wasn't an issue.
If you had extensive themes, you might need separate files for this and choose which ones to load.
Consumers then set the theme for whichever color they want and they can do this on the fly, usually they do this on the body element, but we could do it anywhere in the view.
This was an early requirement where we needed some areas to be maybe styl in Qbit and other areas still be in the legacy code.
Where it really started to become obvious that we'd hit something was if you needed a custom theme, you just set a custom value in that theme attribute, and a surprisingly small number of variables.
The custom variables are added to the live and available options.
You can even add this later on and because of specificity, it gets pushed up and works just fine.
Our minimum theme is actually just 18 lines of CSS, but given that most of those can be generated with a pallette generator, you really only need to decide on two colors and you've got yourself a new theme.
So it was about four years ago.
How's it going?
First off the basics.
Qbit is now used in about 20 customer facing products.
It has done its fundamental job of making it faster and easier to ship.
Plus we have a ton of internal applications and a few third-party reskins that would otherwise have just looked stock and they have our brand applied instead.
Fundamentally, again, this is not about the tech we use, but it is evidence that it's meeting its requirements, it's doing what it needs to do, to the extent that it's recognized as a strategic technical investment.
I even had to record a marketing video about it at one point.
That surprised me.
We do have a lot of products using different themes and we ship quite a few custom themes, particularly for our joint ventures, kind of, as we expected.
The Eagle eyed might spot a couple of brands that you recognize there.
We did rationalize a little bit on the engineering side, phasing out Angular to focus on React.
It turned out the next shiny thing would actually be the Python framework Dash.
Behind the scenes it does use React templates, but it's styling mechanisms in its own API are all pure CSS.
So it works out perfectly to be using the native option.
We've got a few other interesting ones, like some documentation generated for markdown files.
They were able to use a static payload and just have the brand applied.
It was nice, light, easy, very simple to get working.
As I'm writing this presentation, we're also looking at R Shiny coming back into the picture.
So again, this vanilla CSS payload just keeps giving us freedom and options.
That's probably a talk to itself.
We also found that CSS custom properties enabled a huge amount of customization opportunities for our consumers.
Essentially, this came for free.
It was just in the payload and you can choose what you needed to tweak.
Where Saas required you to know quite early in the process to allow compilation of different styles, the custom props allow you to do anything.
Because it's at runtime, it also means anytime during that build process, you can target the element or instance that you need to target and just change the property that you need right there.
And being able to neatly update a variable also meant the overrides were extremely terse.
This helps a lot with complicated components.
Things-oftentimes this is third party things that you've chosen to wrap and include in your library, where you might not be able to control the patterns, but you do need to apply an overwrite more than one place.
Instead we're finding, we're just setting one variable and it just took care of itself.
There were some gotchas here, which I'll get into a bit later.
I will mention IE11, although we have finally been able to drop IE11 support, because we had a few banks on the books we had to support IE11.
So a few people thought we weren't going to be able to do this.
And particularly, cause we use a lot of `calc` as well.
Turns out we were able to make a genuine decision about graceful degradation.
IE11 never got themes.
It only ever got blue theme.
The thing about this that is interesting that almost nobody even noticed-those who did notice didn't care.
So if you ever need an argument do all browsers need to look the same?
They really don't.
We did use postCSS to generate an extra line of CSS for IE11, it did add a little bit of extra to our payload, which we were happily able to remove when it's, uh, we dropped support.
With all of this said, though, what did I miss?
What was I missing from Saas?
And after using CSS custom props, what I missed from Saas wasn't the primary features, the things you might expect, but errror handling.
Saas tells me when I make a typo in a variable name and it tells me what an import is missing.
And that was a problem in a way, because in CSS, non-draconian error-handling means that you get silent errors, custom properties that aren't there just don't tell you that.
It was actually quite a pain to debug.
Stylelint to the rescue.
We were able to wire up, Stylelint it to catch this.
You get author-time alerts when you've missed something.
If you let one through, if it gets out to production, you still don't throw areas in production, but we find that it doesn't happen because this catches it.
And we see it during code review as well.
You can fail your build on it as well, if you have a, a separate build for your pull requests.
I did mention this briefly, but as it's not the focus of this talk, I won't dwell on it.
We did you see us as `calc` a lot alongside this choice.
Uh, there were dire predictions of performance issues.
We never saw them.
We found that it was just useful and so long as you don't go crazy.
And you're judicious about what you calculate.
They're just useful and part of your toolkit and they had no negative effects.
While we do you still use Saas, we offload a few things there, you could probably just, hard-code a few choices and get around needing to calculate everything over and over again.
This is, uh, something I think we'll do in future.
And it's probably something that you would consider alongside the choice of custom properties.
Like all good stories, the best bits aren't what went well, the best lessons are the stuff ups.
So let's get into those.
Most of the problems were about breaking old habits and adopting new ones that better suited the new technology because naturally my lens for CSS custom properties was Saas vars, and general CSS.
And these don't transform, uh, transfer over as uniformly as you might imagine.
A really basic Sass habit is you need to import your variables everywhere that you plan to use them.
And you don't really notice any more, but you're importing in every single file.
It's just boilerplate.
And it's just there.
It doesn't matter though, because Saas variables aren't CSS, they get compiled away-it has no impact on your payload.
CSS custom properties, though, they are valid CSS and so Saas will dutifully repeat them every single time you import them.
And when you multiply this out by every single component file you've got, this blew upour payload really badly.
And I actually did release a version that had this problem.
The debugging was diabolical.
You had dozens of copies of the variables over and over again through dev tools.
Terrible, really bad.
Short version of this is make sure you only compile one copy of your CSS variables.
And then you're fine, Unfortunately, fixing that bloke meant I no longer had error handling because the debugger couldn't find the CSS variables to know what's going on.
Because Stylelint uses static code analysis, even though it worked fine in the browser, the tool in the builds couldn't find it, it didn't know where to look.
It wasn't running it in a browser.
It was just looking at the file.
So I thought, okay, I'm going to have to live without linting.
But sometimes open source comes through for you.
And right at this moment, someone had gone to that plugin project and said they had the exact same problem as I did.
And a few days later a new version came out and we're back in business.
Another bad habit got amplified.
And this is something I realized I'd developed a terrible habit with Saas, being very lazy, with Saas variable naming, not putting them in a namespace and just allowing them to basically be global.
And this goes back to a long time earlier when I'd been carefully name-spacing everything, and someone said "they're been compiled, what does it matter?" And so few people had ever used Saas vars in there end product that we'd never had a clash.
And so we just didn't think about this, but as we know, CSS is of course, global.
It is simple enough to fix.
You do need to put a namespace in.
This is something that I'm just doing it in new release, it's, to be honest, it's a GREP.
It's not been that hard to do.
It's a script that will both run on the library and run on the products and will take care of that.
But it is one to watch out for from the start.
You got to remember this forms part of your API and that's from there, everything else will follow.
The biggest issue in the end was rewiring my brain.
I had to really still thinking about compile time or author time and start thinking about what would be happening at runtime in a whole new way.
This has always been where CSS lived, but this was a new element to it.
A fundamental thing, so fundamental you don't think about it is that you use different selectors to apply different styles.
And it means you've got years and years and years of practice and habit of doing that.
But selectora mean difference.
Custom properties enables that flip of applying differences using the same class, but a different value.
Or perhaps you could think about this as configuration to cause difference.
Now this is of course familiar, if you've been generating different Saas payloads for a long time, you'll have seen this pattern.
You've probably gone through this mental shift.
If you hadn't done a lot of that, you would still be hitting that cognitive load when you move to ...CSS custom properties.
But those separate payloads, still contain component variants, and so the CSS habits still persisted at that component level.
And this one really broke my brain until I realized I was still mixing my approaches.
I was trying to go somewhere between difference through configuration and difference through selector.
Once again, it was driven home that we were using configuration to create difference, focus on that, follow that through, remove the competing options and things came together and it was all fine.
I mentioned earlier on the proxy variables we were creating in our Saas.
That was, uh, a convenience thing that made a lot of sense in Saas because you could namespace something down to the component.
You're essentially doing sort of localized, what we called 'proxy variables' and this got really tricky, and this is not necessarily a bad habit with custom props, but the impacts are a little bit more complicated because it brings specificity into play.
And this is one of those moments where you've got to remember, they're not variables and they really aren't variables.
To think about a variable for moment, a variable can be reassigned, but can only hold one value at a time.
And you can define multiple values for a CSS custom property and ship them all and they are all valid at the same time.
And they apply according to inheritance and cascade.
And the mistake I made was creating a component that set vars on root and not just the component selector-I mixed it up.
And this does work.
But custom themes really need to either be set on root, which was inconsistent or they had to be explicitly handled in any custom theme that you had to any customization to that component that had this mix-had to have either extra variables or extra selectors-it was really, really annoying.
However, the thing I want you to think about here is this isn't automatically bad.
If you wanted to protect part of your design system or protect a particular element this could be a quite useful pattern, where you can set a global theme, but then break out of it and protect it.
So that if someone puts a change through that they might not expect to apply a certain way, you can make that choice.
But in my case, it was just creating inconsistenciesm, so they had to be refactored.
This pattern here is reasonably safe.
You keep the variable confined to the component, so the custom theme value will flow through pretty much automatically.
I will say all of this messiness, I feel it's very likely, we'll find better patterns through CSS layers, which you'll hear about more in another talk.
In summary, at the end of all this fundamentally, are we happy?
Are we happy with this choice?
Absolutely.
Would not go back, have no regrets.
Uh, it worked for us.
It hit all the things that we wanted to do, and we're not looking back.
The things that we'd set out to get, things like a single payload that handled all of our themes, that works.
The ability for consumers to use any technology they want and not be constrained, that works.
The customization is very, very easy and having everything available at runtime just keeps giving us options.
Portability pretty much wins.
The portability of native features has proved to be absolute gold for us, not just for our own products, but in our use and theming third party and cloud systems and prototyping, and a bunch of other scenarios we weren't even thinking about.
But it's interesting, you'll often be given access to apply some styles to something third party, but not run JavaScript.
So if you want the full power of your design system, maybe put more in the CSS.
The key to all this was to embrace the difference between CSS custom properties and compile time variables.
To think about those two different phases in your build and life cycle.
The great thing I think is they give you the flexibility of variables, but the power of specificity in the cascade, it gives you lots of new and interesting tools in the way that you write and administer and extend your design system.
But do you remember, you've got to build new habits.
They aren't variables.
If you think of them as variables you will have traps.
Treat all the features of CSS as powerful features, additions, not challenges to be avoided.
And ultimately think config driven style.
As I said, in many ways, this story is truly about investing in the web, on making the bet on native technology.
Many of the key benefits that we experienced came simply from the fact that it all worked right in the browser.
We didn't need to ship extra code.
We didn't need to have extra compile steps.
Anything that could produce HTML could use everything in Qbit.
We are so used, I think, to piling more complexity and tooling onto everything we choose that we forget that actually you can trust things that are simple.
Simple is not a bad thing.
In this case less is definitely more.
So, my last thought to you is use native CSS.
It'll give you options.
Thank you very much.