Skip to main content
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Justin Mclean
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Justin Mclean

Retrofitting Theming Into A Legacy App Using LESS CSS And CSS Custom Properties

By
Published in ,

A while back, I started to experiment with using CSS custom properties to theme Angular components. Which is totally awesome! But, it's one thing to be working with a fresh Angular install; and, it's a completely different beast to be working with an old, janky, legacy application (cough this blog cough). As such, I wanted to sit down and do some noodling on how I might retrofit theming into a legacy app using LESS CSS and CSS Custom Properties (aka CSS variables).

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

CSS Custom Properties are awesome. And, they have really solid support in all modern browsers. However, IE11 will never support them. And, if you support IE11, depending solely on CSS custom properties for styling can lead to poor a rendering experience.

If you have a "good build system" in place, then there's a chance that you can leverage some sort of tooling, like a CSS Post-Processor, to wire-in default values for browsers that don't support CSS custom properties. However, since I'm talking about old, legacy applications (cough this blog cough), I'm going to assume that the build process is sub-par and that we won't be able to make use of such post-processing goodness.

That said, I am going to assume that we are using LESS CSS, which is a powerful CSS pre-processor. Frankly, it's hard to imagine that anyone is writing CSS without a pre-processor these days - they just make certain things (like nested selectors and BEM naming) so much easier.

With LESS CSS, we can use a little elbow-grease to explicitly implement what a CSS post-processor would be doing for us. Which is to dual-write a given CSS property such that it has a static value followed by a dynamic value:

--background-color: #f0f0f0 ;

.widget {
	background-color: #f0f0f0 ;
	background-color: var( --background-color ) ;
}

The beautiful thing about CSS is that it just ignores whatever it doesn't understand. So, in IE11, for example, it understands this:

background-color: #f0f0f0 ;

But, it doesn't understand this:

background-color: var( --background-color ) ;

As such, it will simply ignore the latter "var" syntax and just apply the static background-color: #f0f0f0 property.

Of course, we don't want to have to manually dual-write all of the dynamic properties in our application. Instead, we can use a LESS CSS mixin to help make our lives a little easier:

// This LESS CSS mixin will dual-write the given property name: once using a hard-coded
// context value in order to support legacy browsers like IE11; and, once using the CSS
// custom properties for dynamic runtime theming.
// --
// CAUTION: This mixin can consume variables from both the DECLARATION scope as well as
// the CALLER scope. However, if both scopes contain the same variable name, the
// DECLARATION scope appears to take precedence.
.var( @propertyName ; @variableName ) {
	@{propertyName}: @@variableName ;
	@{propertyName}: var( ~"--@{variableName}" ) ;
}

This LESS CSS mixin takes a CSS property name and a LESS CSS variable name and then dual-writes the CSS property name, first using the static variable-variable value and second using the runtime CSS custom property. We can then use this alongside our LESS CSS variables:

.var( @propertyName ; @variableName ) {
	@{propertyName}: @@variableName ;
	@{propertyName}: var( ~"--@{variableName}" ) ;
}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@global-color: #454545 ;

// Configure LIGHT theme.
html {
	--global-color: @global-color ;
}

// Configure DARK theme.
html[ data-theme = "dark" ] {
	--global-color: #eaeaea ;
}

html {
	.var( color ; global-color ) ;
}

NOTE: The .var() mixin works because my LESS CSS variable names and my CSS custom property names are the same.

As you can see, we're using the .var() LESS CSS mixin to define the html element. And, we're passing in the CSS property name, color, and the variable name, global-color. This results in the following compiled CSS:

html {
	--global-color: @global-color ;
}
html[ data-theme = "dark" ] {
	--global-color: #eaeaea ;
}
html {
	color: #454545 ;
	color: var( --global-color ) ;
}

For browsers that don't support CSS custom properties, they just see the static color value. But, all modern browsers that support CSS custom properties, will override the static value with the dynamic, runtime custom property value.

One powerful feature of a LESS CSS mixin is that it can reference LESS CSS variables in both the declaration scope as well as the caller scope. This means that we can invoke our .var() mixin within nested CSS style blocks and consume locally-scoped variables that aren't available to other style blocks. This allows us to distribute theming information across our CSS widgetry, keeping the theme-providers collocated alongside the theme-consumers.

