Usage for Themes #6

Open
tf opened this Issue Oct 29, 2015 · 31 comments

Projects

None yet

9 participants

@tf
tf commented Oct 29, 2015

I am really excited about this project. One thing I haven't wrapped my head around is how one could use these values to create "theme-able" components. Let's say there are some variables that allow customization of a component - things like colors and fonts. Since the component's css module has to import its values from some concrete file, I see no way how one could switch between two css files defining different values.

One could create two css modules that differ only in values, but then I would have to duplicate all the rules.

Basically, I wonder what would be the counterpart of the following SASS code:

// component.scss
.my-component {
   background-color: $my-component-background-color;
}

// theme-a.scss
$my-component-background-color: green;
@import "my-component"

// theme-b.scss
$my-component-background-color: red;
@import "my-component"

Or is this simply something css module values are not supposed to solve?

@geelen
Member
geelen commented Nov 10, 2015

Hey, this is a great question. I've been thinking about this a lot, and chatting with @markdalgleish about it the other day, and had this tab open to reply ever since! Ah well, better late than never.

Ok, for some context, I think custom properties (i.e. css variables) are ABSOLUTELY the solution. I thought I had mark convinced too, but then he throws shade on the idea the other day and so now I dunno.

Anyway, here's why I think it's pro as.

The problem with theming that I can see is the inversion of control that is required. You want to define a default set of styles with a bunch of placeholders that get filled in by the consumer. But there's no real mechanism for this — in Sass you have mixins which can work at the component level but not for a whole UI framework. So you end up cloning Bootstrap, hacking _settings.scss and then you build the whole thing from scratch (so no inversion). You can use !default within your component so that if any variables are defined already they won't get redefined. That makes it easier, but again your build system is used to un-invert the inversion.

// First your theme variables
@import 'custom-variables';
// Then the reusable component
@import 'component/index';

With CSS Variables, this changes. So let's say you publish a component with the following raw CSS (forget CSS Modules for a second, pretend this is being served statically off a CDN):

:root {
  --color-primary: blue;
  --bg-primary: white;
  --color-alt: darkblue;
  --bg-alt: #CCC;
  --color-inverse: white;
  --bg-inverse: black;
}

.RadTable td {
  margin: 0;
  padding: 0.25rem 0.5rem;
}

.RadTable > thead, .RadTable > tfoot {
  color: var(--color-inverse);
  background: var(--bg-inverse);
}

.RadTable > tr {
  color: var(--color-primary);
  background: var(--bg-primary);
}

.RadTable > tr:nth-child(2n) {
  color: var(--color-alt);
  background: var(--bg-alt);
}

This HTML suddenly themes that component (note the order of includes):

<html>
  <head>
    <link href="http://some.cdn/radtable.min.css">
    <style>
      :root {
        font-size: 14px;
        --color-primary: green;
        --bg-primary: #CCF;
        --color-alt: darkgreen;
        --bg-alt: #AAF;
      }
    </style>
  </head>
  <body>
    <table class="RadTable">
      <thead><!-- ... --></thead>
      <tr><!-- ... --></tr>
      <!--     ...     -->
      <tr><!-- ... --></tr>
      <tfoot><!-- ... --></tfoot>
    </table>
  </body>
</html>

What's happening is the var keyword is doing the magic. It's telling the browser that this is a placeholder so to allow the cascade to redefine them. It means the inversion of control is now properly achievable!

And the reason I'm pretty confident that this is going to work is that we already have two variables that work like this — rem and currentColor. In the above example I used 0.25rem and 0.5rem in the component definition and then defined font-size on :root. So that inversion is already happening, and it's so much more useful than a massive list of variables at the top of your file. If you haven't seen Simurai's talk at CSSConf AU you should watch it, because it shows you what you can you do with rem em and currentColor.

So what does that mean for CSS Modules? Well, the global nature of the variables is a bit against the spirit of local modules, so we'd want to a) not define them against :root and b) scope their names somehow. So let's dream up what that might look like:

EDIT: all variable names here would be hashed so there's no global collisions and then exported just like class names & animation names.

/* rad-table.css */

/* these would all get rewritten by CSS Modules to be globally safe
  & exported, just like classes or animations */
.vars {
  --1rem: 1rem;
  --color-primary: blue;
  --bg-primary: white;
  --color-alt: darkblue;
  --bg-alt: #CCC;
  --color-inverse: white;
  --bg-inverse: black;
}

