Custom Styling

Triggerz has a pretty standard React Redux front end.

We needed custom styling for our clients. Since they wanted to comply with their own corporate visual identity.

So we wanted this:

To look something like this:

Depending on the client of course.

I was tasked with creating a framework allowing our clients to customize stylesheets, and rendering those when they access our app.

We were already using style variables, which are a good tool for such a task, but we were compiling them on build time using cssnext.

Before build:

input { box-shadow: inset var(--smallDropShadow); border-radius: var(--borderRadius); background-color: var(--primaryContrastColor); padding: var(--smallSpacing); }

This meant we had no style variables references on run time, which was where we wanted to manipulate the styles.

After build:

input { box-shadow: inset 1px 1px 3px rgba(0, 0, 0, .4); border-radius: 3px; background-color: #fff; padding: 10px; }

We wanted to change styles based on the sub domain. For example:

some-client.triggerz.com

The first step was to not compile variables on build time, so we removed cssnext from our postcss build step.

Most browsers these days support style variables natively.

So everything was still working (mostly) after removing the cssnext compile step.

Except it seemed we were relying on some small weird things.

There were some problems with box sizing on input fields, icons and some order of operation on calc stuff.

The order of operation on calc stuff makes a bit sense, since this was happening on build time before and run time after. So it would appear that browsers and our build tools have two different orders.

.comment.withAvatar .comment-content { - max-width: calc(100% - (var(--avatarDimension) + var(--smallSpacing))); + max-width: calc(100% - var(--avatarDimension) - var(--smallSpacing)); }

Yay! 👏

The box sizing issue is still a mystery to me. 🤔

Before the changes I made the style variable properties were (mostly) defined statically in variables.css.

:root { --primaryFontColor: $primaryFontColor; --primaryColor: $primaryColor; --facebookBlue: #3b5998; --adaptiveYellow: rgba(255, 215, 0, 0.2); --adaptiveRed: rgba(209, 0, 0, 0.2); }

We also had some values defined in a variables.js, which we required in our react code.

var variable = { primaryFontColor: hex('707070'), primaryColor: hex('004266'), primaryColorRgba: rgba('0, 66, 102, 1') }

Variables.js was also compiled into variables.css on a separate build step. In this way we could have one definition for a style value available in both our css and js. This was quite handy for some of the more advanced layout stuff we’re doing for data visualization.

function getWidths (props) { var competenceWidth; var chartContainerWidth; if (props.clientWidth <= 768) { competenceWidth = (props.clientWidth - (variable.smallSpacing * 2)) / props.competenciesPerLine; chartContainerWidth = (props.data.length * competenceWidth); } else if (props.clientWidth < 1000) { competenceWidth = (props.clientWidth - (variable.standardSpacing * 4 + variable.rechartsYAxisWidth)) / props.competenciesPerLine; chartContainerWidth = (props.data.length * competenceWidth) + variable.rechartsYAxisWidth; } else { competenceWidth = (variable.maxBodyWidth - (variable.standardSpacing * 4 + variable.rechartsYAxisWidth)) / props.competenciesPerLine; chartContainerWidth = (props.data.length * competenceWidth) + variable.rechartsYAxisWidth; } return { competenceWidth: competenceWidth, chartContainerWidth: chartContainerWidth }; }

But we wanted to make the style variables more dynamic and manipulate them on run time, so this approach was no longer viable.

I decided to create several “style packs” one for each client that wanted custom skin. What they wanted to change was mostly colors and fonts, so I put all variable definitions not related to this into a seperate chunk called “universal styles”. This was stuff like margins, paddings, shadows and so on.

window.universalStyles = { 'facebookBlue': '#3b5998', 'twitterBlue': '#1da1f2', 'linkedinBlue': '#0077b5', 'adaptiveGreen': 'rgba(0, 209, 8, 0.2)', 'adaptiveYellow': 'rgba(255, 215, 0, 0.2)', 'adaptiveRed': 'rgba(209, 0, 0, 0.2)', 'quickTransition': '200ms', 'mediumTransition': '400ms', 'longTransition': '1000ms', 'veryLongTransition': '2s', 'testTransition': '5s', 'maxBodyWidth': '1000px', 'standardSpacing': '20px', 'smallSpacing': '10px', 'tinySpacing': '5px', 'menuHeight': '70px', 'borderRadius': '3px', 'buttonPadding': '10px', 'expandedSignifier': '6px', 'maxAccountInfoWidth': '150px', 'maxActivityHeight': '150px', 'avatarDimension': '50px', 'indicatorSize': '25px', 'standardLineHeight': '15px', 'buttonHeight': '35px', 'competenceWidth': '95px', // This specific value took a loooong disccusion 'rechartsYAxisWidth': '40px', 'rechartsHeight': '200px', 'maxTextWidth': '350px', 'thumbnailSize': '50px', 'inputWidth': '200px', 'maxDropDownHeight': '400px', 'bigAvatarDimension': '80px', 'smallAvatarDimension': '35px', 'coverHeight': '400px', 'coverWidth': '748px', 'logoDimension': '150px', 'smallDropShadow': '1px 1px 3px rgba(0, 0, 0, 0.4)', 'longDropShadow': '2px 2px 6px rgba(0, 0, 0, 0.4)', 'bigShadow': '0 0 20px rgba(0, 0, 0, 0.3)', 'outlineShadow': '0 0 20px #00d108' };

The rest I put into a pack called “default pack”.

window.defaultPack = { 'font': 'Roboto Condensed, sans-serif', 'defaultFontSize': '1em', 'primaryFontColor': '#707070', 'primaryColor': '#004266', 'primaryColorRgba': 'rgba(0, 66, 102, 1)', 'primaryColorRgbaGhost': 'rgba(0, 66, 102, 0)', 'searchContentColor': 'rgba(0, 178, 255, 1)', 'primaryContrastColor': '#fff', 'primaryContrastColorRgba': 'rgba(255, 255, 255, 1)', 'primaryContrastColorRgbaGhost': 'rgba(255, 255, 255, 0)', 'secondaryContrastColor': '#1d1d1d', 'secondaryColor': '#0086b8', 'adminColor': '#f2a437', 'lightGrey': '#efefef', 'warningColor': '#d10000', 'infoColor': '#00a9d1', 'confirmColor': '#00d108', 'starColor': '#ffd700', 'adaptiveBackgroundColor': 'rgba(0, 0, 0, 0.2)', 'adaptiveBackgroundColorDark': 'rgba(0, 0, 0, 0.4)', 'darkBackgroundColor': 'rgba(0, 66, 102, 0.9)', // Note: 90% of primaryColor 'searchContentBackgroundColor': 'rgba(0, 178, 255, 0.9)', // Note: 90% of turquoise 'colorArray': ['#0b5288', '#d3af97', '#f05f3c', '#87bdbf', '#d54177', '#e7b03b', '#5e704b', '#5089a5', '#f3c6c0', '#966a3a', '#b0b4bd', '#714659'] };

I then created a copy of this pack and changed the values to what our client had requested.

window.darkTheme = { 'font': 'Verdana, sans-serif', 'defaultFontSize': '1em', 'primaryFontColor': '#6e89b1', 'primaryColor': '#5c6071', 'primaryColorRgba': 'rgba(0, 25, 70, 1)', 'primaryColorRgbaGhost': 'rgba(0, 25, 70, 0)', 'primaryContrastColor': '#151515', 'primaryContrastColorRgba': 'rgba(0, 0, 0, 1)', 'primaryContrastColorRgbaGhost': 'rgba(0, 0, 0, 0)', 'secondaryContrastColor': '#1d1d1d', 'secondaryColor': '#628491', 'adminColor': '#eaab00', 'lightGrey': '#353542', 'warningColor': '#e64a0e', 'infoColor': '#72b5cc', 'confirmColor': '#4ac23e ', 'starColor': '#ffd700', 'adaptiveBackgroundColor': 'rgba(255, 255, 255, 0.2)', 'adaptiveBackgroundColorDark': 'rgba(255, 255, 255, 0.4)', 'darkBackgroundColor': '#000', 'colorArray': ['#005068', '#366029', '#84312a', '#f2da00', '#b820ff', '#ff9d00', '#00b2ff', '#e984ef', '#00e2cc', '#966a3a', '#aea79f', '#714659'] };

As part of our bootstrapping code I loaded these packs and attached them to the window object, to make them globally available.

This also means that all users potentially have access to all the style packs, which might be dodgy, if a client wants there style pack to be secret... 🤫

BUT the colors and fonts in the style packs is the first thing you see when you open the app, and we wanted to avoid flicker on initial render, so the styles definitions had to be readily available.

There is room for improvement here. One thing we did to lessen this was to refrain from using client names in the style pack names, and instead calling them something more generic. The client names are however still readable in the switch statement in the bootstrapping code.

switch (orgIdName) { case 'some-client': stylePackName = 'darkTheme'; window.faviconName = 'default'; break; default: stylePackName = 'defaultPack'; window.faviconName = 'default'; }

As part of the bootstrapping code we could now detect the subdomain (some-client.triggerz.com), loop through all the style variables related to that client in the window object and use setProperty to apply them.

var styleVariableList = Object.keys(universalStyles).concat(Object.keys(stylePack)); var rawCssText = cheatSheet.innerText; styleVariableList.map(function (variable) { var styleValue = window.universalStyles[variable] || window[stylePackName][variable]; document.documentElement.style.setProperty('--' + variable, styleValue); });

Great! 🎉

Alas 😩, IE11 of course does not support style variables, which was why we were using cssnext to begin with.

Most of our users are corporate, so IE11 represents a big chunk of them.

To solve this I created a (barbaric 😈) polyfill.

When looping through the style variables to setProptery (which has no effect), I used regex to find and replace each variable in the cssText, and insert a new stylesheet with the now updated values.

var styleVariableList = Object.keys(universalStyles).concat(Object.keys(stylePack)); var rawCssText = cheatSheet.innerText; styleVariableList.map(function (variable) { var styleValue = window.universalStyles[variable] || window[stylePackName][variable]; document.documentElement.style.setProperty('--' + variable, styleValue); if (isIE || isIOSSafari) { var regExpSearch = new RegExp('(var\\(\\-\\-' + variable + '\\))', 'g'); rawCssText = rawCssText.replace(regExpSearch, styleValue); } }); var style = document.createElement('style'); style.type = 'text/css'; style.id = 'compiledStyleSheet'; style.appendChild(document.createTextNode(rawCssText)); document.head.appendChild(style);

Also iOS Safari was not working. They have some support for style variables, but something about the way I used setProperty did not agree with them, so we sent iOS Safari through the IE11 treatment. 🤷‍♂️

I also created a small helper that can find any style variable in the window object to use in our react code. 👌

function getStyleValue (variable) { var stylePackName = window.stylePackName; var styleValue = R.path([stylePackName, variable], window) || R.path(['universalStyles', variable], window); if (styleValue) { if (styleValue.includes('px')) { styleValue = styleValue.replace('px', ''); styleValue = parseInt(styleValue); } else if (styleValue.includes('ms')) { styleValue = styleValue.replace('ms', ''); styleValue = parseInt(styleValue); } else if (styleValue.includes('s')) { styleValue = styleValue.replace('s', ''); styleValue = parseInt(styleValue); } } return styleValue; }

Voila! 🎉🎉🎉

Before

After

Let us know if you have suggestions for improvements or want to hear more about Triggerz!