More writings

Style APIs As A Last Resort

Last Spring I studied a popular CSS library and took a bunch of notes, originally just for my own purposes but, as I push myself to write more, I’m reformatting them into articles. This is the second of those. The first one was on HTML interfaces.

This article attempts to explain why style APIs should be avoided wherever possible, suggests how whole categories of style APIs may be unnecessary, and outlines what makes a good style API.

What is a style API?

I’ve seen others use this term, but I don’t believe it has an established definition anywhere. I’ll do my best to explain what I mean when I use it.

A style API is an application programming interface for styling. You know that CSS lets us create systems, but the larger or more complex a web project the more the more we want to be thoughtful about the way we structure this system of styles. This is especially true if we expect the CSS to serve more than one web project. Systems of CSS go by many names—theme, library, framework—but the idea is that the consumers of such a CSS system are freed from worrying about CSS implementation details. They can instead work with CSS at a higher level by using an API to apply styles instead of writing them directly.

The project-owned connection points within CSS systems for controlling how CSS is applied is what I’m attempting to get at with the term “style API.” In other words, the parts of a CSS system that are maintained to allow external users to use and configure the system. Examples of style APIs include component style variants, component style modifiers, utility classes, and even base design constants such as color palettes, text styles, shadows, glows, etc.

All APIs have a cost

The number of API entry points directly influences the difficulty of learning and using a system. Each API adds more for a consumer to learn and understand before they have mastered use of the system. Maintaining an API also takes time. As a system adds new API, the risk of regression increases.

If you’re with me on that, you’ll likely agree that any API should provide value that outweighs the burden its existence brings to the system. A style API should provide a utility that would not be possible without it. If the same result can be achieved without a style API, we’re in a stronger position by not having that style API.

Josh Clark’s article Ship Faster by Building Design Systems Slower goes into more depth on how being reluctant to add API is necessary for the success of a design system.

Let’s talk about Shadow DOM

It’s common to see style APIs created using custom properties to work around the style encapsulation of the Shadow DOM. CSS inheritance passes down into the Shadow DOM and all custom properties are, by default, inherited properties making those properties available on both sides of the shadow boundary.

<!-- Web component definition -->
<example-component>
  <template shadowrootmode="open">
    <label>Text</label>
  </template>
</example-component>
/* -- Project using this web component --- */

/* Does not work, cannot reach in
   and style the Shadow DOM like this. */
example-component label {
  background-color: gold;
}

/* Will work if styles inside of
   this web component use this specific
   API to set the label background color. */
example-component {
  --color-labelBackground: gold;
  /* However, this is a poor custom property name
     because it is just aliasing the `background`
     property. I’ll explain a better approach below. */
}

It’s cool that this works, but let’s look at an inherent problem with this pattern of mirroring properties.

Imagine a label element in the shadow DOM needs to be italic in some cases, and bold in others. Should a style API like --fontStyle-label be created?

/* -- Internal web component styles --- */
:host {
  label {
    font-style: var(--fontStyle-label);
  }
}

Consider a label background that grows darker on hover. Should a style API like --fontStyle-labelBackgroundHovered be created to support that usecase?

/* -- Internal web component styles --- */
:host {
  label {
    font-style: var(--fontStyle-label);
  }
  label:hover {
    background: var(--fontStyle-labelBackgroundHovered);
  }
}

What if focus styles need to match? Should another style API like --fontStyle-labelHoveredOrFocused be created for that?

/* -- Internal web component styles --- */
:host {
  label {
    font-style: var(--fontStyle-label);
  }
  label:hover {
    background: var(--fontStyle-labelBackgroundHovered);
  }
  label:hover,
  label:focus-visible {
    background: var(--fontStyle-labelHoveredOrFocused);
  }
}

Where to draw the line?

It’s not hard to imagine that as an API of this sort gets larger and larger it must inevitably recreate a sort of utility-first naming approach (similar to Tailwind) where modifiers for things like pseudo-selectors and media/container queries need to be encoded in the same namespace as CSS property names.

Reconsidering styling across the shadow boundary

Looking a little closer at how the shadow boundary is defined can be helpful in finding some more congruent ways to style across it without the need for creating APIs.

Of particular interest is that the Shadow DOM only shields styles from the shadow children of the web component. In other words, the shadow root element of the component is not style encapsulated. This means internal styles set using :host can be overridden by consumers of the web component.

