Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Simple component theming with broccoli.js

My team and I have been working on a new internal component library to share across a few Ember apps. The library is an Ember addon in a private github repo. So far, so good. The off-the-beaten-path part of our requirements is that the components need to be themeable. Specifically, we support a color and a "light" or "dark" aesthetic.

We want a great developer experience for creating and updating these components, so we used ember-freestyle as a devDependency of the library to create a living style guide. We added some UI to allow theme switching and even a checkbox to switch themes every second (thanks ember-concurrency!).

The thing that gave us the biggest challenge was figuring out how to author and apply the styles that were necessarily dynamic. Specifically, the css rules that used the dynamic color.

We decided to organize our styles like so:

app/styles/components/
    my-component/
        style.scss
        theme.css

The style.scss file is processed by ember-cli-sass and becomes part of the app bundle. It contains all styles that can be calculated at build-time and shipped to users as static CSS.

The theme.css file contains css with ${primaryColor} as needed and is processed by some custom broccoli code that concatenates the rules and builds them into a Javascript function receives a color and returns a string of the CSS with the color interpolated in.

Our apps use this module to add this dynamic CSS to a <style> tag in the <head> of the document.

//app/styles/components/my-component/style.scss
// styles that are not theme related and don't need to be dynamic go here
// (side note: we use the SUIT naming convention, which I love for component style isolation
.MyComponent {
&-header {
font-size: 1.2rem;
font-weight: bold;
}
}
/* app/styles/components/my-component/theme.css */
/*
styles that are theme-related and need the dynamic color go here
note that the interpolation format chosen is the Javascript template string format
*/
.theme-light .MyComponent-header {
color: ${primaryColor};
}
.theme-dark .MyComponent-header {
color: white;
}
/* eslint-env node */
'use strict';
const path = require('path');
const mergeTrees = require('broccoli-merge-trees');
const concat = require('broccoli-concat');
module.exports = {
name: 'component-library',
treeForApp(tree) {
tree = this._super.treeForApp.apply(this, [tree]);
let appDir = path.join(__dirname, 'app');
let themeCssGeneratorTree = concat(appDir, {
header: "export function generateThemeCss(primaryColor) {\nreturn `",
inputFiles: [ 'styles/components/**/theme.css' ],
footer: "`;\n}",
outputFile: '/utils/component-theme-css-generator.js',
});
return mergeTrees([tree, themeCssGeneratorTree]);
}
};
// consuming-app/app/services/theme.js
import Ember from 'ember';
import { generateThemeCss } from '../utils/component-theme-css-generator'; // this file is generated by the addon's broccoli code
const { computed } = Ember;
export default Ember.Service.extend({
// called by a route
appendStyleTag(theme) {
if (this.styleTag) {
document.head.removeChild(this.styleTag);
}
let themeCss = generateThemeCss(theme.primaryColor);
this.styleTag = document.createElement('style');
this.styleTag.type = 'text/css';
this.styleTag.appendChild(document.createTextNode(themeCss));
document.head.appendChild(this.styleTag);
return Ember.RSVP.resolve(); // this may need to be async in the future to wait for styles to be applied
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment