Case study
Why changing language, theme and motion at the same time is harder than changing each one, what Experience Engine does about it, and what the evidence does and does not show. About five minutes.
The problem
A product that serves more than one market and more than one brand ends up with several ways its interface can vary: the language and reading direction, the visual theme, how much it animates. Each has good tooling. A localization library handles copy and formats, design tokens handle colour and spacing, and a setting handles animation.
The trouble starts when they change together. Take a storefront admin switching from English with a light theme to Arabic with a dark, compact one. That single user action means:
- load the Arabic copy and an Arabic typeface, and do not show Arabic text until both are there;
- flip the layout to right-to-left, including the chart and the table columns;
- swap every colour, radius and spacing value;
- replace the data table with a compact list, whose code has to be downloaded first;
- animate the change, or not, depending on a setting and on the device;
- if the user clicks again before this finishes, make sure the older request cannot land on top of the newer one;
- if anything fails to load, leave the screen exactly as it was.
Why the usual approach gets difficult
None of those steps is hard. What is hard is that they live in different places: a locale provider, a theme provider, a few lazy imports, some conditional rendering, a loading flag. The coordination between them is nobody’s job, so it ends up in application code, written again at each place an experience can change.
That code tends to grow the same parts every time: a counter to detect stale requests, a Promise.all over whatever needs loading, an ordering of state updates so nothing shows half-applied, and a catch block that tries to undo what already happened. It can be written correctly. It is rarely written once.
The model
Experience Engine treats the combination as one value, an experience, with three independent dimensions: culture (locale, direction, typography, formats, translations, fonts), theme (tokens, density, component adaptation, assets) and motion (how a change is shown). The application declares them as plain data and asks for a complete experience:
await engine.setExperience({ culture: "ar-EG", theme: "midnight", motion: "smooth" });Each dimension can still change on its own. What changes is who owns the transition: the engine, in one place, instead of the application, in many.
What the engine does with a request
The Experience Transition Protocol. The same eight stages run for every change.
Normalize
Validate the request and fill in defaults.
Resolve
Turn it into an immutable snapshot: locale, direction, tokens, formats, resources, motion.
Diff
Compare with the current snapshot to get a typed delta, one section per dimension.
Seeds
Pick the nodes in the dependency graph that the delta touches directly.
Closure
Follow the graph from those seeds to find everything affected. The rest is left alone.
Prepare
Load the translations, fonts, assets and code the target needs, together, without touching what is on screen.
Transition
Hand the resolved motion to the application to play.
Commit
If this is still the latest request, replace the committed snapshot in one step. Otherwise discard it.
Implementation
Two packages. @experience-engine/core is the runtime: no dependencies, no framework, no browser globals, so the same code resolves an experience in a server component. @experience-engine/react is a provider and a handful of hooks on top of useSyncExternalStore; components re-render when the engine commits and at no other time.
Components adapt at three depths. A token restyles a component in place. A variant keeps the component and changes its form. A replacement swaps in a different component, and its code is prepared like any other resource.
This site is the larger worked example. The storefront preview in the Studio holds no language, theme or animation state of its own; it renders whatever snapshot the engine committed.
One transition, concretely
The transition described at the top of this page, run by the engine when this page was built. From the engine
en-USlightinstant → ar-EGmidnightsmooth
| What | Before | After |
|---|---|---|
| Language | en-US | ar-EG |
| Direction | ltr | rtl |
| Currency format | USD | EGP |
| Page surface token | #f5f6f8 | #0b1220 |
| Navigation | Navigation (base) | NavigationCompact (variant) |
| Orders | OrdersTable (base) | OrdersList (replacement) |
| Motion | instant | view-transition, 480 ms |
- Changed keys in the delta
- 30, of which 23 are visual tokens
- Dependency graph
- 35 of 35 nodes in the prepared closure, from 32 seeds
- Resources prepared before commit
- 4: translation, font, asset, code
- Outcome
- COMMITTED
Run it yourself in the Studio and open the inspector, or break it on purpose in the Playground.
What the evaluation found
Stored results from the research record. Stored results, not live measurements
- The safety properties hold in the tested workloads. 301 cases in headless Chromium (50 resource failures, 50 stale races, 101 rapid requests, 100 mixed): 0 invariant violations.
- Each stage earns its place. 500 graphs, 10,000 transitions. Without typed delta: 1.358× prepared work. Without closure: 7,661 downstream omissions. Without the guard: 1,050 stale overwrites. Without prepare-before-commit: 1,200 partial states.
- A careful manual implementation does just as well on safety. 10,000 matched transitions with injected failures and stale requests: 0 partial commits, 0 stale overwrites. The contribution is the reusable abstraction, not a guarantee that cannot be had otherwise.
- It is not faster in general. In several measured scenarios the engine added orchestration overhead.
- Server rendering works in the tested setup. Two cookies on the same route returned two exact server-resolved identities (ar-EG::luxury::smooth and en-US::light::instant), both HTTP 200 with matching direction.
The full table, with the scope of each result and its source file, is on the Evidence page.
Limitations
- Version 0.1.0 is experimental and has not been used in production.
- The engine reports commits. The stages before a commit cannot be observed as live events.
- Component adaptation is driven by theme tokens, so the delta lists those token keys instead of component changes.
- Results on dependency locality come from synthetic graphs. The graph in this demo has a few dozen nodes.
- Per-request server rendering was validated on a local production server, not behind a CDN or a shared cache.
- Nobody outside the project has independently replicated the results.
- The evaluation is about system behaviour. It does not measure whether people prefer the result.
Where to go next
- StudioCompose an experience and inspect the transition
- PlaygroundRapid requests, injected failures, unregistered ids
- Personalized SSRA cookie choosing the server-rendered experience
- ArchitectureLayers and stages in more detail
- GitHubSource, documentation, examples and the research archive