CAUTION: This works great until you have a local variable name that collides with an inherited variable name. In such cases, the inherited variable takes precedence (at least, from what I can see). As such, I would suggest prefixing global variable names with something like global-. Other approaches I've seen use upper-case names for global variables and lower-case names for widget-local variables.

So, for example, we can mix our .var() usage with local variables:

div.widget {
	@fun-color: #333333 ;

	.var( color, fun-color ) ;
}

Here, we have a variable @fun-color which is local to the widget's style block. This means that the LESS CSS variable won't leak-out into the parent context. And, even though the .var() mixin was declared in the parent context, it can still access the local variable, @fun-color, at compile time.

GLOBAL vs. LOCAL Variables: As I was exploring CSS theming, I struggled a lot with how I felt about global vs. local variable names. Part of me wanted all of the variables to be global such that I could see the extent of a "theme" at a glance; which would, in and of itself, make it easier to add additional themes.

But, the more I thought about "clean code", the more strongly I believed that I should write code that's easy to delete. If a style is truly global, it should get codified in a global variable. However, if a CSS component has one-off styles specific to said component, I believe they should be defined as CSS custom properties that are collocated with the component's CSS. This way, if you need to delete the CSS component, you end up deleting the one-off CSS custom properties right along with it.

If, on the other hand, the component's CSS custom properties were arbitrarily placed in a list of global variables, those global variables would almost certainly be left orphaned when the corresponding component's CSS was deleted. Trust me - I've seen this happen time and time again in real-world development.

Now that we understand how LESS CSS variables and mixins work; and, about how we can define fallback values for legacy browsers like IE11; let's take a look at a simple example. In the following code, we have an HTML page that has two CSS components on it - widget and thinger. The parent page, along with both components, can be dynamically themed using a Light and Dark theme, which is designated using an html attribute:

<!doctype html>
<html lang="en" data-theme="light">
<head>
	<meta charset="utf-8" />

	<title>
		Retrofitting Theming Into A Legacy App Using LESS CSS And CSS Custom Properties
	</title>

	<link rel="stylesheet" type="text/css" href="./legacy.css" />
</head>
<body>

	<h1>
		Retrofitting Theming Into A Legacy App Using LESS CSS And CSS Custom Properties
	</h1>

	<p id="themer">
		<a href="javascript:void( 0 );" data-theme-name="light">Light Theme</a> ,
		<a href="javascript:void( 0 );" data-theme-name="dark">Dark Theme</a>
	</p>

	<div class="widget">
		This is my legacy widget!
	</div>
	<div class="thinger">
		This is my legacy thinger!
	</div>

	<!-- Setup the click-handler for the CSS theming. -->
	<script type="text/javascript">

		document.getElementById( "themer" ).addEventListener(
			"click",
			function handleClick( event ) {

				var themeName = event.target.dataset.themeName;

				if ( themeName ) {

					document.documentElement.dataset.theme = themeName;

				}

			}
		);

	</script>

</body>
</html>

As you can see from the "click" handlers, our theming is controlled by a dataset attribute, theme; which, corresponds to the runtime DOM (Document Object Model) state:

<html data-theme="light">

or,

<html data-theme="dark">

We can then use attribute selectors in our CSS to drive style changes based on the selected them. Here's the root LESS CSS file for this demo - it defines the .var() mixin and the global variables and then pulls-in the individual LESS CSS files for our widgets:

// This LESS CSS mixin will dual-write the given property name: once using a hard-coded
// context value in order to support legacy browsers like IE11; and, once using the CSS
// custom properties for dynamic runtime theming.
// --
// CAUTION: This mixin can consume variables from both the DECLARATION scope as well as
// the CALLER scope. However, if both scopes contain the same variable name, the
// DECLARATION scope appears to take precedence.
.var( @propertyName ; @variableName ) {
	@{propertyName}: @@variableName ;
	@{propertyName}: var( ~"--@{variableName}" ) ;
}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// Setting these as LESS CSS variables allows us to provide the hard-coded fallbacks for
// IE11 - these will be consumed both in the .var() mixin as well as in the "default
// theme" styling (the following "html" stanza).
// --
// CAUTION: I am prefixing GLOBAL, UNSCOPED VARIALBES with "global-" in order to avoid
// naming collisions with module-level variable names. Having conflicts between global
// and local variable names causes unexpected results (I THINK due to scope precedence).
@global-background-color: #ffffff ;
@global-color: #454545 ;

