< Blog roll

Public CSS Custom Properties in the Shadow DOM

Overview#

Naturally there is a lot to talk about with respect to styling custom elements and web components. This article won‘t dive into all the ways to style a component and/or open up stylistic configuration of your web components. This article is going to focus specifically on using CSS Custom properties and things to think about when you create them for your component consumers or when you are using them internally in your components.

There are two main categories of usage to discuss:

There are lots of articles already describing private CSS variables and naming schemes and such, so this article will focus on the public side of CSS properties.

Using Public CSS properties#

I think the first thing to think about is why and when to start exposing CSS properties to your consumers in the first place. There is of course a spectrum of component needs and use cases, and a spectrum of configuration that you want to present your users as the component author. But I hope that we can agree that components are best when there is some sort of way to tailor them to a use case that you the amazing component author forgot to think about even though you‘re great.

When to reach for public CSS Custom properties#

There are a gazillion (technical term for “a lot”) of ways to provide configuration for the components you build and you can pick and choose the ones that feel best to you depending on your component, your consumer audience, and your personally philosophy about how components should operate.

If you‘re a progressive enhancement person and you code your components such that they present a nice experience even when JS isn‘t loaded yet, you might opt for CSS properties because they don‘t necessarily need JS to work. If you‘re a “JS is required on the web anyway” type person then you might opt more often for JS component property configuration. Here‘s how I think about it, and how I recommend you do too.

My personal advice: Don‘t reach for JS when CSS will do the trick.

If the thing you‘re configuring in your component is a CSS property anyway, stick to CSS to configure it even if there are design-system-y standard values you want to stick to. CSS is simpler and less constricting which your consumers will love as soon as they run into a valid use case you didn‘t code for.

Decide who wins between component styles and external properties#

American football referee signalling a false start penalty.

Inevitably you will come across a feature where you want to expose a custom CSS property to configure it, but the component will be weird if the user gets it wrong. Accessibility is a great reason why these use cases happen. The age old “I don‘t know what alt text this image needs, so I give you a way to add one, but you need to make it meaningful else it sucks” situation.

When that happens, you will need to decide if you will take control and prevent the behavior by way of your styles, or if you will let the external style win and potentially be a negative experience.

Take this situation as an example:

:host {
  --fast-animation-duration: 150ms; /* public css property */
}

.some-animating-thing {
  animation-duration: var(--fast-animation-duration);

  @media (prefers-reduced-motion) {
    --fast-animation-duration: 2s;
  }
}

In order to accomplish my accessibility feature of “the component automatically slows down the fast animation”, I needed to completely override the incoming value of the CSS custom property I said was usable for configuration.

In my opinion, component authors should decide if a public CSS property’s value is “absolute” in that the component will ALWAYS take that value even if its bad or if CSS properties are conditionally applied like the above in order to preserve some feature.

That decision has ramifications both in documentation (you have to write down when the CSS property works and when it doesn‘t) and in implementation.

My personal advice: Public CSS properties should be absolute.

If you provide a public configuration option to the user, its simpler and more consistent with other forms of configuration if public CSS properties just always win. This might be a spicy take, but I don‘t recommend doing a ton of runtime checking of JS property values either. If the consumer sets a property to a bad value, the component does weird things. Assume positive intent, write great docs, and the other stuff is on your consumers.

Here’s a way of implementing the above example with an “absolute” public CSS property:

:host {
  --fast-animation-duration: 150ms; /* public css property */
}

.some-animating-thing {
  animation-duration: var(--fast-animation-duration);
}

Notice the media query is missing? That’s another choice you have when you decide that public CSS properties always win. If you are tempted to overwrite the public CSS property value and you decide not to, where does that media query go? One answer is to let your consumer account for that prefers-reduced-motion case if they need/want to knowing that some of them won‘t do the right thing all the time.

Taking a public CSS property into account

“But I want to keep the media query so that I know the component is accessible” you say? Sure you do, you‘re an awesome component developer that thinks about accessible components. So here‘s a hybrid example where the public CSS property value is “taken into consideration but ultimately overwritten”.