It follows, then, that a component author can intentionally put styles on that shadow root to make those styles available for customization.

<!-- Web component definition -->
<some-element>
  <template shadowrootmode="open">
    <style>
      :host {
        /* Setting properties on the root of the
           web component enables consumers to
           override and change them. */
        border-radius: 50%;
      }
    </style>

  </template>
</some-element>
/* -- Project using this web component --- */
some-element {
  /* We can change this because the web component author
     offered it on the outer element, which is above
     the shadow boundary */
  border-radius: 16px;
}

In the above example we’ve exposed the border-radius as style configuration without creating a style API.

I’m sure you can imagine many ways to leverage inheritance and other context-related features in CSS to provide more elegant style configuration across the shadow boundary before resorting to inventing new style API.

Consider these ways that the CSS language has built in:

The inherit keyword

Many native CSS properties are inherited by default and other properties can be made to inherit by setting the inherit keyword.

:host {
  text-align: /* This is one of the properties that inherit by default.
                 See the full list of others in the link above. */;
  
  /* This property does not inherit by default but can be told
     to with the inherit keyword */
  background: inherit;
}

The currentColor keyword

The color property inherits by default. This unlocks the ability to leverage the currentColor keyword for referencing the value on the color property as a value in any property value. There are many ways this can be used to automatically adapt component colors to an outer color. Gradients, border colors, accent colors, color-mixes, etc., can all be used with currentColor.

:host {
  color: /* This is one of the properties that inherit by default.
            See the full list of others in the link above. */;
  
  /* Using the inherited color */
  border: 1px solid currentColor;
}

Font relative units

The font property is a shorthand. This property and its constituent longhands (font-size, font-family, line-height, etc.) all inherit their values by default. This means that font relative units (em, ex, ch, lh, etc.) are (by default) relative to whatever font properties are set on the outer scope. This unlocks the ability to change any length within the component’s internally scoped CSS based on what font size is present in the component’s outer scope.

:host {
  font-size: /* This is one of the properties that inherit by default.
                See the full list of others in the link above. */;
  /* Using the inherited font size */
  height: 4em;
}

Container and media queries

Beyond direct property inheritance, consider how other forms of context can be used to adapt styles. Container queries provide the ability to apply a different style configuration based on the size the parent component is rendered at. This could be used, for example, to automatically change the variant of a component without the need for an API.

:host {
  container-type: size;

  control-group {
    /* Large variant */
    @container (height > 10rem) {
      /* Stack horizontally */
      flex-direction: row;
    }
    /* Small variant */
    @container (height < 10rem) {
      /* Stack vertically */
      flex-direction: column;
    }
  }
}

Bringing it together

Let’s put some of the above ideas together in a practical to example to illustrate how a web component can be style configured without the need for additional API.

<!-- Web component -->
<guitar-knob></guitar-knob>
/*-- Internal guitar-knob styles --*/
:host {
  /* There is a separate element for a focus ring to allow for
     a more fancy animation on focus */
  &:focus-visible {

    .focus-ring {

      /* The focus ring should match the rounded corners of the
         component. This can be achieved in a congruent way by
         inheriting whatever border-radius is on the outer element. */
      border-radius: inherit;
    }
  }

  label {
    /* The label text needs to be a little bit lighter.
       One way to achieve this in a congruent way is by inheriting
       the color of the outer element and then mixing it with white. */
    background: color-mix(in oklch, currentColor 20%, white);

    /* The space for the label needs to be two lines tall.
       A congruent way to do this is to use a font relative
       unit for the sizing. */
    block-size: 2lh;
  }
}
/*-- Project using the guitar-knob component --*/
guitar-knob {
  /* We’ve changed this knob to be a rounded square for a more
     unique look. The custom focus ring deep in the shadow tree
     is inheriting and now using this new border-radius. */
  border-radius: 20%;
  
  /* We’ve changed the text color on our use of this knob
     and, without any APIs, an element deep in the shadow tree
     is inheriting and now using this new color for the label. */
  color: rebeccapurple;
}

guitar-instrument {
  /* On this parent element we’ve decided to change the font,
     this new font is best with a taller line-height. The label
     deep down in the shadow tree of the guitar-knob is inheriting
     the new font and it’s larger line-height and has increased it’s
     block-size accordingly. */
  font-family: "Fira Sans", sans-serif;
  line-height: 1.6;
}