.table {
  /* Now using a RadTable will always add the `.vars` class and, since 
    the other components are nested, they'll inherit those values. But if 
    the structure was different we could compose vars multiple times 
    as needed. */
  composes: vars;
}

.table td {
  margin: 0;
  padding: 0.25rem 0.5rem;
}

.head, .foot {
  color: var(--color-inverse);
  background: var(--bg-inverse);
}

.row {
  color: var(--color-primary);
  background: var(--bg-primary);
}

.row:nth-child(2n) {
  color: var(--color-alt);
  background: var(--bg-alt);
}
/* theme.css */

/* import the table so we have access to the compiled variable names */
@value --1rem, --color-primary, --bg-primary, --color-alt, --bg-alt from "rad-table.css";

.theme {
  --1rem: 14px;
  --color-primary: green;
  --bg-primary: #CCF;
  --color-alt: darkgreen;
  --bg-alt: #AAF;
}
// Doesn't matter which order these are imported in, the @value
// in theme.css makes sure rad-table appears in the DOM above
// itself, so the cascade is dependable
import theme from "./theme.css";
import styles from "rad-table.css";

export default props => {
  // You have to add the theme class to the table. Source order 
  // will mean .theme has priority over .vars
  return <table className={styles.table + ' ' + theme.theme}>
    <thead className={styles.head}>{/* ... */}</thead>
    <tr className={styles.row}>{/* ... */}</tr>
    {/* ... */}
    <tr className={styles.row}>{/* ... */}</tr>
    <tfoot className={styles.foot}>{/* ... */}</tfoot>
  </table>
}

To me, this is pretty neat. You can write a component with clear placeholders for styling that simply adding a class after the fact can hook in to. It really embraces the cascade in a way that might be harmful in global CSS, but I think it's a perfect fit for CSS Modules.

Thoughts?

@sokra
Member
sokra commented Nov 10, 2015

You can safely use :root:

:root {
  --color-primary: blue;
  --bg-primary: white;
  --color-alt: darkblue;
  --bg-alt: #CCC;
  --color-inverse: white;
  --bg-inverse: black;
}

.table {
  /* no need to compose the vars */
}

because every --xxx becomes a globally unique identifier.


Every themable component should have a className prop so the user can set the theme from outside:

/* theme.css */
.green {
  --color-primary: green;
  --bg-primary: #CCF;
  --color-alt: darkgreen;
  --bg-alt: #AAF;
}
import { green } from "./theme.css";

<MyTable className={green} />

I would prefer omitting the -- from the exported/imported identifier.

@value color-primary, bg-primary from "rad-table.css";

Just like the . in class names is omitted too.

@geelen
Member
geelen commented Nov 10, 2015

Yeah I thought about using :root but thought there might be some benefit. I guess that has the benefit of giving default variables the lowest possible specificity.

Your example would still need className={green + ' ' + styles.table}, unless you did something like this:

@value 1rem, color-primary, bg-primary, color-alt, bg-alt, table as base-table from "rad-table.css";

/* theme.css */
.table {
  composes: base-table;
  --color-primary: green;
  --bg-primary: #CCF;
  --color-alt: darkgreen;
  --bg-alt: #AAF;
}
import { head, row, foot } from "rad-table.css";
import { table } from "theme.css";

I quite like that.

@sokra
Member
sokra commented Nov 10, 2015

Here a more complete example:

Note that table.css is imported from inside the component and themes.css is imported by the user of the component.

table.css contains the :root decl and normal classes using the vars.

themes.css contains a (or multiple) classes that just override the custom properties. The custom properties need to be imported from the table.css.

// table.js
import * as styles from "./table.css";

export class Table {
  render() {
    return <table className={this.props.className + " " + styles.table}>
      {/* ... */}
    </table>
  }
}
// app.js
import { Table } from "./table.js";
import { green } from "./themes.css";

class App {
  render() {
    return <Table className={green} />
  }
}
@markdalgleish
Member

I'm thinking that variables could become another first class, local-by-default feature of CSS Modules.

Values do a good job of allowing you to be explicit about variable dependencies between modules, but it certainly doesn't enforce it. I really like the guarantees that locally scoped classes and animations currently provide, and I think that variables are another candidate for this.

I propose that the following:

:root { --color-primary: blue; }

...would be compiled to this:

:root { --HASH: blue; }

