Back to engineering

Engineering

Two planes and a gotcha: per-tenant theming with CSS custom properties

The Bookatu engineering team5 min read

How Bookatu themes every tenant's storefront from one component tree: brand colour injected as CSS variables, dark mode built as a second value plane, and the inline-specificity bug that needs exactly one !important.

The short version

  • Each tenant's brand colour is injected as CSS custom properties on a single scope element, so components read tokens, not fixed colours.
  • Dark mode is a second "plane": the same token names with different values, swapped by a data attribute, so component CSS never forks.
  • An inline custom-property value (a tenant's brand tint) outranks any stylesheet rule, so a near-white tint can survive into dark mode and wash out.
  • The fix is one !important on the dark-plane override, because that is the only way a normal stylesheet rule beats an inline declaration.
  • Keep the global app theme and the storefront theme on separate scopes so a tenant's choices never leak into the dashboard.

One platform, many brands

Bookatu is one booking platform with per-industry verticals: salon, spa, personal trainer, sports coach, fitness or yoga studio, restaurant, and photographer. Each one gets tailored wording and its own public storefront. A photographer's private client galleries, a trainer's session packs, and a restaurant's floor-plan reservations all render from the same component tree. The thing that must change per tenant is the look: their brand colour, and whether the visitor sees light or dark.

We do not ship a separate build per tenant. We theme at runtime with CSS custom properties. This post walks through how the brand colour gets in, how dark mode rides on top as a second plane, and the one specificity bug that cost us an afternoon.

Brand colour as CSS variables

A custom property is a value you define once and read back with var(). MDN notes that custom properties "are subject to the cascade and inherit their value from their parent." That inheritance is the whole trick. We set the tenant's brand colour on one scope element, and every descendant component reads it without a single tenant-specific selector.

html
<!-- The brand value is resolved server-side and dropped
     onto the scope root as an inline style at render. -->
<div class="sf-scope"
     style="--brand: #6d28d9; --brand-soft: #efe9fb;">
  <!-- the whole storefront renders in here -->
</div>
css
/* Components read tokens, never raw colours. A button three
   levels deep inherits --brand from the scope above it. */
.sf-scope {
  --surface: #ffffff;
  --text: #111827;
}

.sf-scope .button-primary {
  background: var(--brand);
  color: #fff;
}

.sf-scope .chip {
  background: var(--brand-soft);
  color: var(--text);
}

Because custom properties inherit, there is no per-tenant CSS to write or ship. The component stylesheet is fixed. Only the values on the scope change, and they change at request time, not at build time.

Dark mode as a second plane

Dark mode is not a new set of components. It is the same tokens with different values. We call it a "plane" because the component layer stays still while the value layer underneath swaps. A data attribute on the scope picks the plane.

css
/* Light is the default plane, declared on the scope. */
.sf-scope {
  --surface: #ffffff;
  --text: #111827;
}

/* The dark plane is the same token names, new values.
   No component rule changes. Only the variables move. */
.sf-scope[data-sf-theme="dark"] {
  --surface: #0b0b0f;
  --text: #f4f4f5;
}

We also respect the visitor's system setting. MDN describes prefers-color-scheme as the media feature that detects whether a user "has requested a light or dark color theme." We use the media query as the default and let an explicit choice on the attribute override it. One detail worth stating: set that attribute synchronously in a tiny head script before the first paint, or the page flashes the wrong plane for a frame while your client script catches up.

The specificity gotcha

Here is the bug. A tenant's brand usually comes with a soft tint, a near-white version of their colour used for hover backgrounds and chips. In light mode it reads fine. In dark mode that near-white tint glows against the dark surface. We wrote a dark-plane rule to recompute the tint. It did nothing. The chips stayed pale.

The reason is specificity. The tenant's tint was injected inline, on the style attribute. MDN puts inline styles at a specificity weight of 1-0-0-0, which beats any selector. Our dark rule was a selector, .sf-scope[data-sf-theme="dark"], so it ranked lower. The inline near-white value won every time, in both planes. Custom properties are not special here; they follow the same cascade as normal properties, so an inline custom-property declaration outranks a stylesheet one.

The value would not budge no matter how specific we made the selector. That is the tell: it was not a cascade problem, it was an inline declaration we could not outrank with another normal rule.

MDN is blunt about the only escape: "The only way to override inline styles is by using !important." So the dark-plane override gets one !important, and the tint finally recomputes for the dark surface.

css
/* The inline --brand-soft wins over a plain selector, so it
   leaks into dark mode. !important lets this normal rule beat
   the inline declaration, and only on the dark plane. */
.sf-scope[data-sf-theme="dark"] {
  --brand-soft: color-mix(in srgb, var(--brand) 28%, #0b0b0f) !important;
}

We do not reach for !important often. MDN warns that it "interacts directly with specificity and the cascade" and that overuse makes stylesheets hard to follow. But this is the exact case the keyword exists for: a normal stylesheet declaration that must beat an inline one. It is one property, scoped to one plane, with a comment explaining why.

Keeping the planes apart

Bookatu actually runs two theme systems. The marketing site and the owner's dashboard use a global theme on the document root. The public storefront uses its own scope, because a tenant's brand colour and the visitor's chosen storefront mode must not bleed into the app chrome. We scope them separately: the app theme on one attribute at the root, the storefront theme on .sf-scope with its own attribute. A visitor choosing a dark storefront does not flip the salon owner's dashboard. Two planes, two scopes, no leaks.

The payoff is that a salon, a photographer, and a restaurant share one component tree, one stylesheet, and one dark mode. The only thing that changes per tenant is a handful of variable values, set on a single element. And when you hit a value that will not move, check whether it was injected inline before you blame the cascade.

Sources

  • Using CSS custom properties (variables) - MDN Web Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties
  • Specificity - CSS - MDN Web Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity
  • !important - CSS - MDN Web Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/important
  • prefers-color-scheme - CSS - MDN Web Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
CSSthemingcustom-propertiesdark-modemulti-tenantfrontendspecificity

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs