Migrating to Zazz
Adopt Zazz incrementally on top of an existing CSS codebase, using cascade layers, @scope donut scoping, and migrations-layer shims so the two systems never fight while you migrate.
You don't have to rewrite your CSS to start using Zazz. Because Zazz lives entirely in cascade
layers and styles at zero specificity, you can drop it into an existing project and let it take
over screen by screen. No big-bang rewrite, no !important war with your old styles.
The core problem: unlayered styles beat layered ones
Zazz puts every rule inside a layer (variables, reset, legacy, zazz.*, or migrations). The
cascade has a rule that surprises people: any unlayered author rule beats every layered rule,
regardless of specificity. Your existing CSS is almost certainly unlayered, so on its own it would
override Zazz's components and utilities everywhere the two overlap.
The fix is to give your old CSS a layer of its own, ordered below Zazz. From there, everything else follows.
Step 1: Layer your old CSS below Zazz
In the entry stylesheet you load before Zazz, declare the full layer order up front, then import
your legacy CSS into the legacy layer:
/* entry.css - loaded before Zazz */
@layer variables, reset, legacy, zazz, migrations;
@import "./legacy/app.css" layer(legacy); /* old CSS → below Zazz */
@import "./zazz/styles/main.css"; /* re-declares the same order */The first @layer statement wins for ordering, so declaring it here (before main.css
re-declares the same order) locks legacy underneath everything Zazz ships. Now where both
systems style the same thing, Zazz wins; everywhere Zazz is silent, your old CSS still applies. On
day one, nothing visually breaks.
main.css already leaves a commented slot for this import, so if you load Zazz directly you can
uncomment it there instead:
/* main.css */
@import "./your-legacy.css" layer(legacy);The migrations layer sits at the very top of the stack, above zazz.utilities. That ordering
matters in Step 4: a translated class like .btn-primary outranks everything in Zazz, so old
markup renders correctly even against utility classes. Once you rewrite the markup and delete the
shim, native Zazz classes and utilities take over as usual.
Step 2: Isolate regions with @scope
@scope applies a block of rules to a region and, with a lower boundary, stops them at a subtree:
"donut scoping." The donut hole is the part you leave alone:
@scope (.app) to (.legacy-widget) {
/* matches inside .app, but NOT inside any .legacy-widget */
}This is how you wall off converted and unconverted sections from each other. Combine it with
all: revert-layer to strip one system's styles from a subtree without touching the others — for
example, drop a revert-layer reset inside the legacy layer to remove your old styles from a
converted section while Zazz keeps applying on top. Unlike a .app :not(.legacy-widget *) selector,
@scope accounts for hierarchical proximity, so it still behaves correctly when regions nest inside
each other.
Step 3: Blank-slate a region with all: revert
Once Zazz is on the page, its reset layer restyles native elements globally. That's what you
want for new UI, but it also reaches into old markup. To hand a region fully to Zazz, reset it to
the browser's defaults first with all: revert, which drops every property back to the
user-agent value, erasing both your legacy styles and Zazz's on that element:
@scope (.takeover) to (.legacy-widget) {
:scope,
:scope * {
all: revert; /* ignore all author CSS; start from UA defaults */
}
}Bounding the reset inside @scope is what lets you avoid a global * { all: revert }, which the
cascade can't cleanly override and which web components and lower layers can't escape.
all: revert is the sledgehammer. It removes Zazz too. Its surgical sibling, all: revert-layer,
drops only to the previous cascade layer. Put a revert-layer reset inside the legacy layer
and it strips your old styles while Zazz's higher layers keep applying on top. Reach for
revert-layer when you mean "remove my old styles but keep Zazz," and revert when you want a
true blank slate (for example, to quarantine a third-party widget from both systems).
Step 4: Translate old class names in the migrations layer
The last piece bridges markup you haven't rewritten yet. A shim in the migrations layer re-points
your old class names at Zazz's component tokens, so existing HTML renders as Zazz before you
touch it. These rules live in a migrations.css you create and load (import it at the commented
slot at the bottom of main.css) inside @layer migrations. Because Zazz components are
token-driven, the shim is small and stays themeable, so light/dark and brand changes flow through
for free.
Here .btn-primary is translated to the Zazz button semantic tokens. Old modifier classes map to
Zazz variants the same way data-variant does, by reassigning the component tokens, not restating
the component rule:
/* migrations.css — a temporary bridge. Delete each rule as you rewrite the markup. */
@layer migrations {
@scope (.app) to (.legacy-widget) {
.btn-primary {
--button-background: var(--primary);
--button-foreground: var(--primary-foreground);
}
}
}The same move works for any component: a legacy .card can adopt card tokens, a .tag can adopt
badge tokens, and so on. Each translated rule is a stepping stone. Once a screen's markup is
updated to native Zazz classes and attributes, delete its shim. When the migrations layer is
empty, the migration is done.
A migration path
- Layer. Add the
@layerorder and import your old CSS intolegacy(Step 1). Ship it. Nothing should change yet. - Isolate. Use
@scopedonut scoping (withrevert-layerwhere needed) to wall off converted and unconverted sections (Step 2). - Reset where needed. Blank-slate stubborn regions with
all: revert/revert-layerso legacy styling stops leaking in (Step 3). - Translate, then replace. Add
migrations-layer shims to make old markup look right now (Step 4), then rewrite the HTML to real Zazz classes and delete the shim, screen by screen.
Browser support
@layer, all: revert, and revert-layer are Baseline widely available, safe everywhere
with no fallback. @scope is Baseline 2024 (newly available): Chrome/Edge 118, Safari 17.4,
Firefox 128. If you must support older browsers, skip the @scope steps and lean on @layer
plus migrations-layer shims instead.
Next steps
- Set up the entry stylesheet in Installation.
- Read Overview for the full layer cascade model that makes all of this predictable.