Skip to content
Richard Feldman edited this page Mar 27, 2017 · 25 revisions

Long-Term Goals

This is where I see elm-css going in the long term.

tl;dr basically like CSS Modules, except you can define the styles in the same file as your view, yet still compile them to a separate .css file. (Dead code elimination means nothing elm-css related ends up in compiled .js files, just classname literals.)

Background

The big question is whether styles should be assembled at build time or at runtime. It turns out to be a complicated question with a clear answer at the end. The rest of this document explains the reasoning that leads to the conclusion.

There are a ton of different approaches to CSS styling that are seeing use in the industry, and it's important to consider their benefits and drawbacks.

Inline Styles

Prior to Christopher Chedeau's presentation about "CSS in JS" in 2014, the consensus best practice for years had been to put all your CSS in a separate .css file, with the possible exception of a few situational inline styles. The presentation calls out some shortcomings of the .css file approach:

  1. Global Namespace
  2. Dependencies
  3. Dead Code Elimination
  4. Minification
  5. Sharing Constants
  6. Non-deterministic Resolution
  7. Breaking Isolation

I have some thoughts on how this list pertains to elm-css.

  • elm-css has namespace to help with the Global Namespace problem (#1), but it's not a bulletproof solution. We can do better.
  • Because elm-css lets you write stylesheets in Elm, we get dependencies (#2) and sharing constants (#5) for free.
  • I was surprised how in the presentation, "Dead Code Elimination" (#3) turned out to be "Dead Code Elimination where the programmer does it by hand"—and since elm-css easily lets you sharing constants between view code and stylesheets, manual cleanup gets a lot easier. That said, in the future, automatic dead code elimination on elm-css stylesheets seems plausible—courtesy of a future elm-make release.
  • I doubt minifying classnames (#4) moves the needle for most of us, especially once the stylesheets are gzipped. At Facebook's scale, though, the traffic coefficient means that every byte shaved is a bunch of money, so maybe this is a reasonable thing to consider at their size.
  • Non-deterministic Resolution (#6) and Breaking Isolation (#7) could be resolved by a helper library that exposes a "safe subset" of elm-css where doing things like increasing specificity or breaking isolation is a validation error. I'm not sure if something like this should be in scope for elm-css itself, but it seems reasonable. It's doable regardless of whether stylesheets are assembled at build time or at runtime.

Radium uses the "embrace inline styles" approach this presentation discusses.

Injecting into a <style> element

The "inline styles" approach has some serious drawbacks. One is performance; Pete Hunt noted:

inserting huge strings of inline styles caused serious performance problems (several hundred millisecond pauses or more) at Smyte.

This approach seems to perform particularly slowly in comparison to .css files.

Another downside (also noted in Pete's article) is that you can't use pseudo-classes, pseudo-elements, or media queries in inline styles. This is a big usability hit.

One way to "get the best of both worlds" is to have a library build up per-element descriptions of the styles you want, and then inject them into a <style> element which the library controls. It can automatically generate classes behind the scenes to attach the right styles to the right elements, so you get the UX of inline styles but with better performance and while getting to use pseudo-classes, media queries, etc.

Aphrodite, jsxstyle, and styled-components, among others, use this approach.

Preprocessors

The "preprocess a different language into a .css file at build time" approach has been around for a long time. elm-css was originally built to be a preprocessor, and was directly influenced by Sass and Stylus. Since then PostCSS has been released as well.

To differentiate between this approach and the above two approaches (inline styles and <style> element injection), I'll refer to preprocessors (and handwritten .css files) as "build-time CSS" and to the others as "runtime CSS."

One problem build-time CSS has always had is namespace collisions. What if you name a class .menu in one file and then also name a class .menu in a different file, but they're meant to style different menus? The browser will not complain if you do this; instead, you'll probably get some nasty styling bugs. Trying to avoid this is why elm-css ships with a namespace transformation, but it's not a bulletproof solution, and using it takes nontrivial effort.

It would be better if build-time CSS didn't have this downside, and CSS Modules offers a solution. It's a preprocessor that auto-generates classnames at build time, based on the hash of the declarations in that style. It also automatically synchronizes those classnames with the classnames used in the corresponding JS code, making namespace collisions effectively impossible.

Generating classnames removes the downside that made build-time CSS more error-prone than runtime CSS. elm-css can and should offer something like this, but does not yet.

Unlike most other preprocessors (jss being an exception), elm-css at build-time has no difference in expressivity compared to runtime. In either case you have access to the complete Elm language—modules, functions, Elm's entire package ecosystem, etc. Also, because elm-css styles are written in Elm, you can put your styles in the same files as your views and still compile them to .css files.

Dead Code Elimination can make this have no impact on the compiled .js file; to end users, it would be as if the styles had been defined in a completely separate file all along.

Comparing build-time CSS to runtime CSS

Even in the world where elm-css auto-generates classnames (meaning no more namespace conflicts, and less having to name things), and auto-generates filenames (relevant in a post-Elm 0.19 world, where Elm does asset management and can presumably take care of loading the appropriate auto-generated .css files when they're needed) and has Dead Code Elimination (meaning you can define your styles in the same file as your view logic, without runtime cost), runtime CSS still maintains at least one advantage over build-time CSS:

Properties can be set based on runtime state (e.g. setting opacity to be model.opacity rather than to a constant that was fixed at build time).

In the same way that it's essentially impossible for runtime CSS to have the performance benefits (a single hardcoded string literal instead of parsing and/or assembling data structures on the fly) or caching benefits (given that browsers cache .css files, but don't cache style information contained on elements) of build-time CSS, it's also essentially impossible for build-time CSS to set properties dynamically based on runtime state. There's no real way to design around this tradeoff.

Another problem that elm-css has which, say, styled-components does not, is that elm-css aspires to present a reliable interface to the enormous CSS spec. This means that elm-css has (and will always have) a lot of functions in it. This means that even assuming ideal Dead Code Elimination, anyone who uses elm-css for runtime style creation will accumulate a larger and larger JS download the more different CSS properties they happen to be using.

Further Optimizations

Another factor to consider is that precompiled styles benefit more from gzip. The reason elm-css supports mixin (but not an inheritance feature like @extend from Sass or composes from CSS Modules) is that in practice these inheritance features are worse for both performance and download size (because of gzip) than mixins, making them turn out to be footguns that should never be used for any reason.

One final thing to note: it's possible to further optimize time to interaction by separating "critical styles" from the rest and loading them differently. Fortunately for those who need this, it's already possible in elm-css, so this is a non-issue here.

Choosing a Winner

Regardless of which approach is best in the here and now, all of the following seem worth pursuing as future goals:

  1. Dead Code Elimination
  2. elm-css can auto-generate classnames (and animation names)
  3. elm-css can auto-generate compiled .css filenames, and Elm can automatically load them when necessary

Given that we are already working toward this world, then what should we work towards in terms of build-time CSS versus runtime CSS?

Here are the pros and cons in this world, assuming we are talking about the sort of runtime CSS that uses style tag injection:

Benefits of runtime CSS

  • Properties can be set based on runtime state (e.g. setting opacity to be model.opacity rather than to a constant that was fixed at build time)

Benefits of build-time CSS

  • Less CPU intensive. The browser is dealing with a hardcoded string literal (the classname) which indexes into a lookup table of cached styles. Systems which construct and combine objects to accomplish the same thing are always going to be more CPU-intensive.
  • Less JS to download. In the runtime CSS approach, the actual functions have to exist in the JS, meaning there are more functions for the end user to download.
  • Parallelization. Browsers can download CSS files and parse them in parallel with script tags on the page.
  • Caching. Browsers cache .css files and do not cache styles that are attached to elements.

Relevance of these benefits

We can boil this comparison down to:

  • Runtime CSS can dynamically set properties based on current state
  • Build-time CSS has many performance benefits

In practice, the number of properties that need to be set based on current state tends to be extremely small. With a library like mdgriffith/elm-style-animation to do things like animating opacity or left, it becomes vanishingly small.

For those few cases where it's still required, the performance penalty of using the style attribute seems likely to be insignificant. Having those few styles unverified (and thus more error-prone) is probably not the end of the world if it's isolated to that one tiny piece of the code base, and even if it were, writing a tiny library that only added types to those few properties (opacity, left, etc.) would probably be a more reasonable solution than importing all of elm-css.

An important question to ask about any question where the tradeoff is "one has worse performance" is: if the performance ever gets bad, what will we do to optimize it? In the case of runtime CSS, the answer is almost certainly "convert large chunks of styles to build-time CSS until the performance becomes acceptable." This would lead to inconsistencies across the code base, and would be more or less work depending on how often we'd (unnecessarily) coupled our styles to runtime state just because we could.

Conversely, if we embrace build-time CSS and ever need state-based declarations, it is easy to opt into them on a case-by-case basis.

Conclusion

With all this in mind, it seems clear that build-time CSS is the right default. There are many significant performance benefits, and the drawbacks are both minor in comparison and easy to work around.

Clone this wiki locally