In the example above, all of the configuration across the shadow boundary is done without creating any style APIs.

When creating style APIs

Let’s switch gears now and talk about how to craft the style APIs that make up a CSS system. There are a few guidelines here that have formed in my head and I’ll go through them below.

Consider again whether the API is still needed

Before adding a style API stop and consider. Is there another way that doesn’t incur the cost of a new API? Before updating a style API, think about just removing the API instead.

CSS is exploding in capability. I wouldn’t be surprised to learn that the changes to CSS since the pandemic now exceed the previous 10 years combined. There are now incredible new features like cascade layers and color-mix both of which obviate large structural pieces of many existing CSS system architectures and both now have over 90% support in global browser usage.

The possibilities and potential for reimagining CSS architecture at scale have never been greater.

Avoid directly aliasing CSS properties

When naming a style API, use a declarative approach rather than an imperative one. Instead of directly aliasing an existing CSS property, look for ways to allow the consumer of the API to express the intent of their configuration. By being less prescriptive of implementation in the naming, the API is less likely to require changes to maintain semantic accuracy as the component internals get updated over time to add new features or adapt to different environments or design languages.

Ideally API names should only be expressing higher-level configuration options.

example-component {
  /* Imperative naming.
     Implies that labels in this
     component will set this value for
     the `background` color property. */
  --color-labelBackground: gold;
}

example-component {
  /* Declarative naming.
     Allows more flexibility in how
     it applies to the component. */
  --color-secondary: gold;
}

Avoid directly aliasing CSS values

Unless the CSS library is fully down the utility-first rabbit hole, my advice is that it’s generally not a good idea to force consumers to use a library API as an alias for plain CSS.

example-component {
  /* Arguably more utility is lost than
     gained by using a library value over
     just writing 0.75rem. */
  font-size: var(--ln-fontSize-0_75rem);
}

example-component {
  /* This API name is more useful because
     it can hold a different value depending
     on context. */
  font-size: var(--ln-fontSize-caption);
}

Above example uses ln short for “Library Name” as a sample library-specific namespace. A project like Spectrum CSS might use sp, Tailwind tw, etc.

Enforce a maximum number of hyphens

An easy way to improve a style API is to make it more readable. We like kebab-casing in CSS names but a long string of kebab-cased words can be difficult to parse at glance, especially when a namespace is involved and the semantics come midway into the name.

In my opinion, custom property APIs are a lot easier to follow with a fixed number of hyphens (and camel-casing in-between) to enforce structure and meaning in predictable places.

:root {
  /* Harder to scan: */
  --ln-control-accent-color: blue;
  --ln-focus-ring-color: cadetBlue;
  --ln-label-color-quaternary: lightGray;
  --ln-heading-title-font-size: 1.5rem;
  --ln-subheading-font-size: 1.2rem;
  --ln-caption-font-size: 0.65rem;

  /* Easier to scan: */
  --ln-color-controlAccent: blue;
  --ln-color-focusRing: cadetBlue;
  --ln-color-labelQuaternary: lightGray;
  --ln-fontSize-headingTitle: 1.5rem;
  --ln-fontSize-subheading: 1.2rem;
  --ln-fontSize-caption: 0.65rem;
}

Above example uses ln short for “Library Name” as a sample library-specific namespace.

Both of these groupings hold the same token name but, in my opinion, one is faster to read than the other.

I’ve been referring to this specific use of two hyphens above (conforming to <namespace>-<valueType>-<valueName>) as triptych notation, which is a riff on Hungarian notation.

Shadow parts

You might have noticed I didn’t address shadow parts above. My thoughts on all of this are still evolving and on the ::part pseudo-element especially. I haven’t played with this feature enough yet to have an opinion on how best to use it.

My current thinking is that shadow parts seem best fit for components that are intended to be aggressively restyled, but I wonder how useful it actually is in practice since it seems like you’d run into a similar API scaling problem of ‘well if that’s a part, this other thing should be a part also’ until the entire component might as well just be in the light DOM.

Wrapping up

Hopefully you found some of my thinking above useful! As always, I’d love to hear from you and continue the discussions using the links in the footer.

If you enjoyed this, check out my previous article on Naming CSS Variables, which explores many of these same topics from a more general perspective, and this one on HTML Interfaces, which gives suggestions around ways component libraries can interact with CSS theming using HTML.