Local Scope in CSS

Faux local scope in the CSS global scope

When building applications for the web at scale, the need for a proper scope and encapsulation in CSS is becoming ever more apparent, without it our applications are fragile to change, and the house of cards that is CSS can quickly come crashing down around us.

In this article I'll be examining some of the techniques we can use today to achieve encapsulation, and taking a look to the future with the currently proposed specifications that introduce scope at a language level.

What is scope and why is it so important?

Encapsulation, or scope, is often taken for granted, most programming languages support encapsulation in some form, be it through the use of classes in an object-orientated language architecture, or through the use of lexical closures in languages with first-class functions.

The use of encapsulation or scoping allows us to shape our application architecture by:

  • Restricting access to some of the components implementation, allowing us to define a clear interface
  • Facilitate the bundling of data with the methods (or other functions) operating on that data
  • Avoid namespace collisions or accidentally overriding another part of the application by a later declaration

The last point is probably the most relevant when it comes to CSS.

CSS global scope

In CSS everything exists in the global scope. Declarations that come later can override other rules that have been previously declared.

The problem is further compounded by the language where by rules that are more specific will take precedence over rules that are less specific.

/* The background color will be red */
.foo.bar {
    background-color: red;
}

.foo {
    background-color: blue;
}

.bar {
    background-color: green;
}

This problem often arises when using third-party CSS libraries, where by the library styles can end up overriding the CSS rules for your project, even though your stylesheets are declared later.

Techniques for achieving scope in CSS

Language Design Methodologies

While CSS provides the ability to declare styles that cascade, it's often advantageous now a days to not use this feature of the language, as you'll find it difficult to scale a project as the complexity grows.

One approach for achieving a sort of "pseudo encapsulation" in CSS is through the use of language design methodologies, such as BEM or SUIT CSS.

In essence a language design methodology defines a set of rules for how to write CSS. These rules are enforced through linters and pre-compilers, which will break the build if this convention is not followed. In the case of BEM and SUIT CSS the language design methodology is taken a step further through a style that conveys a semantic meaning, while maintaining a set of shallow declarations that are additive by nature.

For example, in SUIT CSS you may declare a set of styles as follows:

.MyComponent {}
.MyComponent-descendent {}
.MyComponent--modifier {}
.MyComponent.is-state {}

As you'll notice, the styles are flat and the only combination selector is one that defines a components state.

These rules can optionally be prefixed with a namespace for the project for further encapsulation, this is especially useful if you are sharing a set of core styles between multiple projects or applications.

The advantage of writing CSS in this manner is that each component is responsible for defining how it looks, and the design encourages you to compose larger features from smaller components. The CSS in this design pattern is scoped to the component, and styles are not leaked into the global scope.

However, this still doesn't avoid the issue of namespace collision when using third-party libraries, and problems can still arise because this is still a manual process, which is fairly tedious, repetitive and prone to human error.

Pre-compiler scoping

As JavaScript workflows have trended towards building collections of components, CSS workflows have followed suit. However, any progress on the CSS front has been purely conventional, as seen by the language design methodologies above, and are not directly supported by the language itself.

One of the fundamental features of the Webpack loader (which is also core to JSPM and easily possible with Browserify) is the ability to explicitly describe each file's dependencies regardless of the type of source file. For CSS in a component workflow, that takes the following form:

// Marks the CSS as being a dependency of this JS.
// Depending on the loader, the CSS is either injected
// into the DOM or bundled into a separate CSS package.
require( './my-component.css' );  
module.exports = function() {  
    // component definition
};

Now, whenever my-component.js is loaded or bundled, the corresponding CSS is guaranteed to be present, just like any other dependency. This convention leads us to a new capability, and necessitates a new specification, enter css-modules.

css-modules

With css-modules we can emulate local scope and control dependencies in front-end components. By default, all class names and animation names are scoped locally. All URLs url(...) and @imports are in module request format.

For example, say I have the following styles declared for a component:

/* my-component.css */

.root {}
.image {}
.description {}

When css-modules processes this CSS file, it will automatically prefix each class selector making it globally unique. To apply these classes in our template we import (or require) it into a js file:

Note: At the time of writing, you will need a build step for this part since most browsers don’t support import / require statement natively yet. There are css-modules implementations for webpack, browserify and jspm to handle this part.

// ES6
import styles from './my-component.css'  
// CommonJS
var styles = require('./my-component');  

Behind the scenes, css-modules exports the classnames of our stylesheets as an object, which gets assigned to the styles variable declared in the import statement of our js file.

We can then use this object in our templates to output the classnames in the correct parts of the DOM of our component:

<!-- using ES6 string literals -->  
<div class="${styles.root}">  
    <img class="${styles.image}" src="path/to/image.png" />
    <p class="${styles.description}">Lorem ipsum</p>
</div>  
<!-- using AngularJS (one-time binding) -->  
<div class="{{::styles.root}}">  
    <img class="{{::styles.image}}" src="path/to/image.png" />
    <p class="{{::styles.description}}">Lorem ipsum</p>
</div>  

Looking at the rendered HTML, you'll notice the classnames that are output is similar to the code we wrote in our CSS, except we have avoided any accidental namespace collision because css-modules has prefixed our component with a namespace.