// Note that the default theme uses the LESS variables from above.
html {
	--global-background-color: @global-background-color ;
	--global-color: @global-color ;
}

html[ data-theme = "dark" ] {
	--global-background-color: #212121 ;
	--global-color: #eaeaea ;
}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

html {
	.var( background-color ; global-background-color ) ;
	.var( color ; global-color ) ;
	transition-duration: 1s ;
	transition-property: background-color, color ;
	transition-timing-function: ease ;
}

a {
	color: inherit ;
}

// Hide theme switcher by default.
#themer {
	display: none ;

	// Show theme switcher if browser supports CSS custom properties ( aka variables ).
	// --
	// NOTE: Media queries are automatically "bubbled up" to become context selectors in
	// LESS CSS. As such, I don't have to include the "&" selector at the end.
	@supports( --any-var: any-value ) {
		display: block ;
	}
}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// Pull in the various LESS files for our UI components.
@import "./widget.less" ;
@import "./thinger.less" ;

Again, because we are attempting to provide a good experience for legacy browsers like IE11, I am using both LESS CSS variables and CSS custom properties to define the theme values. This is why the default theme - light - is being defined in terms of LESS CSS variables: that way the .var() mixin knows what fallback value to hard-code in front of the dynamic value.

The other thing to notice here is that we are hiding the theme-selection tooling by default; and then, only showing it if the browser supports CSS custom properties:

#themer {
	display: none ;

	@supports( --any-var: any-value ) {
		display: block ;
	}
}

And, again, since CSS just skips-over anything it doesn't understand, if a browser doesn't know what @supports() is; or, it doesn't know what --any-var pertains to; it will skip over the style, leaving the display:none in place. This locks the legacy browser users into the default, or "light", theme. Which is a much better experience than a broken page.

Now that we've seen the root LESS CSS file, let's take a look at the CSS component files. Here's the "widget" file that is included using @import:

div.widget {
	// These LESS CSS variables are going to be SCOPED TO THE CURRENT STYLE BLOCK. As
	// such, we won't be "polluting" the global LESS CSS variables scope, we'll just be
	// defining variables for use within this module.
	@background-color: #e0e0e0 ;
	@color: #000000 ;

	// Note the use of parent selector "&" in the following theme setup. This defines 
	// contextual CSS Custom Properties that are scoped to the current style block, but
	// that are affected by the theme property on the root HTML element. As such, we
	// won't be corrupting the global CSS Custom Properties, we'll just be overriding
	// them in this particular style block.
	html & {
		--background-color: @background-color ;
		--color: @color ;
	}

	html[ data-theme = "dark" ] & {
		--background-color: #330000 ;
		--color: #da0000 ;
	}

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	// NOTE: Even though we are using a GLOBAL mixin, the evaluation of that mixin is
	// able to read the LOCAL LESS CSS variables. That's cool :D
	.var( background-color ; background-color ) ;
	border-radius: 7px 7px 7px 7px ;
	.var( color ; color );
	margin: 20px 0px 20px 0px ;
	padding: 10px 10px 10px 10px ;
	transition-duration: 1s ;
	transition-property: background-color, color ;
	transition-timing-function: ease ;
	width: 300px ;
}

As you can see, we're declaring LESS CSS variables locally within the div.widget style block. Then, we're consuming those local variables using the global .var() mixin.

And, since CSS custom properties inheritance work just like any other CSS property, the --background-color and --color values are also local to the div.widget style block. This means that these custom properties won't conflict with any other CSS custom properties that may be defined in another CSS component.

For example, they won't conflict with our "thinger" CSS custom properties, which have similar names:

