Back to engineering

Engineering

Storefront themes that can't break a layout, and the CSS layer that fought us

The Bookatu engineering team6 min read

Letting every business pick a look for their shop page sounds like a support nightmare waiting to happen. Here's how we made themes that restyle a storefront but are structurally incapable of breaking it, and the @layer gotcha that ate an afternoon.

The short version

  • Themes are token-level only: headline font, corner radius, hero glow. Never structure. A theme can restyle a page but cannot move or remove anything, so it can't break a layout
  • One data attribute on the storefront scope switches the whole look, with zero effect on the admin or marketing planes
  • The default emits no attribute at all, so every existing shop keeps its exact current look until an owner opts in
  • The headline font swaps refused to apply until we understood @layer precedence, which is where the afternoon went

A tenant asked a reasonable question: could they choose how their public shop page looks, not just its brand colour? A photographer wants something quiet and editorial, a boxing gym wants something loud. The naive way to build this is a pile of theme components, each a full re-implementation of the storefront. That way lies a combinatorial support burden: every theme times every feature is a surface that can break independently. We wanted the opposite, a theme system where the worst a theme can do is look a bit different.

The rule: a theme sets tokens, never structure

The whole design rests on one constraint. A theme is allowed to change design tokens and nothing else. It can set the headline font family and weight, the corner radius, and the intensity of the hero glow. It cannot change the DOM, reorder sections, hide anything, or touch spacing that the layout depends on. Because a theme only ever adjusts values the layout already reads, there is no arrangement of themes and content that produces a broken page. The blast radius is 'the corners are rounder than you might like', which is a preference, not a bug.

Mechanically it's a single attribute on the storefront's scope element. The storefront already lives in its own CSS scope, isolated from the admin and marketing planes, so painting inside it can never leak out to the back office. Each theme is a short block of custom-property overrides keyed off that attribute.

css
/* The storefront scope carries the chosen look. The admin
 * and marketing planes never see this attribute. */
.sf-scope[data-sf-look="editorial"] {
  --radius-lg: 0.55rem;
  --radius-xl: 0.8rem;
}
.sf-scope[data-sf-look="bold"] {
  --radius-lg: 0.4rem;
  --radius-xl: 0.55rem;
}

/* 'auto' (the default) emits NO attribute, so an existing
 * shop keeps its exact current look until the owner opts in. */

The afternoon that @layer ate

The radius and glow changes worked immediately. The headline font swaps did not. Selecting the editorial theme rounded the corners but left the masthead in the default typeface, no matter how specific we made the selector. The instinct is to reach for higher specificity, or the nuclear option of !important. Both are the wrong answer, and reaching for them without understanding why would have hidden the actual lesson.

The storefront headline carries utility classes for its font and weight, and those utilities live in a CSS cascade layer. Cascade layers change the rules: a declaration in a later layer beats a declaration in an earlier layer regardless of selector specificity. That is the entire point of layers. Our theme rules lived in the components layer; the utility classes on the headline lived in the utilities layer, which is declared later. So the utility won every time, and no amount of specificity on a layered rule could touch it, because specificity is not the tiebreaker once layers are involved.

Specificity stops being the tiebreaker the moment cascade layers are in play. A weak selector in a later layer beats a strong selector in an earlier one.

The fix is almost anticlimactic once the model is clear. Unlayered styles, declarations that belong to no layer at all, outrank every layered declaration. So the theme's headline rules moved out of the components layer entirely and became unlayered. Now they beat the utility classes not by brute-force specificity but because unlayered wins over layered, full stop. The comment we left in the stylesheet is longer than the fix, because the fix is obvious and the reason is not.

css
/* DELIBERATELY UNLAYERED. The headline carries utility classes
 * (font-serif / font-semibold) in @layer utilities, which beats
 * any @layer rule regardless of specificity. Unlayered rules
 * outrank every layer, so these overrides actually apply. */
.sf-scope[data-sf-look="editorial"] .sf-hero h1 {
  font-family: var(--font-editorial), serif;
  font-weight: 500;
}

Why this shape was worth it

The payoff is that adding a new look is a dozen lines of custom properties and a row in a config array, not a new component tree with its own bugs. QA for a theme is 'does it read cleanly across the eight conditions', not 'does every feature still work under this theme', because features can't know a theme exists. And the opt-in default means shipping the whole system changed exactly zero live storefronts on day one. The most reassuring feature we built was the one that does nothing until someone asks it to.

cssdesign systemsmulti-tenantfrontend

Building on Bookatu?

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

Developer docs