React components for building IDE-style application shells — menu bar, activity bar, sidebar, docking layout, terminal, chat pane and status bar.
Documentation and live examples → · Storybook
npm install nexus-shell zustandimport 'nexus-shell/style.css';React 19 and zustand (4.5 or 5) are peer dependencies, so the shell shares your app's copy of zustand rather than bundling a second one.
Upgrading from 0.2? See CHANGELOG.md for the breaking changes and how to adopt the new APIs.
An application built on this library starts with ShellLayout and grows by
registering features into it.
// main.tsx — a complete application
import { createRoot } from 'react-dom/client';
import { Boxes, Files } from 'lucide-react';
import {
AppTitle,
ShellLayout,
chatPanel,
componentRegistry,
initializeShell,
terminalPanel,
workspace,
} from 'nexus-shell';
import 'nexus-shell/style.css';
// Views register by id, so the shell never has to import them.
componentRegistry.register('editor', Editor);
initializeShell({
panels: [
{ id: 'files', label: 'Explorer', icon: Files, component: FileExplorer },
terminalPanel({ side: 'bottom' }),
// Chat on the right rail, answered by your own backend. Nothing about it
// is bound to one edge — or to being a panel at all.
chatPanel({ side: 'right', onSend: async (text, chat) => chat.reply(await ask(text)) }),
],
commands: [
// `Mod` is Cmd on macOS and Ctrl elsewhere.
{ id: 'file.save', label: 'File: Save', keybinding: 'Mod+S', execute: save },
],
menus: { File: [{ id: 'save', label: 'Save', commandId: 'file.save' }] },
statusBar: [{ id: 'branch', label: 'main', alignment: 'left' }],
});
// Opens the editor, or brings it forward if it is already open.
workspace.open('editor', { title: 'App.tsx' });
createRoot(document.getElementById('root')!).render(
<ShellLayout title={<AppTitle title="Acme Studio" icon={<Boxes size={16} />} />} />,
);That is a working IDE: dockable tabs, a command palette on Cmd/Ctrl+Shift+P,
keyboard shortcuts, dialogs, a terminal, a chat pane, and a themable frame.
ShellLayout mounts the shortcut listener, modal host and palette itself.
The shell has four places a view can live, and nothing is nailed to one of them. Chat and the terminal are ordinary registrations, so they go wherever you put them — including into a dockable tab, alongside your own views.
| Place | How |
|---|---|
| Left rail | panels: [...] |
| Right rail | A panel with side: 'right' |
| Bottom drawer | A panel with side: 'bottom' |
| Dockable tab | componentRegistry.register(id, Component) + workspace.open(id) |
| Bottom of the workspace | A tab in the bottom row of your initialLayoutJson |
Anything hosted in a pane or a tab is told so, and drops its own title bar —
SidebarPane and the tab strip already draw one. Pass chrome explicitly to
force it either way.
You add capability by registering it. None of this changes the layout, which is what lets plugins and distant code contribute to the shell.
| To add | Do this |
|---|---|
| A view or tab | componentRegistry.register(id, Component), then workspace.open(id, { title }) |
| An action or hotkey | commandRegistry.registerCommand({ id, label, keybinding, execute }), or useCommand(...) from a component — appears in the palette automatically |
| A menu entry | menuRegistry.contribute('File', [{ id, label, commandId }]) |
| A sidebar panel | Add it to panels; it becomes an activity bar icon |
| An inspector or properties panel | A panel with side: 'right'; it docks right, with its own rail |
| Chat or a terminal, anywhere | chatPanel({ onSend }) / terminalPanel() with any side, or register the component as a tab |
| A status bar item | useStatusBarStore.getState().addWidget(...) |
| A dialog from anywhere | await useModalStore.getState().openConfirm('Sure?'), or openCustom(close => <Form onSave={close} />, { title }) |
| An unsaved-changes guard | useTab().setDirty(true) inside the tab, or useLayoutStore.getState().setTabDirty(tabId, true) |
workspace is the docking area as an API, usable from anywhere — components,
commands, or code outside React. You never need FlexLayout's model or actions.
import { workspace } from 'nexus-shell';
workspace.open('desk'); // open, or focus the one already open
workspace.open('settings', { config: { section: 'system' } }); // re-open with new parameters
workspace.open('editor', { title: 'a.ts', reuseKey: 'file:a.ts' }); // one tab per document
workspace.open('editor', { reuseKey: null }); // always a new tab
workspace.open('log', { location: 'bottom' }); // split below the active tabset
workspace.find('viewer'); // TabRef[] — id, component, title, config, selected, active, dirty
workspace.snapshot(); // { tabs, active } — what is on screen
await workspace.close(id); // asks first if the tab is dirty
workspace.reset(); // back to initialLayoutJson, saved layout cleared
workspace.onDidActivate((tab) => …);Inside a tab, useTab() is the tab's view of itself:
const Desk = () => {
const input = useRef<HTMLTextAreaElement>(null);
const { config, setTitle, setDirty } = useTab<{ query?: string }>({
// On open, on focus, on click — the place to focus an input.
onActivate: () => input.current?.focus(),
});
…
};Saved layouts evolve with your app. Layouts persist by component id. When you rename a component, alias the old id rather than registering it forever — saved tabs are rewritten on load:
componentRegistry.alias('mail', 'desk', { title: 'Desk' });For bigger changes, version the layout and migrate it:
initializeShell({
layout: {
version: 2,
migrate: (json, fromVersion) => rewrite(json, fromVersion),
onUnknownComponent: 'drop', // or 'placeholder' (default), or (id) => <Gone id={id} />
},
});initialLayoutJson on ShellLayout seeds a first run only; a layout the user
has arranged is restored instead.
Every component the shell is assembled from is exported on its own — pure and
prop-driven, with no global state or registry lookups. This is the escape hatch:
reach for it when you're embedding one piece into an app you already have, or
building a frame ShellLayout can't express.
import { ActivityBar, SidebarPane, StatusBar, TreeWidget } from 'nexus-shell';
const [active, setActive] = useState<string | null>('files');
<div className="theme-dark flex h-screen flex-col">
<div className="flex flex-1">
<ActivityBar
items={panels}
activeId={active}
onSelect={(id) => setActive(id === active ? null : id)}
/>
{active && (
<SidebarPane title="Explorer" onClose={() => setActive(null)}>
<TreeWidget data={nodes} onToggle={toggle} onActivate={open} />
</SidebarPane>
)}
<main className="flex-1">{children}</main>
</div>
<StatusBar widgets={[{ id: 'branch', label: 'main', alignment: 'left' }]} />
</div>;These are controlled, so state stays in your app — a real cost the shell was absorbing for you.
Between the two sit the Connected* variants, each binding one component to its
store. ShellLayout is built entirely out of them, so you can rearrange the
shell's own frame and keep every registration working.
ShellLayout assembles all of these. Each is also exported on its own.
| Component | What it is |
|---|---|
ActivityBar |
Vertical icon rail for switching side panels, either edge |
ChatPane |
Chat transcript with slash-command autocomplete; fills any host |
CommandPalette |
Filterable, keyboard-navigable command list |
ContextMenu |
Floating menu positioned at viewport coordinates |
DataGrid |
Sortable, filterable, keyboard-driven table: multi-select, preview vs. open, server-side sort and paging, expandable rows, virtualisation |
MenuBar |
Application menu bar with submenus and content slots |
Modal |
Alert / confirm / prompt dialog, or any content, with a focus trap |
QuickSearch |
Compact search field with grouped results, for the menu bar |
SearchWidget |
Full-height search panel with autocomplete, for the sidebar |
SettingsPanel |
Theme picker body for the settings sidebar |
SidebarPane |
Titled, closable side panel; side docks it left or right |
StatusBar |
Footer with left / center / right item groups |
TerminalPane |
Terminal log and input; fills any host |
PaneHostProvider |
Tells hosted components a title bar is already drawn |
ThemeSwitcher |
Segmented theme control |
TreeWidget |
Virtualised tree for any hierarchy, with drag-to-move and a data-driven menu |
GraphCanvas |
Infinite pannable, zoomable field for node-and-edge editing |
GraphNode |
Positioned, focusable node with edge ports |
GraphEdge |
Directed edge with bezier / smoothstep / straight routing |
GraphMiniMap |
Scaled graph overview with a viewport indicator |
NodePalette |
Drag-to-create and keyboard-to-create node types |
useGraphKeyboard |
Spatial keyboard navigation and editing for a graph |
useGraphLayout |
Auto layout engines, with a freeform escape for hand placement |
layeredLayout |
Layered tree layout in four directions; handles cycles |
gridLayout |
Uniform grid, ignoring edges |
PropertyPanel |
Inspector for the current selection, with mixed-value editing |
TextField … StaticField |
Nine composable property fields, usable on their own |
FieldShell |
The label / description / error frame, for fields of your own |
UserProfile |
Avatar and identity widget |
Everything in style.css lives in nexus.* cascade layers, so your styles win
without specificity fights — unlayered CSS beats every layer, and your own
layers win as long as nexus is declared first. With Tailwind v4:
/* your entry stylesheet */
@layer nexus, theme, base, components, utilities;
@import 'nexus-shell/style.css' layer(nexus);
@import 'tailwindcss';Or import nexus-shell/style.css from JavaScript before your own CSS.
FlexLayout's stylesheet is bundled inside style.css; the library's JavaScript
imports no CSS, so test runners need no special configuration.
nexus-shell/style.no-reset.css is the same stylesheet without Tailwind's
preflight, for apps that bring their own reset.
Four themes ship in the stylesheet: theme-light, theme-dark, theme-gt
(Georgia Tech) and theme-tamu (Texas A&M).
Colors are CSS custom properties scoped to those classes, so switching themes is
a class on an ancestor — no React context, no provider.
Add your own by defining a .theme-yourname block with the same properties,
and register it so pickers, commands and scheme know about it:
registerTheme({ id: 'midnight', label: 'Midnight', scheme: 'dark' });For code that draws outside CSS — charts, canvas — read the resolved colors:
const t = useThemeTokens(); // { scheme, background, muted, mutedForeground, primary, … }
<ResponsiveCalendar emptyColor={t.muted} theme={{ text: { fill: t.mutedForeground } }} />Every corner radius derives from --radius, and pills and avatars from
--radius-pill. For a square UI:
:root { --radius: 0; --radius-pill: 0; }The shell's state lives in module-level stores, so reset it between tests and drive a real, headless workspace rather than mocking FlexLayout:
import { resetShell, createTestWorkspace } from 'nexus-shell/testing';
beforeEach(() => resetShell());
it('opens Desk once', () => {
const ws = createTestWorkspace({ components: { desk: Desk } });
openDesk();
openDesk();
expect(ws.find('desk')).toHaveLength(1);
});npm install
npm run site # documentation site at :5173
npm run storybook # component workbench at :6006
npm run dev # showcase app at :5173
npm run typecheck # library sources
npm test # vitest — unit and architecture tests
npm run site:test # renders every site route and demo
npm run build # library build to dist/
npm run build-storybook
npm run site:build # documentation site to website/dist/
npm run e2e # Playwright against the showcase appnpm run typecheck covers src/ only. To include the examples:
npx tsc --noEmit -p tsconfig.examples.jsonCI runs all of the above on every push and pull request.
src/
components/ Pure, prop-driven components — what the shell is made of
connected/ Store-bound wrappers used by ShellLayout
core/ Stores (zustand), registries, and initializeShell
lib/ Shared utilities
stories/ MDX documentation pages
examples/
showcase/ Demo apps built on the library — not published
dialogue-mapper/
mockup-reviewer/
wiki/
wargame/
e2e/ Playwright specs for the showcase app
basic-app/ Minimal integration example
website/
src/demos/ Live examples — the source shown on the site
src/content/ The component gallery, as data
scripts/ Generates the props tables from the library's types
website/ is a Vite app that imports the library from ../src, not from
dist. Every example on the site is rendered by the current source, so a demo
that no longer compiles against the real API fails the build rather than
quietly documenting an API that no longer exists.
Two generation steps keep it honest:
- Props tables come from
website/scripts/generate-api.mjs, which parses the component sources for prop names, types, defaults and JSDoc. Nothing is hand-written. - Code samples are extracted from the demo files by
#regionmarker, so the snippet a visitor copies is provably the code that produced the thing they just interacted with.
Adding a component to the site is one entry in website/src/content/components.ts
plus a demo file. There is no per-component page to keep in sync.
The site ships no dependencies of its own — routing, syntax highlighting and the copy button are all local, so the docs can't drag the repo's install surface around.
Deployment. .github/workflows/deploy-pages.yml builds the site and
Storybook on every push to main and publishes them as one Pages artifact — the
site at the root, Storybook under /storybook/. First-time setup needs your
credentials: run ./scripts/setup-pages.sh.
The split between components/ and connected/ is the load-bearing one. A
component that reads a global store can't be configured by its caller, rendered
twice with different data, or documented — its props table is empty because it
has no props. Everything in components/ is therefore forbidden from importing
core/services, and the tests in components/__tests__ exist to keep it that
way.
Anything under examples/ is a consumer of the library, not part of it. It's
built and documented alongside the components to prove the primitives are
sufficient, but it ships to nobody.
MIT