div.thinger {
	// Again, we are going to be using the LESS CSS variable to provide the default theme
	// that also works as the fallback for IE11 and other legacy browsers.
	@background-color: #ddddff ;
	@border-color: #3333ff ;

	html & {
		--background-color: @background-color ;
		--border-color: @border-color ;
	}

	html[ data-theme = "dark" ] & {
		--background-color: #333333 ;
		--border-color: #666666 ;
	}

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	.var( background-color ; background-color ) ;
	border: 1px solid #ffffff ;
	.var( border-color ; border-color ) ;
	border-radius: 7px 7px 7px 7px ;
	.var( color ; global-color ); // NOTE: I can consume global variables here.
	margin: 20px 0px 20px 0px ;
	padding: 10px 10px 10px 10px ;
	transition-duration: 1s ;
	transition-property: background-color, border-color, color ;
	transition-timing-function: ease ;
	width: 300px ;
}

Here, we can see this CSS component is making use of both local and global LESS CSS variables in order to implement its theming.

Code That's Easy To Delete: Touching back on something I said earlier, you can see clearly now that if I ever delete the thinger CSS component, the one-off LESS CSS variables that theme this component will automatically get deleted at the same time since they are all collocated. Essentially, there's no room for "developer error" when the variables are organized this way.

Putting this all together, our compiled runtime CSS file looks like this - notice that all of our themes properties, like color and background-color, are being defined twice: once with a static value and once with a dynamic CSS custom property:

html {
  --global-background-color: #ffffff;
  --global-color: #454545;
}
html[data-theme="dark"] {
  --global-background-color: #212121;
  --global-color: #eaeaea;
}
html {
  background-color: #ffffff;
  background-color: var(--global-background-color);
  color: #454545;
  color: var(--global-color);
  transition-duration: 1s ;
  transition-property: background-color, color;
  transition-timing-function: ease ;
}
a {
  color: inherit ;
}
#themer {
  display: none ;
}
@supports ( --any-var: any-value ) {
  #themer {
    display: block ;
  }
}
div.widget {
  background-color: #e0e0e0;
  background-color: var(--background-color);
  border-radius: 7px 7px 7px 7px ;
  color: #000000;
  color: var(--color);
  margin: 20px 0px 20px 0px ;
  padding: 10px 10px 10px 10px ;
  transition-duration: 1s ;
  transition-property: background-color, color;
  transition-timing-function: ease ;
  width: 300px ;
}
html div.widget {
  --background-color: #e0e0e0;
  --color: #000000;
}
html[data-theme="dark"] div.widget {
  --background-color: #330000;
  --color: #da0000;
}
div.thinger {
  background-color: #ddddff;
  background-color: var(--background-color);
  border: 1px solid #ffffff;
  border-color: #3333ff;
  border-color: var(--border-color);
  border-radius: 7px 7px 7px 7px ;
  color: #454545;
  color: var(--global-color);
  margin: 20px 0px 20px 0px ;
  padding: 10px 10px 10px 10px ;
  transition-duration: 1s ;
  transition-property: background-color, border-color, color;
  transition-timing-function: ease ;
  width: 300px ;
}
html div.thinger {
  --background-color: #ddddff;
  --border-color: #3333ff;
}
html[data-theme="dark"] div.thinger {
  --background-color: #333333;
  --border-color: #666666;
}

Which, allows us to dynamically change the CSS theme at runtime:

CSS theming being applied in a legacy app using CSS custom properties and LESS CSS.

And, since we are dual-writing our CSS properties with both static values and dynamic values, we still get "pretty good" rendering in legacy browsers like IE11:

CSS theming using default values in legacy browsers like IE11.

As you can see, the "theme tooling" is being hidden because IE11 doesn't support CSS custom properties. And, when we inspect the runtime CSS, we can see that it's using the static CSS properties and skipping-over the dynamic properties that it doesn't understand.

Understanding how CSS custom properties (aka CSS variables) work is one thing; but, it's another thing to then try and apply those practices in a legacy application. You quickly come to realize that things are never as clean or as simple as they sound in theory. Right now, this is my plan of attack for retrofitting CSS theming into a legacy app that doesn't really have a "build process". It's a little bit of manual labor; but, I think the overall effect is fairly good.

Want to use code from this post? Check out the license.

Reader Comments

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel