If you've worked with Angular before, you've probably encountered its built-in view encapsulation system, perhaps without even realizing it. Generally speaking, Angular uses view encapsulation to ensure styles applied on/within a component do not affect any DOM outside of the component (including its children).

This is a godsend in most cases. It would be a terrible experience for developers to maintain an application-wide global stylesheet or have to check if their CSS class name was already in use when creating new styles.

By default, Angular applies the ViewEncapsulation.Emulated mode to components. This system automatically applies a component-specific ID as am attribute to each component and child DOM in the generated HTML.

In addition, any CSS applied to the component will also have the same ID applied to every selector. Ever look at the source code of an Angular application and see an attribute similar to "_ngcontent-obs-c46"? Yep, that's the view encapsulation at work.

While view encapsulation has it's perks, it can definitely get in the way when you're working with general components or with recursive/dynamic generation. In these cases, you may actually want components to appear differently depending on the context or location.

So how do we combat view encasulation in these cases? Well, we typically have a couple options:

  1. Shared services/global providers
  2. :host-context
  3. ::ng-deep
  4. CSS variables

Each of these options has perks and drawbacks, and maybe I'll outline these in a future post focused on view encapsulation. However, let's assume that we're going to use CSS variables approach to avoid view encapsulation. It's worth noting that I generally find these to be the least problematic of the bunch.

CSS variables are a great tool to apply context-specific styles because they transcend typical view encapsulation. If you apply a CSS variable on a component, all child components will also have access to that variable. CSS variables can be overridden just like normal CSS properties and can be used in conjunction with default values.

Also like normal CSS, these variables are cascading. This means that they will never affect parent elements in the DOM hierarchy.

Despite some notable execeptions (here's looking at you, Internet Explorer), CSS variables also have widespread support across modern browsers. Polyfills exist for those pesky exceptions.

Okay, so what?

While CSS variables are a great tool to use when we're looking to customize the look & feel of our general components, they can be a pain to apply in practice and typically require some boilerplate to get going.

The most standard way of apply variables as property bindings to a DOM element via Angular's Renderer2 service, requires the programmer to bypass Angular's default CSS sanitization. You'll also need a reference to the DOM element to do so - either by introducing static elements that bloat your application or through ViewChild(ren) calls which can be a pain when working with asynchronous code and dynamically generated DOM.

This is where the custom decorator @ApplyAsCSSVariable comes in. @ApplyAsCSSVariable is a property-level decorator that will automatically apply the value of a property as a CSS variable on the component's host element. This absorbs a lot of the headache and boilerplate involved with the aforementioned methods.

Peek under the hood

Without going too deeply into TypeScript decorators, here's a broad overview of how @ApplyAsCSSVariable works:

  1. Attach the @ApplyAsCSSVariable decorator to one or more properties within your component.
  2. On component declaration (not instantiation), wrap these properties with custom getter/setter methods.
  3. Whenever the value of the property is changed, the custom setter method is executed. This function decomposes the values into CSS variables and applies them as properties on the component's host element. It then saves the property value as normal.

We can also make the setter function do whatever we need to do in terms of handling different data types. For example, @ApplyAsCSSVariable has the following functionality:

This decorator also works in both development and production (minified, tree-shaken, etc.) environments!

Try it for yourself

Check out this StackBlitz to see the decorator in action. This demo contains a child component that uses the @ApplyAsCSSVariable decorator to set the color of different <span> elements directly from an incoming color @Input() property.

And here's the source code (with comments) if you'd like to use it yourself.

import { ElementRef } from '@angular/core';

export function ApplyAsCSSVariable(nameOverride?: string) {
  return function (target: Object, key: string) {
    /**
     * Assumes that a reference to the host component's
     * ElementRef called 'elementRef' exists
    */
    const elName = 'elementRef';

    /** Map of CSS variables applied to the host element */
    let _vars: string[] = [];

    /**
     * Use a mapped key to store the actual value of the variable so
     * that we can use the value of the variable. It doesn't matter
     * what this value is as long as there are no other properties
     * on the component with the same name.
     */
     const _derivedKey = `___cssvariable_${key}___`;

    /** Return the derivedKey value when getting the variable value */
    function get() {
      return this[_derivedKey];
    }

    /**
     * Helper function to transform a string to a the
     * --camel-case format typically used by CSS variable names
     */
    function _convertToCssFormat(x: string) {
      x = x.replace(/[A-Z]/g, (m, o) => (o ? '-' : '') + m.toLowerCase())
        .replace(/_|\s+/g, '-');
      return (x.startsWith('--') ? '' : '--') + x;
    }

    /**
     * When setting an incoming target value, apply the value
     * as 1+ properties on the host element, depending on the
     * value type
     */
    function set(value: any) {
      /** Check if we have access to the host component's ElementRef */
      const _el = 
        this[elName] && this[elName] instanceof ElementRef
        ? this[elName]
        : undefined;

      if (_el?.nativeElement) {
        /** Remove any CSS variables already applied to the host element */
        _vars.forEach((k) => _el.nativeElement.style.removeProperty(k));
        _vars = [];

        if (value !== undefined && value !== null) {
          /** Apply static values as a single CSS variable */
          if (typeof value === 'string' || typeof value === 'number') {
            const name = _convertToCssFormat(nameOverride ? nameOverride : key);
            _el.nativeElement.style.setProperty(name, String(value));
            _vars.push(name);
          } else if (typeof value === 'object') {
            /** If variable is an object, apply the key-value pairs as CSS variables */
            Object.keys(value).forEach((k) => {
              const name = _convertToCssFormat(k);
              _el.nativeElement.style.setProperty(name, String(value[k]));
              _vars.push(name);
            });
          }
        }
        } else {
        console.error(
          `[@ApplyAsCSSVariable] Unable to find host element ${elName}.`
        );
      }
      this[_derivedKey] = value;
    }

    Object.defineProperty(target, key, {
      get,
      set,
      enumerable: true,
      configurable: true,
    });
  };
}

Considerations

As simple as this decorator is, there are a couple of things to keep an eye out for.

Because the decorator is applied during declaration, we can't get a reference to the component host element in the DOM because it doesn't exist yet! As a workaround, the setter function will specifically look for a specifically named property "elementRef" on the host component that contains the reference to this component.

The good news is that we can get the reference via Angular's built-in component dependency injection by declaring the variable in the component's constructor.

You'll also notice that we are directly applying the CSS variables via the web-standard setProperty and removeProperty functions.

This is done for a couple reasons, but does represent an anti-pattern and will not work as expected if running your application via web workers or server-side renderering. However, running your application in a browser thread, this should not be an issue!