<div class="_myComponent__root">  
    <img class="_myComponent__image" src="path/to/image.png" />
    <p class="_myComponent__description">Lorem ipsum</p>
</div>  

This means that we can avoid namespace collisions by also running third-party libraries through the same css-modules processor, prefixing them with a unique global namespace, and because the process is automatic, it is less prone to human error.

There are also lots of other cool features that css-modules provides such as composition, which I will be covering in a later article.

CSS :scope pseudo-class

Defined as part of the W3C CSS Selectors Level 4 (working draft) specification, the :scope pseudo-class provides scoping to CSS at a language level by representing any element that is in the contextual reference element set. A scoped <style> element is used to "scope" a selector so that it only matches within a subtree.

So what does this all mean? It means that instead of hanging styles off of the :root context, which by default is the <html> tag, we can declare a separate :scope context that is attached to a specified DOM subtree, all child elements of this subtree will be styled according to the scoping of the CSS, and sibling elements and their children will remain unaffected.

So with this information in mind lets illustrate this further with an example:

<style>  
    p {
        color: black;
    }
</style>

<p>I will be coloured black.</p>

<section>  
    <style scoped>
        p {
            color: red;
        }
    </style>
    <p>I will coloured red because my styles are scoped!</p>
</section>

<p>I will also be coloured black.</p>  

As you can see, the <style> tag is given the scoped attribute, this creates a new scope context, hanging off of the parent element, which in this example is the <section> element. All children of the <section> element will receive the new styling rules, and will be styled accordingly.

We can also @import an external CSS file and scope it like so:

<div>  
    <style scoped>
        @import 'path/to/stylesheet/main.css';
    </style>
</div>  

CSS @scope at-rule

One criticism of the CSS :scope pseudo-class is that we are back to mixing HTML and inline-styles again, and I am inclined to agree, I don't like to use inline <style> tags because more often than not you end up repeating yourself, the code is harder to maintain and I cannot leverage my favourite CSS pre-compiler anymore.

The @scope at-rule allows authors to create scoped style rules using CSS syntax, where the elements matched by the <selector> are scoping roots for the style rules in the <stylesheet>, and selectors of style rules scoped by @scope are scope-contained to their scoping root.

For example:

span {  
    color: black;
}

@scope div {
    span {
        color: red;
    }
}

@scope section {
    span {
        color: green;
    }
}

By default all spans on a page (which are attached to the :root scope context) will be coloured black, while spans inside of the div and section @scope context will be coloured red and green respectively.

This simple example illustrates how to create isolated scopes in CSS, the easiest way to think about it is to view the @scope at-rule as being similar to the @media at-rule, in that, the styles are conditionally applied given the context. Styles will not bleed outside of this context.

Importantly, styles that are declared after cannot affect rules that are scoped. For instance in the following example, the colour of the <p> element text will be red.

@scope aside {
    p { 
        color: red; 
    }
}

aside#sidebar p {  
    color: black; 
}

Even though the ruleset for aside#sidebar p is more specific, because aside is scoped, only the rules declared inside that scope context will be applied.

In the case of both the :scope pseudo-class and the @scope at-rule, at the time of writing, both are currently in a state of working draft proposals, so browser support is very light, and in the case of Google Chrome the feature was partially implemented behind an experimental flag and then later removed due to code complexity, favouring instead the next feature for discussion, the Shadow DOM.

Shadow DOM

The Shadow DOM is a method of establishing and maintaining functional boundaries between DOM trees and how these trees interact with each other within a document, thus enabling better functional encapsulation within the DOM.

Unlike language design methodologies or pre-compiler scoping, which only emulate a local scope, the Shadow DOM provides us with an actual local scope for a component, and not just for CSS either, but for the HTML and JavaScript too!

At the time of writing though, browser support is light, and browsers who do support the feature normally have it turned off by default behind an experimental feature flag.

For example, if I had the following markup and JavaScript the output would be as follows:

Note that if the JavaScript asks for the host.textContent, the output would be Hello World instead of Lorem Ipsum, this is because the DOM subtree under the shadow root is encapsulated.

Summary (tl;dr)

  • JavaScript and CSS have made great strides towards component based workflows, however CSS at a language level is still lacking the necessary features for achieving encapsulation
  • Up until now, scope in CSS has been achieved through convention, by using language design methodologies like BEM and SUIT CSS
  • Pre-processor scoping tools, such as css-modules, are helping to automate these conventions, reducing human error
  • The Shadow DOM is a future specification which aims to achieve proper encapsulation for JavaScript, HTML and CSS. At the time of writing though browser support is light.

Personally I believe that the Shadow DOM/web components are the way we should be designing and building our application architecture, and as browser support picks up this will only become more prevalent.

In the mean time though we (unlucky few) still have to support legacy browsers, which unfortunately means we are unable to take advantage of these future specifications today. That being said, projects like css-modules are closing the gap between the future and today, and leveraging them will enable us to write decoupled, more maintainable code, that will scale as our architecture grows.

Matt Fairbrass's Picture
Matt Fairbrass

Matt is a UX developer & designer originally from London, England now living in Sydney, Australia. Matt has over 5 years professional experience building web & mobile apps using web technologies.

Sydney, Australia
Matt Fairbrass's Picture
Write a comment Previous article » Prev »