DEV Community

Jackson Gabbard
Jackson Gabbard

Posted on

Goodbye CSS-in-JS

Developer experience matters. A lot. Unfortunately, CSS-in-JS just doesn't give folks using Cord's SDK the flexibility or the speed they need. So we tried something new: Vanilla Extract.

Actual footage of the browser painting pixels

If there’s one thing we’ve learned this year, it’s that developers have to be able to customize the sh*t out of our components. Want to make a text composer Barbie themed? By god, you should be able to!

Of course, not all customer requests are that... niche.

More often than not, they want to change the border on a thread, or add a hover effect to match their application. Pretty basic. Until now, we could almost-comfortably tell customers they could make their vision a reality... but it required us to make a CSS variable available to customize each and every specific piece of UI.

Did it solve the problem? Sure. But was it scalable or a good developer experience? Absolutely not.

This all goes back to our decision to add shadowRoot to our web components. This choice had a very surprising number of downstream consequences. ShadowRoot prevented developers’ CSS and Cord’s CSS from interfering with each other (which was the goal). But, because of that, it limited developers’ ability to style Cord’s components.

And so we kicked off a project to ditch the shadowRoot and make Cord fully customizable by allowing developers to use the full power of CSS ✨

The goals of the project were:

1. Make every piece of Cord UI customizable via plain ol’ CSS:
If you know CSS, you should be able to use your knowledge exactly as it is in your head. And if you’re using anything that eventually compiles to CSS, that should work fine, too.

2. Create the best developer experience possible:
You should be able to open the developer tools in your browser, and understand how to write the CSS you need.

3. Re-write our CSS without causing self-inflicted problems in the future:
Might sound obvious, but we got here with good intentions. So this time around, we were extra careful about making simple, future-proof choices.

4. Move away from JSS to boost performance:
Bye-bye runtime CSS cost! CSS-in-JS seemed like such a good idea, but we hit loads of performance problems caused by run-time JS->CSS transpilation. Never again.

How did we get here?

Our initial build (using CSS-in-JS and ShadowRoot) was very developer-ish. We wanted to protect our babies from the cruel, cruel world of Other People’s Code™.

But, like any overprotective parent, we clipped our babies wings and, in the process, limited developers’ ability to match Cord to their UI. Customer after customer crashed into the brick wall we’d created. It was time to peel back the layers and give more control away.

Our first move was to get rid of CSS-in-JS. Why? Because it creates dynamic classnames. Dynamic classnames are great for keeping the cruel world away, but they’re absolutely useless for letting developers style Cord to their satisfaction. So, the dynamic classnames had to go 👋🏽

Drake doesn't like dynamic classnames either