:host {
  --fast-animation-duration: 150ms; /* public css property */
}

.some-animating-thing {
  animation-duration: var(--fast-animation-duration);

  @media (prefers-reduced-motion) {
    animation-duration: calc(var(--fast-animation-duration) + 2s);
  }
}

Ultimately the actual animation-duration the component used wasn‘t the one the consumer set. The calc() takes that value into account and does some accessible math, and you totally wrote that into your docs! But now have to keep track of that 2s in your docs and change it when the calc() changes so that your docs stay accurate. Ever forget to update something like that? Never happen, right? Right.

Actually implementing public CSS properties#

Ok, so you‘ve decided on the configuration and you‘ve aligned your outlook on public CSS properties. Now you get to implement it! There are a few things to keep in mind when you have a CSS property that is designed to be used externally. We’ve sneakily already covered the first one

Make sure you don’t overwrite the value

In the world where you agree with me completely and you‘ve decided that your CSS properties are absolute winners and the component will always respect the value of them even if they are weird and bad, then you should take care where and how you define those properties so they don‘t get overwritten.

Making sure to not overwrite the value depends on one key aspect: whether or not the property has a component-specific default value.

If you want your component to inherit a design system token that is defined globally in some high up stylesheet relative to the component that‘s totally doable, but a different implementation because the value isn‘t component-specific.

Default value is “global”

For components with global stylesheets with design system tokens, you‘ll want to take care to only USE the CSS property and not DEFINE its value in your shadow DOM styles.

The first example “consumes” a globally defined token (generally defined on :root) and add fallback values to account for the global tokens not being defined.

/* some shadow root stylesheet */
.some-class {
  padding: var(--my-global-token, 4px); /* 4px will only be used if the token isnt defined */
}

All globally/generically defined tokens can inherit through the shadow DOM boundary and will apply to your component as long as they are only used and not redefined.

The next example shows what redefining the token in component styles looks like.

/* some shadow root stylesheet */

/* redefines the value and ignores a global token on :root */
:host {
  --my-global-token: 10px;
}

.some-class {
  padding: var(--my-global-token, 4px); /* 10px */
}

Here we‘re redefining the value in :host so the token value from :root won‘t cascade. The value can still be changed externally, but your consumers will need to apply the component‘s tag name or some other specific selector to override the 10px value declared in :host.

Here‘s what it would look like to override a value declared in :host:

/* some app.css - not shadow styles for the component */
my-component {
  --my-global-token: 4px; /* overrides values declared in :host */
}
Set default values on :host

The discussion of overwriting global tokens in :host leads to the second scenario where you have a component-specific public CSS property (not a global design token) that has a default value. They are essentially the same scenario except you likely don‘t have some global CSS file from the design system setting your component-specific CSS property values and are instead wanting to help out consumer devs needing to customize.

:host {
  --my-public-css-prop: 10px;
}

When you establish a public CSS property that has a default value, you can only set that default value in your :host styles. Setting the CSS property value anywhere deeper in your shadow DOM styles could prevent outside styling.

/* some shadow root stylesheet */
:host {
  --my-public-css-prop: 10px;
}

.some-shadow-dom-class {
  --my-public-css-prop: 10px;
  padding: var(--my-public-css-prop);
}

In the above example, the CSS property isn‘t actually public anymore. By setting it in a shadow root class you‘ve essentially made that property private. Unless there is some other styling mechanism available like a CSS part on the same element, your consumer would not be able to pass in a custom value for --my-public-css-prop.

Make more CSS properties

Another way to ensure that your component can handle complex use cases and features without overriding the value of public CSS properties is to just make more of them! Consider the media query example from above:

:host {
  --fast-animation-duration: 150ms; /* public css property */
}

.some-animating-thing {
  animation-duration: var(--fast-animation-duration);

  @media (prefers-reduced-motion) {
    animation-duration: calc(var(--fast-animation-duration) + 2s);
  }
}

