← All writing

Beyond light and dark: designing multi-surface themes in MUI

A practical architecture for multi-brand MUI applications where dark navigation, glass workspaces, and light content coexist on one page.

  • React
  • TypeScript
  • MUI
  • Design Systems
ArticleSalman M. Khan

Most theming examples begin with one switch: light or dark. That is the correct model for many products. It was not enough for a platform we had to rebrand.

The application had a global light or dark preference, but the page was not visually uniform. Navigation needed its own branded dark treatment. The main workspace used a translucent, glass-like layer. Dense forms and long-form content inside that workspace sometimes needed an opaque light surface—even while the surrounding application remained dark.

Treating all of that as additional color modes quickly made the code harder to explain. The useful distinction was this: color scheme, brand, and visual surface are separate concerns.

The problem is larger than a dark-mode toggle

MUI correctly models palette.mode as light or dark. Components use that value to choose expected behavior: contrast, overlays, control states, and other defaults. A glass workspace is not a third color scheme. Neither is a sidebar.

They are visual roles. A sidebar describes where a component lives. Glass describes how a layer is rendered. Neither tells MUI whether native components should behave as light or dark components.

That gives us three independent axes:

  • Tenant selects brand tokens such as color, typography, geometry, and density.
  • Theme family represents the user's global light or dark preference.
  • Surface describes the local visual context: application, sidebar, glass, or content.

Keeping those axes separate prevents names such as glassMode or sidebarDarkMode from leaking through feature code. It also lets the same surface resolve differently for another tenant without changing the component that consumes it.

A finite theme matrix instead of scattered conditions

The resolver accepts three typed values and returns one MUI theme. The combinations form a finite matrix rather than a chain of feature-level conditionals:

type TenantId = 'prism' | 'northstar'
type ThemeFamily = 'light' | 'dark'
type SurfaceVariant = 'application' | 'sidebar' | 'glass' | 'content'

resolveTheme(tenant, family, surface)

That shape matters. Every supported combination can be inspected and tested. Adding a tenant means defining its foundations and complete surface palette—not sprinkling another tenant check through the page.

The resolved theme keeps palette.mode valid and stores custom context separately:

theme.surface = {
  tenant: 'prism',
  family: 'dark',
  variant: 'glass',
  backdropFilter: 'blur(24px)',
  shadow: '...'
}

Third-party MUI components still receive the light/dark signal they understand. Our own layout primitives can read semantic surface metadata without pretending that MUI supports a third mode.

Why nested ThemeProviders are justified here

MUI supports nested themes. The important question is not whether nesting is possible; it is where a new provider represents a real design boundary.

Application surface — follows the global family
├── Sidebar surface — remains dark
└── Glass workspace — translucent local context
    └── Content surface — remains light and opaque

Ordinary boxes, stacks, cards, and list rows do not create providers. A provider appears only when background, foreground, dividers, component defaults, elevation, and interaction states need to change together.

This avoids the provider-per-component trap. It also means a component can continue using normal semantic tokens such as background.paper, text.secondary, and divider. The boundary supplies the right meaning for its local context.

Glass is a surface, not just a background color

It is tempting to implement glass by changing one background value. That works until cards, inputs, borders, disabled states, and typography need to remain legible over variable content.

In this system, the glass surface coordinates translucent backgrounds, stronger inner paper, borders, shadow, blur, text, and action states. In the dark family it still reports a dark MUI mode. In the light family it reports light. Glass describes the treatment; the family still determines component color behavior.

The nested content surface solves a different problem. Forms and dense reading areas benefit from stable, opaque contrast. The content boundary therefore remains light even when its glass parent and application shell are dark. The form does not contain mode checks. It simply consumes the closest theme.

Cache resolved themes, not application data

MUI theme creation is deterministic for a given tenant, family, and surface. Rebuilding those objects during rendering would create unstable provider values and unnecessary work. The resolver therefore caches each theme by a compound key:

const key = `${tenant}:${family}:${variant}`
const cached = cache.get(key)

if (cached) return cached

const theme = createTheme(/* foundations + surface tokens */)
cache.set(key, theme)
return theme

This is a small in-memory cache for immutable design output. It is not a server-state cache and it does not replace a data-fetching library. Its job is simply to preserve stable theme identities across renders.

Runtime tenant configuration needs a boundary

In a production multi-tenant product, branding configuration commonly arrives after authentication or tenant discovery. It may come from an API, an edge-generated configuration file, or deployment configuration. The UI should not trust that payload directly.

The demo uses a mock asynchronous request, validates the unknown response with Zod, and commits the parsed configuration atomically to a small Zustand store. It also demonstrates cached startup, revalidation, tenant switching, and preserving the current tenant when a switch fails.

const logoSize = useTenantConfig(
  (config) => config.branding.logoSize,
)

const analyticsEnabled = useTenantConfig(
  (config) => config.features.analytics,
)

This is safer than string paths and more intentional than giving every component the whole configuration object. A component subscribes to the exact value it needs. Feature code asks about capabilities such as analytics, not whether the tenant happens to be Northstar or Prism.

Test the combinations that the types promise

A finite matrix is useful because it can be exhaustively tested. The demo verifies all tenant, family, and surface combinations. Tests assert that metadata matches the request, MUI mode is always valid, content remains light, the sidebar remains dark, glass follows the selected family, and a repeated key returns the same cached theme object.

Those tests do not prove visual accessibility by themselves. Contrast testing, browser screenshots, and interaction checks still belong in a production pipeline. They do prevent architectural drift—for example, someone accidentally turning glass into an invalid palette mode or bypassing the cache.

When this architecture is the wrong choice

This pattern adds a resolver, a token matrix, runtime configuration, and carefully placed providers. That cost is justified only when the product has the corresponding requirements.

If an application has one brand, one global scheme, and visually uniform pages, use MUI's standard theming and color-scheme APIs. They are simpler and easier for a team to maintain. Do not invent semantic surfaces because a dashboard has two card colors.

The pattern becomes valuable when multiple brands and simultaneous visual contexts are genuine product constraints: branded navigation, mixed light/dark regions, translucent workspaces, white-label configuration, or dense content that must remain readable inside expressive application chrome.

The broader design-system lesson

Rebranding work often begins as a color replacement and reveals a modeling problem. If every component knows the tenant, mode, and page it lives on, the design system is not providing enough semantic structure.

Model color scheme, brand, and visual surface as separate concerns.

Once those concepts are independent, the theme resolver can combine them, layout boundaries can apply them, and feature components can return to doing the simple thing: consuming semantic design tokens.