Then if you want to override this variable, you explicitly need to import it. Importing and overriding a variable could look something like this, perhaps:

@value --color-primary from './variables.css`;

.table { --color-primary: green; }

Which, of course, is also compiled into this:

.table { --HASH: green; }

Thoughts?

@tf
tf commented Nov 10, 2015

I looked a bit into custom properties, but did not really think about them in combination with css modules. Seems like their biggest downside, the global namespace, is a problem which can be solved as you show above.

Still, they are not a technology available today and rather hard to polyfill, I imagine. Polymer seems to provide a subset of the functionality. But I couldn't find a project aiming for a stand-alone polyfill.

A few more questions regarding the integration with css module values:

  • So far it looked to me like names defined via @value where placeholders for the right hand side of CSS declarations. The examples above also use @value to import custom property names. I am wondering if this might blur the scope of the concept to much.
  • I agree with @markdalgleish that custom properties should be another first class, local by default concept. But they are not a "top-level" construct but must either be wrapped in :root or a class. Is this an issue? Maybe not.
@geelen
Member
geelen commented Nov 10, 2015

@markdalgleish Yeah that's exactly what I meant with my initial post. Sorry if it wasn't clear, but all variable names are local-by-default and hashed, hence why you have to use @value in theme.css

@markdalgleish
Member

@geelen sorry—this is what happens when I try to catch up on a comparatively short train ride. At least we're on the same page 😄

@geelen
Member
geelen commented Nov 10, 2015

Yeah just made an edit to clarify

@geelen
Member
geelen commented Nov 10, 2015

But it's cool, right? The way css variables which are suuuper greedy when global become just greedy enough when made local :)

@markdalgleish
Member

This is actually a really good example of how necessary values are, too.

@tf
tf commented Nov 10, 2015

But are these really values?

@ojame
ojame commented Nov 10, 2015

I like this idea! This is very much focused on theming from the top down. Because you're using the same hash for all variables of the same name (obviously so they can be re-assigned in a local selector if needed), what happens when you're setting the same var on root multiple times?

:root { --color-primary: blue; } - from myapp/theme.css
:root { --color-primary: green; } - from myapp/node_modules/whatever/theme.css

Is it just whichever is in the last-most dependency, or is there a way to tackle this? There's a decent chance someone could want components imported from y to have a different theme as those imported from x.

@sokra
Member
sokra commented Nov 10, 2015

I would disallow using imported vars in :root.

Declaration

:root {
  --xyz: 123px;
}
/* means: export xyz */
.className {
  --xyz: 123px;
}
/* means: export xyz */

Overriding

@value xyz from "./abc";

.className {
  --xyz: 234px;
}
/* means: override xyz for this scope */
@value xyz from "./abc";

:root {
  --xyz: 234px; /* error */
}
/* this is not allowed, because it has sideeffects (not locally scoped) */

Usage

@value xyz from "./abc";

.className {
  border-size: var(--xyz);
  border-size: var(--xyz from "./abc");
}
@geelen
Member
geelen commented Nov 10, 2015

@tf yeah I think we'd have to carefully consider how to fit --varname in with the rest. My initial reaction was that -- is their identifier, but @sokra's suggestion that the -- is implied also makes some sense. But we want to avoid clashes as well.

As for polyfills, they're basically not possible as far as I can tell. But support is improving! Chrome made some progress just last week, and Firefox support has been pretty steady.

Anyway, it feels like something worth adding to CSS Modules in the future? That makes me pleased :)

@tf
tf commented Nov 10, 2015

@sokra I'm not really sure this restriction buys us much. Given that there could always be another component using abc nested inside some element with className, the definition of --xyz for .className could always "leak" into nested components which did not explicitly define --xyz.

These side effects can becomes even worse when custom property values are used outside of the module that originally defined it like in your usage example.

But I guess those are two sides of the same coin: Custom properties provide an interface to change components in a certain DOM subtree. And leaky rules are also possible with CSS modules right now, i.e. .className span, right?

@tf
tf commented Nov 10, 2015

@geelen I think one has to be careful not to turn @value into some generic text replacement mechanism. To me it feels like values and custom properties really live in different namespaces since they are used in completely different spots. Hence I'm not sure it makes sense to use the same import mechanism.

Maybe there could be something like @property --primary-color from "./somewhere"?

@tf
tf commented Nov 10, 2015

