Skip to content

SaaS application shell

The product is your application’s outer frame. Dashboard, billing, team, integrations, reports: each is a module a customer opens, sometimes two at once. The dock is the module switcher, each module is an App, and the whole thing reads as an application instead of a website with a sidebar.

This is the same registry idea as the internal ops console, pointed outward at customers. The difference is the chrome. An internal tool can look like macOS; a customer-facing product usually wants its own restrained frame.

  • A SaaS product is already a set of modules. The dock is a switcher that scales as you ship more of them, with no nav-bar redesign each time.
  • Customers run modules side by side: a report open next to the settings that drive it, billing open while you check the team roster. Windows hold that; a single-pane router throws one view away to show the next.
  • “It feels like an app, not a website” is the pitch of most SaaS products. A desktop shell delivers that literally.

The shipped themes clone macOS, Windows, and Ubuntu. A product shell usually wants none of those. It wants its own restrained frame that reads like Linear, Notion, or a Vercel dashboard, and that is a theme you write. It stays small because the structural levers are real chrome tokens, not component branches: put the dock on the left, hide the menu bar, and drop the traffic lights for a single close glyph.

chrome: {
windowControls: "minimal", // a single close glyph
dockPosition: "left", // not the bottom
menuBar: "none", // hidden
}

Because the menu bar is hidden, there is no status tray and no clock in this stance. System-wide widgets that the internal console puts in the menu bar have nowhere to render here; a SaaS shell leans on the dock and on Spotlight instead. Keep chrome.menuBar in the theme’s customizable schema so a customer who wants the bar back can turn it on; see below.

Themes are pure data, so write a small factory and build the active theme per request from the tenant’s config. The one override most tenants care about is the brand accent that tints the dock tiles and the focused-window highlight:

src/shell-theme.ts
import type { OsTheme } from "@react-ui-os/core";
export function createShellTheme({ accent }: { accent: string }): OsTheme {
return {
id: "shell",
name: "Shell",
palette: {
background: "#f6f7f9",
surface: "rgba(255, 255, 255, 0.86)",
textPrimary: "#1a1d23",
textSecondary: "rgba(0, 0, 0, 0.6)",
accent,
border: "rgba(0, 0, 0, 0.08)",
},
shape: { windowRadius: 10, dockTileRadius: 10, small: 6 },
motion: {
windowOpenDurationMs: 160,
windowOpenEasing: "cubic-bezier(0.2, 0.85, 0.25, 1)",
dockHoverDurationMs: 120,
genieDurationMs: 240,
genieEasing: "cubic-bezier(0.4, 0, 0.2, 1)",
},
blur: {
surface: "blur(12px) saturate(120%)",
spotlight: "blur(16px) saturate(120%)",
},
wallpaper: { src: undefined, parallax: false, vignette: false },
chrome: { windowControls: "minimal", dockPosition: "left", menuBar: "none" },
};
}

Then build it per request and mount it. A fresh object per render means nothing leaks between tenants:

src/Shell.tsx
import { Desktop } from "@react-ui-os/desktop";
import { createShellTheme } from "./shell-theme";
import { modulesForPlan } from "./modules";
import type { Tenant } from "./tenant";
export function Shell({ tenant }: { tenant: Tenant }) {
const theme = createShellTheme({ accent: tenant.brandColor });
const apps = modulesForPlan(tenant.plan);
return <Desktop apps={apps} theme={theme} />;
}

brand is omitted on purpose: it labels the menu bar, which this theme hides. modulesForPlan is the same shape as the console’s role map; gate a module behind a plan by leaving its App out of the returned array. See the dynamic app registry recipe.

Add a customizable block to the theme and the Settings system window renders a real panel with no extra work. Let a customer change the accent, the window and dock-tile radius, the window-open speed, the dock position, and whether the menu bar shows:

customizable: {
"palette.accent": { kind: "color-from-palette", section: "Appearance", /* ... */ },
"shape.windowRadius": { kind: "range", section: "Appearance", min: 0, max: 20, step: 2 },
"chrome.dockPosition": { kind: "select", section: "Layout", /* left | bottom | hidden */ },
"chrome.menuBar": { kind: "select", section: "Layout", /* none | top */ },
}

The library persists each choice through the storage adapter and overlays it on the theme to produce the effective theme. With the menu bar hidden, a customer opens Settings with Cmd-, or by finding it in Spotlight (Cmd-K). Trim or extend the schema to control exactly which knobs a tenant sees.