If we wanted to fix this example so that the public CSS property isn‘t reset and the actual value used isn‘t a different value than was provided externally we can just add a new CSS property and use that directly.

.some-animating-thing {
  animation-duration: var(--fast-animation-duration, 1s);

  @media (prefers-reduced-motion) {
    animation-duration: var(--fast-animation-reduced-motion-duration);
  }
}

Now our fixed code has 2 public CSS properties, but each of them is never reset or overridden internally and we‘ve removed the calculation. And as long as we write both of these CSS properties down in our extensive and accurate documentation which all of us surely have, the consumers of our component can get the value they set for both scenarios. Naming the extra CSS properties might be a bit difficult depending on the situation, but naming things is always hard no matter what, isn‘t it?

Public CSS properties that need multiple values

Let‘s say that you have a component with multiple values for a configuration, but you want to provide a truly custom value for your consumer to override. In the situation where you have a public CSS property that needs to have multiple values but still allow configuration, your only choice is to apply your multiple default values as fallbacks to your public CSS property.

/* some shadow root stylesheet */
.size-small {
  padding: var(--my-public-css-prop, 4px);
}

.size-medium {
  padding: var(--my-public-css-prop, 8px);
}

.size-large {
  padding: var(--my-public-css-prop, 12px);
}

If you try to set that public CSS property for all your size variants, you‘ll be turning the CSS property basically private again.

/* this example won‘t work like you want */

/* some shadow root stylesheet */
.size-small {
  --my-public-css-prop: 4px;
  padding: var(--my-public-css-prop;
}

.size-medium {
  --my-public-css-prop: 8px;
  padding: var(--my-public-css-prop);
}

.size-large {
  --my-public-css-prop: 12px;
  padding: var(--my-public-css-prop);
}

Public CSS properties are public API#

Fantastic, you‘ve got a robust public API of CSS properties for your components. Surely you wont ever mess it up right? Its not super easy to forget and accidentally set some CSS property you told all your consumers was public and they one day discover they can‘t overwrite. Of course it is super easy to do that! That‘s why we consider public CSS properties truly part of the public API of our components. And what do we do to the public API of our components to make sure they don‘t break?

We test it. Yep, we unit test the CSS.

Gif of President Obama making a face showing incredulity

Our consumers can change them, and we designed our component such that they can, so we need to verify that feature doesn‘t unknowingly break.

My personal advice: Test that your public properties stay public.

That‘s right, dust off that good ‘ole window.getComputedStyle() and verify that your public CSS props actually work. Here‘s what that might look like:

// sample test for a public CSS property
// with whatever testing tool floats your boat
describe('Public CSS props', () => {
  it('should use the value as is', () => {

    const component = render('<my-component style="--my-cool-public-css-prop: 69px">'); //nice
    const internalElWherePropIsUsed = component.renderRoot.querySelector('.prop-used-here');

    // lets say the CSS prop is used on the border of the internal element
    const actualBorder = window.getComputedStyle(internalElWherePropIsUsed).border;

    // REMEMBER: DOM access to the style object converts colors to rgb() format
    // so if you‘re setting a CSS prop to a color, just set it to rgb() to begin with
    // to avoid a conversion function
    expect(actualBorder).to.equal('69px'); //nice

  });
});

You could even get really fancy and check if your public CSS property gets set even when the external value isn‘t set directly on the :host element by rendering some global styles into your test setup.

describe('Public CSS props', () => {
  it('should use the value as is', () => {

    // your design system friends will haunt your dreams if you use 37px :)
    const component = render(`
      <style>:root { --my-cool-public-css-prop: 37px; }</style>
      <my-component>
    `);

    // everything else would be the same
  });
});

You‘ll have more tests than you originally had, and more tests that aren‘t strictly testing the JS public API, but public API is public API and you‘ll be glad you did test your CSS approach. Public CSS custom property overwriting bugs are hard to spot on your own. Taking the time to codify the feature in your unit tests will prevent your users from discovering them way down the line. Depending on the type of component you have it might be literal years before someone decides to use that CSS property and discovers that it never actually worked right because the component styles reset it.

The End