To replace it, we provided our users with meaningful classnames they can easily write selectors for (yay for Goal #1), but we still wanted to be able to write our CSS within TypeScript (because that serves Goal #3 of future-proofing our code).

We started by experimenting with migrating our JSS code to Vanilla Extract. While Vanilla Extract and React-JSS are both CSS-in-JS solutions, they’re very different in terms of how you write the CSS with them.

JSS and Vanilla Extract Syntax Comparison

We wrote a codemod with jscodeshift that would take out the JSS style from a component, create the .css.ts file, and move what’s needed...all while changing the structure of the style object to have the selectors field.

That saved us a lot of copy pasting. But what about nice classnames?

By default, Vanilla Extract protects us from classname collisions and style leaking by using unique generated classnames. That serves some use cases, but it’s absolutely not what we wanted.

We initially wrapped their style function, adding a readable classname of our choice to every component.

Instead of writing const myComponent = style({<some css there>} we were writing const myComponent = style('cord-my-component', {<some css there>}).

This worked, but there were two majors problems with this approach:

  1. We were ourselves writing CSS targeting the Vanilla Extract uniquely generated classname: This allowed us to avoid any risk of collision or style leak, but it prevented us from using all the nice, readable classnames we were adding. It also meant we were not writing style the way our user would. That was a no-go. We want to use Cord the same way our developers use Cord.

  2. The DOM was very polluted with those unique, random classnames: This is another developer experience problem more than a technical one. It’s simple though: when you have loads of dynamically generated classnames, it’s very hard to find the readable classname when you inspect the DOM. And, naturally, developers inspect the DOM constantly when tweaking styling. We needed them to be able to succeed at Cord without getting out their magnifying glass and tweezers to find the static, useful classnames in a sea of generated ones.

A screenshot of the DOM polluted with loads of messy, unreadable classnames

For a while, we were afraid we'd have no choice but to give up on CSS-in-JS and go for the old school CSS.

That was until we realized we could use Vanilla Extract’s globalStyle.

By using globalStyle, we got rid of all the random classnames. We were suddenly writing selectors using the same classnames our users would. Very nice. 👌🏽 We worked hard to write our CSS selector with very low specificity. That way, users can easily override them. In order to do so, we used ':where()', which can be used to write selectors that do not increase specificity

The first selector below is part of Cord's default styles, and it has 0, 1, 0 specificity. If developers want to override the default styles, they can write the same selector, without using the :where(). As seen below, that selector would have 0, 2, 0 specificity, therefore taking precedence over Cord default styles.

We use :where to lower the CSS specificity as much as possible

Without :where, you have a higher CSS score to try to beat

One drawback of globalStyle is that it does not support nested selectors.

They have a good reason for that: it avoids potentially unexpected results when merging the nested selector with the globalStyle. Why? Because you can pass any selector to globalStyle.

Let's look at another example. Below, you can see it's not straightforward what the result of merging would be, and it can quickly degenerate. Someone could change the global selector (adding another target) without paying attention to the nested one, or vice versa.

    globalStyle('.someClass button,.someOtherClass:not(button):active', {

      backgroundColor: 'black',

      color: 'white',

      ':hover': {

        backgroundColor: 'white',

        color: 'black'

    }});
Enter fullscreen mode Exit fullscreen mode

This makes our life slightly harder, as we need to write slightly more repetitive code in our selectors.

JSS vs. Vanilla Extract Global styles comparison

After that, it was just a lot of porting JSS code into Vanilla Extract code. Like the Ship of Theseus, we needed to change out the implementations of every component while staying afloat. But how do we migrate all at once?!

We don’t.

We opted to migrate iteratively, piece-by-piece, duplicating the component and allowing them to be switched from JSS to Vanilla Extract with a simple config at runtime.

At first we were carefully switching them on manually for testing, before introducing smaller components. Eventually, we took advantage of the ShadowDOM removal and switched them all at once.

Vanilla Extract (unlike JSS) doesn’t support adding styles at runtime. This is great because it means there’s no runtime cost, but it also required us to re-think how to implement dynamic behavior. In most cases, the solution was to add/remove classes based on the state.

When you can dynamically alter the structure of the CSS using JavaScript, you think differently about how to build the page.

We had to unlearn this instinct and go back to core CSS. We let the browser do the work by adding and removing static classnames from elements based on state, allowing us to relinquish control to the browser, and letting it decides when to repaint. An added bonus to this decision was the fact that we were suddenly doing zero runtime CSS computation, which served Goal #4 perfectly.

Simpler JavaScript, more browser-centric CSS, and huge performance wins? That’s a lot to get excited about.

Where are we now?

Remember those goals we laid out at the beginning of this article? We achieved every last one of 'em.

1. Make every piece of Cord UI customizable via plain ol’ CSS ✅

Every piece of Cord’s UI now has a classname developers can target when writing their CSS. These class names are prefixed with cord-, to make it obvious they are meant to be used. There are also classnames which are added/removed based on the state of the component. For example, cord-present and cord-not-present are added to a Cord avatar, depending on whether a user is or isn’t currently on the page.

Something that looked like class="message-1-21-210 message-1-21-204” now looks like class=“cord-message cord-no-reactions cord-from-viewer”.

This means that developers can understand the state of a component just by looking at its CSS classes (which is another huge win for Goal #2). Let’s take a closer look at this one:

The classname cord-message is hopefully pretty self explanatory. It’s the classname that goes on every Cord message.

The classname cord-no-reactions means nobody has reacted to this message. This is useful if developers want to style a message differently based on it having a reaction or not.

The classname cord-from-viewer means the current user sent this message. This is useful if developers want to implement an Intercom-like looking chat, where the messages sent by the current users (and from everyone else) are on opposite sides of the chat. You can see a live example of this in our Docs.

2. Create the best developer experience possible ✅

It's now easy-as-pie for designers and developers to style Cord's components however they want to. Yep, you can (easily) make our components match the look and feel of your brand so that they feel native in your application. Classnames are consistent and predictable, and our components’ HTML is as lean as hell.

A before and after example might help here...

Before

In earlier iterations of Cord, to add tooltips, we had a WithTooltip React component. Wrapping an HTML element with this component would add a div with event listeners, which rendered a tooltip.

After

In the new version of Cord components, there’s no extra div. The event listeners are attached directly to the HTML element that needs to have a tooltip. This makes a huge difference when you’re using flexbox or grid layouts. You now only have to worry about elements in the DOM that have a good reason to be there.

You can also override and customize Cord components’ styles easily, without accidentally overriding unwanted ones. We really sweated the details here. Every page has default styles for things like links and paragraph tags, and we don’t want to accidentally inherit them in Cord. But we also don’t want to make it hard to alter Cord to match your visual identity.

That's precisely why, now, all our components aim to have a CSS specificity of 0, 1, 0. This way, styling all the <p> tags with a top-level p { … } declaration won’t affect Cord, but you can still easily target all <p> inside Cord components by using the .cord-component class, which every Cord component has.

This means that .cord-component p (which has specificity of 0, 1, 1) would be enough to customize all the <p> tags within Cord. When styling Cord components, there’s no need to use !important. Hooray!

3. Re-write our CSS without causing self-inflicted problems in the future ✅

We write global styles using Vanilla Extract. This gives us type safety, allows us to reuse classnames between components, and enables backwards compatibility with our previous CSS variables approach.

We’ve ditched JSS in favor of Vanilla Extract, and this was quite the performance boost. With JSS, the amount of style tags on the page was directly tied to the number of Cord components. A page would become noticeably slow when it had hundreds of Cord threads in it.

JSS vs. Vanilla Extract with :where

With Vanilla Extract, we got rid of a truly unbelievable number of style tags

4. Move away from JSS to boost performance ✅

In the JSS world, a page with 173 Cord threads took almost seven seconds to load thanks to hundreds of thousands of JSS dynamic styles.

JSS is very, very slow on big pages

Switching to Vanilla Extract reduced this time to virtually zero seconds.

Vanilla Extract’s cost is not tied to how many components are on the page. Whether you have one or one thousand threads on the page doesn’t make a difference at all. Instead, the cost is tied to how many styles we have (i.e. how big Cord's stylesheet gets).

As you can see below, it currently takes 46 milliseconds to parse the spreadsheet. This will only go up as we write more styles, but it’s a long way to go back to the seven seconds of JSS. Remember, the P in JSS stands for Performance!

The same page as above, but painted in 0.46ms

Where we’re going next

We're super excited about what our customers can build with Cord, and we're going to continue building the richest developer experience possible... while still offering extremely useful UI library components. We're just getting started.

Want to take Cord for a spin? Get a free developer account here. Simply follow our Quick Start Guide to get up and running with a richly featured chat experience in, like, 5 minutes. Literally.

Top comments (1)

Collapse
 
lazybean profile image
lazybean

I like vanilla and I like memes!
Nice article, thank you!