Also @value supports some kind of "bulk import" (i.e. @value colors: "./colors.css") which I cannot make sense of in the context of custom properties.

@geelen
Member
geelen commented Nov 10, 2015

Ohh I see @sokra's point. But the property names themselves get hashed, so maybe it's ok for them to have one predictable value site-wide, than to have the default value some places and the overridden one in others?

@tf you're quite right, @property would avoid making @value too greedy. Because I definitely don't want to be looking on both sides of foo: bar to replace a @value. Then I think we can safely leave the -- off the import, since properties are always written that way.

Also, var(--prop-name from "./path.css") looks good as a shorthand, but I'm tending to use @value to do all my imports now so I don't have paths littered around my file, it feels like ES6 imports, it feels good.

@sokra
Member
sokra commented Nov 10, 2015

It should only be possible to affect stuff depther in the DOM tree. The same is true for classes with CSS Modules. You can't import a class and modify it. You can't modify a value. When writing a component you can't affect other components, expect when they are nested inside your component. This rule must hold for custom properties with CSS Modules. You can't override vars in the :root, but you can override vars inside your (local) class. This affects only nested components and nothing outside of your component.

@geelen
Member
geelen commented Nov 11, 2015

Yeah. I see what you're saying. Should we stop people using :global as well though?

@value xyz from "./abc";

:global(html) {
  --xyz: 234px;
}

In fact, :root, :global(:root), html & :global(html) could all be used. And you could effectively do the same with body, head + *, etc etc. Maybe we should just reject all variable redefinitions except within a .localname or when the user explicitly says :global to mean "I got this".

@geelen
Member
geelen commented Nov 11, 2015

Also btw this is in Canary now under the "experimental web platform features" flag: https://twitter.com/addyosmani/status/661598908985049094

It's also started to land in Safari, which means Edge will have to follow suit: https://twitter.com/grorgwork/status/654747040644116480

@sokra
Member
sokra commented Nov 12, 2015

Maybe we should just reject all variable redefinitions except within a .localname or when the user explicitly says :global to mean "I got this".

Sounds good.

This examples are fine:

@value xyz from "./abc";

:global(:root) {
  --xyz: 234px;
}

:global(a) {
  --xyz: 234px;
}

.className > a {
  --xyz: 234px;
}

This examples are errors:

@value xyz from "./abc";

:root {
  --xyz: 234px; /* overwriting prev default value */
}

a { /* global tag without :global, overwriting var globally */
  --xyz: 234px;
}

In this examples --abc is global (not hashed):

:global :root {
  --abc: 1px;
}

:root :global {
  --abc: 1px;
}

.className :global {
  --abc: 1px;
}
@geelen
Member
geelen commented Jan 13, 2016

With this coming in Safari 9.1 and iOS 9.3, and Chrome stable at end Feb, I think CSS vars are going to start becoming definitely usable. I like where we got to in this discussion, I'm gonna start thinking more deeply about this again.

@SpencerCDixon

Have there been any examples of people using CSS Modules theming to date?

@andywer
andywer commented Apr 9, 2016

Hey there! Even though that issue seems to approach its end of life...
I just published a plugin to address this problem: https://github.com/andywer/postcss-theme

Maybe that's some help for you, @SpencerCDixon?

@SpencerCDixon

Looks great @andywer I'll have to play around witih it my next project! Thanks :-)

@joshfarrant

Support is looking pretty good for CSS variables now, anyone got any more thoughts on this now it's a bit easier to test?

@kevinSuttle
kevinSuttle commented May 25, 2016 edited

I wouldn't say support "is looking pretty good" when IE doesn't support custom properties at all.

Also, isn't the point of theming to be global?
http://simurai.com/blog/2014/05/04/cssconf

@joshfarrant

I'd say so, especially in the context of this issue. Apart from IE/Edge all major browsers now support CSS Variables - certainly good enough support to begin looking into this in more detail and testing it across browsers. IE/Edge support will come.

@kevinSuttle
kevinSuttle commented Jun 6, 2016 edited

And it's going to be very difficult, if not impossible, to polyfill the non :root-scoped CSS Custom Property definitions without knowing the DOM ahead of time. Maybe that's where CSS Modules can capitalize? postcss/postcss-custom-properties#1 (comment)

@mattberridge mattberridge referenced this issue in iris-dni/iris-frontend Jul 27, 2016
Merged

Finish CSS Basics #8

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment