Context Aware Components

I've been dabbling with some pretty hardcore CSS architecture recently that is likely going to become much more mainstream in the near to mid future. This architecture relies on design patterns that, when used together, form one of the most robust, accessible, and flexible frameworks that I've seen to date in the front-end world.

What is "Context Awareness" in regard to component based design?

In short, context awareness is the ability for a component to know certain aspects of its environment. For example, the relative lightness of the background that a component is based on, or if it's desired that everything in a particular area of the page render with a looser layout density. Context awareness can also pair very nicely with user preference and device detection features! Things like motion reduction, color and contrast preferences, and hover/pointer detection can fit together with context aware component architecture in a very graceful and scalable fashion.

One might argue that such knowledge can pave the path to tight coupling, but by religiously respecting separations of concerns, this can be easily avoided.

Historical Approaches

In bygone eras, "safe" context management generally fell to the content management system to provide pseudo-context-awareness by means of render-time conditional logic...however this approach had some very significant drawbacks:

  1. The server had to know, without any doubt whatsoever, what the client-side context was going to be for the data it was going to render.
  2. The client either had to...
    1. Be relatively static - what the server rendered was basically it.
    2. Have a fairly robust javascript driven front-end framework behind it that could re-architect itself on the fly.
    3. Have a combination of AJAX-driven responsibilities that would inevitably result in maintenance problems in the long-run.

Take, for example, an extremely simple concept: A paragraph must have sufficient color contrast against its background. That's pretty basic, right?...but what if the user(s) building the content model won't know for sure what the context for their content is going to be? What if the design changes course long after the content has been built? What if their content has to render on both light and dark backgrounds depending on client-side user preferences?

Naive implementations

Taking the "safe contrast" requirement naively, there are a few approaches that appear to work superficially:

  1. Simply give the paragraph multiple classes that can be applied.

    p.light {
      color: black;
    }
    
    p.dark {
      color: white;
    }

    Great! Except...the rendering logic has to know ahead of time if the paragraph should be light or dark.  If that logic is PHP, it means the persistent content is tightly coupled to the display layer...not good!

    If it's a React component, it'll have to have intricate knowledge of its parent element(s)!  Also not good...

  2. What if we style elements based on the container they're in? 

    div.light p {
      color: black;
    }
    
    div.dark p {
      color: white;
    }

    Looking better...now if a paragraph is moved between containers, it'll contrast itself accordingly...however...

    1. Now the div element has "crossed the streams" so to speak with paragraph styles.  This is a tight coupling that will almost certainly result in maintainability problems in the long run.
    2. The styles are driven by cascade, not inheritance!  Consider the following example: 

      <div class="light">
        <p>This paragraph should render with a dark color.</p>
        <div class="dark">
          <p>This paragraph should render with a light color.</p>
        </div>
      </div>

      One would expect the inner-most paragraph to render with a light color...but that's not what happens!  In the style cascade, div.light p falls after div.dark p, meaning that nested contexts are not supported by this mechanism!

We can do better...right?

Yes...yes we can. Much better in fact. Through careful use of custom properties and CSS inheritance, amazing things can be achieved.

:root {
  --text-color--light: white;
  --text-color--dark: black;
}

:root, .light {
  --text-color: var(--text-color--dark);
}

.dark {
  --text-color: var(--text-color--light);
}

p {
  color: var(--text-color);
}

Believe it or not, this changes everything.  The custom property --text-color is controlled via property inheritance.  This means that contexts are infinitely nestable.  By default, and in .light containers, the --text-color property will resolve to --text-color--dark.  In .dark containers, the property will resolve to --text-color--light.

This pattern solves three problems:

  1. The paragraphs aren't tightly bound to the .light and .dark containers.  In fact, the actual color selection is delegated to a completely separate, much simpler component.  From the paragraph's perspective, it only cares that its color is --text-color.  The containers could care less what's inside them, and the variable declarations are implementation agnostic.
  2. The contexts are able to be nested infinitely deep.
  3. Most importantly, the rendering service does not have to care about contexts!

What if I need more than just a single light and dark flavor?

Great question! This is also something that can be accomplished through a carefully planned architecture!  Imagine that one is designing a "call to action button" component.  This component should have two primary variations: Primary and Secondary.  Each variation should have a light and dark mode rendition. This presents a unique challenge in that the component itself has information that seemingly must be passed up to its context...but that's not supported in CSS!  Properties don't reverse-inherit up the stack.

There is an intricate pattern that can solve this problem, but it relies on several not-so-commonly parroted behaviors of CSS that one won't find on the common first results on Google search, but rather are buried in the CSS specification itself.

The "Guaranteed Invalid" custom property value

Outlined by §2.2. Guaranteed-Invalid Values, the guaranteed-invalid value for custom properties, which ensures that a custom property will fall back to the next fallback value in the chain, is inherit.  For example:

:root {
  --dummy: inherit;
  --color: var(--dummy, red);
}

will evaluate to --color: red;

Astute readers will also note that an empty value, that is, a value that is an empty space is explicitly called out as being valid in §2.2, meaning that:

:root {
  --dummy: ;
}

is 100% legal and supported.

Where is this going?  I promise there's an "ah ha!" moment coming, but there is a bit more prerequisite knowledge to cover.  By combining these two behaviors, one can achieve the unthinkable: if conditions in CSS.

:root, .light {
  --if-light: initial;
  --if-dark: ;
}

.dark {
  --if-light: ;
  --if-dark: initial;
}

p {
  color: var(--if-light, black) var(--if-dark, white);
}

Now let's take a moment to pick this apart...the --if-light and --if-dark properties are subject to property inheritance, not cascade.  These properties will flip back and forth from initial to a single empty space character.

In a .light container, --if-light resolves to initial and --if-dark resolves to an empty space.  Recall that the initial keyword will force a custom property to fall back to the next value in the chain.  This means that in light containers, the paragraph expression resolves to the exact value of "color: black ;".  Note the space between black and ;.  That space is resolved from var(--if-dark, white) in which --if-dark is an empty space!

Flip to a .dark container and we see the same happen inreverse.  --if-light resolves to an empty space and --if-dark resolves to initial.  This results in an exact value of "color:  white;".  Note there are two spaces between color: and white.  The second space comes from var(--if-light, black) in which --if-light is an empty space!

This design pattern puts full control of property values in the hands of the component itself!  This means that the component can freely define its own internal variations in a very elegant fashion.  Let's get back to the Call to Action example.

:root, .light {
  --if-light: initial;
  --if-dark: ;
}

.dark {
  --if-light: ;
  --if-dark: initial;;
}

/** 
 *  Primary call to actions...
 *    On light backgrounds are black with white text
 *    On dark backgrounds are white with black text
 */
.call-to-action--primary {
  background: var(--if-light, black) var(--if-dark, white);
  color: var(--if-light: white) var(--if-dark, black);
}

/** 
 *  Secondary call to actions...
 *    On light backgrounds are off-black with off-white text
 *    On dark backgrounds are off-white with off-black text
 */
.call-to-action--secondary {
  background: var(--if-light, #333) var(--if-dark, #ccc);
  color: var(--if-light, #ccc) var(--if-dark, #333);
}

Using this design pattern, components are able to automatically provide adequate color contrast based on their position in the document without renderer foreknowledge and without javascript.

Where can we go from here?

While light/dark color contrast is a very basic example, the same architecture can scale to many other properties and design concepts.  Think about...

  • Variable contrast user preferences such as high contrast or forced color modes for Windows users
  • Layout density user preferences
  • Motion user preferences
  • Simplifying A/B testing