@vinyasa/patterns
v2.0.3
Published
8 composite page-and-workspace patterns built on top of the rest of `@vinyasa/*`: AppShell, SplitView, InspectorPanel, MultiPanelLayout, DashboardLayout, SettingsLayout, AnalyticsWorkspace, AIWorkspace.
Downloads
584
Readme
@vinyasa/patterns
8 composite page-and-workspace patterns built on top of the rest of @vinyasa/*: AppShell, SplitView, InspectorPanel, MultiPanelLayout, DashboardLayout, SettingsLayout, AnalyticsWorkspace, AIWorkspace.
Installation
pnpm add @vinyasa/patterns @vinyasa/form @vinyasa/icons @vinyasa/layout @vinyasa/navigation @vinyasa/overlay @vinyasa/tokens @vinyasa/typography react react-domUnlike most @vinyasa/* packages, this one composes several sibling packages directly rather than just referencing the token contract: @vinyasa/layout (Box/Flex/Grid, in every component), @vinyasa/typography (Heading/Text, for titles and section headers), @vinyasa/form (IconButton) and @vinyasa/icons (Menu/X icons, for AppShell's mobile trigger and InspectorPanel's close button), and @vinyasa/overlay (Sheet, the mobile drawer AppShell renders below its breakpoint). @vinyasa/navigation is also a peer, but for a slot-fill reason rather than a direct import: AppShell's sidebar/header props are typically filled with its Sidebar/TopNavigation, though any ReactNode works. @vinyasa/ai-native is not a peer dependency — AIWorkspace's composer slot is a plain ReactNode, and any composer works; ai-native's PromptEditor is just what the Storybook demo happens to use. Rendering any of these requires a VinyasaProvider (from @vinyasa/tokens) above them in the tree.
No separate stylesheet import is needed — CSS is bundled into each component's own JS entry, with one exception noted under Subpath imports below.
This package has no root export — every component is subpath-only (import { AppShell } from '@vinyasa/patterns/app-shell', never from '@vinyasa/patterns'). A root barrel re-exporting all 8 components would let a bundler tree-shake the unused JS down to just the ones you import, but the CSS side-effect imports the others carry are not eligible for the same tree-shaking (confirmed empirically with both esbuild and Rollup on this monorepo's other packages). Removing the root entry entirely makes that the only possible outcome, not something that depends on your bundler being clever enough to shake it out.
Usage
AppShell
The top-level page frame every screen renders inside: a persistent sidebar rail, a header row, and a content area. Below mobileBreakpoint, the sidebar moves into a Sheet drawer instead of disappearing.
import { AppShell } from '@vinyasa/patterns/app-shell';
import { Sidebar } from '@vinyasa/navigation/sidebar';
import { TopNavigation } from '@vinyasa/navigation/top-navigation';
function App() {
return (
<AppShell
sidebar={<Sidebar header="Acme Cloud" sections={sidebarSections} />}
header={<TopNavigation items={navItems} />}
>
<DashboardPage />
</AppShell>
);
}| Prop | Type | Default | Description |
| -------------------- | -------------------------- | -------------- | -------------------------------------------------------------------------------------------- |
| sidebar | ReactNode | — | Required. Persistent nav rail; rendered a second, independent time inside the mobile drawer. |
| header | ReactNode | — | Rendered across the top of the content area, alongside AppShell's own mobile menu trigger. |
| children | ReactNode | — | Required. Page content. |
| footer | ReactNode | — | Optional persistent footer region below content. |
| mobileBreakpoint | keyof typeof breakpoints | 'md' | Below this, sidebar moves into the mobile drawer. |
| mobileSidebarLabel | string | 'Navigation' | Accessible name for the drawer dialog and its trigger button. |
SplitView
A two-pane resizable layout — a fixed-size primary pane and a flexible secondary remainder, divided by a drag handle. Use it for master/detail shapes: an inbox list beside a reading pane, an editor beside a console.
import { SplitView } from '@vinyasa/patterns/split-view';
<SplitView
primary={<MessageList />}
secondary={<MessageDetail />}
resizeHandleLabel="Resize message list"
/>;| Prop | Type | Default | Description |
| --------------------- | ---------------------------- | -------------- | ------------------------------------------------------------------------------------------------ |
| primary | ReactNode | — | Required. The fixed-size pane. |
| secondary | ReactNode | — | Required. The remaining-space pane. |
| primarySize | number | uncontrolled | Controlled width/height (px) of primary. |
| defaultPrimarySize | number | 280 | Initial size when uncontrolled. |
| onPrimarySizeChange | (size: number) => void | — | Fires on resize. |
| minPrimarySize | number | 160 | Resize floor. |
| maxPrimarySize | number | 560 | Resize ceiling. |
| orientation | 'horizontal' \| 'vertical' | 'horizontal' | 'horizontal': primary left, dragged left-right. 'vertical': primary on top, dragged up-down. |
<SplitView
orientation="vertical"
defaultPrimarySize={360}
primary={<Editor />}
secondary={<Console />}
/>InspectorPanel
A single toggleable, resizable auxiliary panel (properties inspector, contextual details) docked to either edge of a layout. Renders nothing at all when closed — no collapsed rail. It owns no trigger of its own; wire a button to open/onOpenChange.
import { InspectorPanel } from '@vinyasa/patterns/inspector-panel';
import Flex from '@vinyasa/layout/flex';
<Flex style={{ height: '100vh' }}>
<Canvas />
<InspectorPanel open={open} onOpenChange={setOpen} title={selectedLayer?.name ?? 'No selection'}>
<LayerProperties layer={selectedLayer} />
</InspectorPanel>
</Flex>;| Prop | Type | Default | Description |
| ------------------- | ------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------ |
| open | boolean | uncontrolled | Controlled open state. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Fires on open/close (including the built-in close button). |
| title | ReactNode | — | Panel header title. |
| children | ReactNode | — | Required. Panel body. |
| size | number | uncontrolled | Controlled width (px). |
| defaultSize | number | 320 | Initial width. |
| minSize/maxSize | number | 240/480 | Resize bounds. |
| side | 'left' \| 'right' | 'right' | Which edge of the panel the drag handle sits on — place the returned pair on the matching side of your own layout. |
MultiPanelLayout
Generalizes SplitView to three-or-more independently resizable panes in a row, each handle transferring width only between its two immediate neighbors — the shape of a code editor's file-tree/editor/output layout.
import { MultiPanelLayout } from '@vinyasa/patterns/multi-panel-layout';
<MultiPanelLayout
panels={[
{ key: 'files', content: <FileExplorer />, defaultWidth: 220, minWidth: 160, maxWidth: 360 },
{ key: 'editor', content: <Editor />, defaultWidth: 680, minWidth: 320 },
{ key: 'output', content: <Output />, defaultWidth: 320, minWidth: 200, maxWidth: 480 },
]}
/>;| Prop | Type | Default | Description |
| ------------------- | ------------------------- | ----------------- | ---------------------------------------------------------- |
| panels | MultiPanelLayoutPanel[] | — | Required. 3+ panes — for 2 panes, use SplitView instead. |
| resizeHandleLabel | string | 'Resize panels' | Accessible label applied to every handle. |
Each MultiPanelLayoutPanel: { key; content; defaultWidth?: 240; minWidth?: 120; maxWidth?: 640 }. Keyboard on each handle: Arrow Left/Right nudge by 16px, Home/End jump to that pane's min/max.
DashboardLayout
The common workspace-overview page shape — a title/description/actions header above a responsive widget grid — meant to sit inside AppShell's content slot.
import { DashboardLayout } from '@vinyasa/patterns/dashboard-layout';
import Button from '@vinyasa/button/button';
<DashboardLayout
title="Overview"
description="Revenue and subscription metrics across all workspaces."
actions={
<Button variant="primary" size="sm">
New report
</Button>
}
>
<StatCard label="MRR" value="$48,200" change="+8.2%" />
<StatCard label="Active subscriptions" value="1,284" change="+34 this month" />
</DashboardLayout>;| Prop | Type | Default | Description |
| ------------- | ---------------------- | ---------------------------------------- | -------------------------------------------- |
| title | ReactNode | — | Required. Page title. |
| description | ReactNode | — | Optional subtitle under the title. |
| actions | ReactNode | — | Rendered as a gapped row. |
| children | ReactNode | — | Required. The widget grid's content. |
| columns | GridProps['columns'] | 'repeat(auto-fit, minmax(240px, 1fr))' | Column count or raw grid-template-columns. |
| gap | GridProps['gap'] | 4 | Grid gap. |
SettingsLayout
The common settings-page shape — a fixed-width nav pane beside the active section's content, stacking above content below the md breakpoint via a plain CSS reflow (no drawer, unlike AppShell).
import { SettingsLayout } from '@vinyasa/patterns/settings-layout';
<SettingsLayout
title="Settings"
nav={<SettingsNav activeKey={activeKey} onSelect={setActiveKey} />}
>
<ProfileSection />
</SettingsLayout>;| Prop | Type | Default | Description |
| ---------- | ----------- | ------- | ---------------------------------------------------------------------------------------- |
| title | ReactNode | — | Optional page heading; omit when one already lives elsewhere (e.g. AppShell's header). |
| nav | ReactNode | — | Required. Fixed-width settings nav. |
| children | ReactNode | — | Required. Active section's content. |
AnalyticsWorkspace
The common analytics-page shape — a filter/toolbar row above main chart/table content, with an optional resizable side panel built on this package's own InspectorPanel. Owns no trigger; wire a button in filters to flip sidePanelOpen.
import { AnalyticsWorkspace } from '@vinyasa/patterns/analytics-workspace';
import Flex from '@vinyasa/layout/flex';
import Button from '@vinyasa/button/button';
<AnalyticsWorkspace
filters={
<Flex justify="between">
<RangePicker />
<Button size="sm">Export</Button>
</Flex>
}
sidePanel={selectedDay ? <BreakdownPanel day={selectedDay} /> : null}
sidePanelTitle={selectedDay ? `${selectedDay} · by source` : undefined}
sidePanelOpen={panelOpen}
onSidePanelOpenChange={setPanelOpen}
>
<TrafficChart
selectedDay={selectedDay}
onSelectDay={(day) => {
setSelectedDay(day);
setPanelOpen(true);
}}
/>
</AnalyticsWorkspace>;| Prop | Type | Default | Description |
| ----------------------- | ------------------------- | ------------ | ------------------------------------------------------- |
| filters | ReactNode | — | Required. Toolbar row. |
| children | ReactNode | — | Required. Main content — chart or table. |
| sidePanel | ReactNode | — | Optional side panel content; omit to skip rendering it. |
| sidePanelTitle | ReactNode | — | Side panel header title. |
| sidePanelOpen | boolean | uncontrolled | Controlled open state. |
| onSidePanelOpenChange | (open: boolean) => void | — | Fires on open/close. |
AIWorkspace
The common chat-app shape — a scrollable message thread with a composer docked at the bottom, plus an optional side panel (conversation history, cited sources) built on InspectorPanel, same pattern as AnalyticsWorkspace.
import { AIWorkspace } from '@vinyasa/patterns/ai-workspace';
import { PromptEditor } from '@vinyasa/ai-native/prompt-editor'; // any composer works — ai-native is not a peer dependency
import Button from '@vinyasa/button/button';
<AIWorkspace
sidePanel={<SourcesPanel sources={sources} />}
sidePanelTitle="Sources"
sidePanelOpen={panelOpen}
onSidePanelOpenChange={setPanelOpen}
composer={
<PromptEditor
value={draft}
onValueChange={setDraft}
onSubmit={handleSend}
leading={
<Button variant="outline" size="sm" onClick={() => setPanelOpen(!panelOpen)}>
Sources
</Button>
}
trailing={
<Button variant="primary" size="sm" disabled={!draft.trim()} onClick={handleSend}>
Send
</Button>
}
/>
}
>
{conversation.map((message, i) => (
<MessageBubble key={i} message={message} />
))}
</AIWorkspace>;| Prop | Type | Default | Description |
| ----------------------- | ------------------------- | ------------ | ------------------------------------------------------- |
| children | ReactNode | — | Required. Conversation thread; scrolls independently. |
| composer | ReactNode | — | Required. Message input, docked at the bottom. |
| sidePanel | ReactNode | — | Optional side panel content; omit to skip rendering it. |
| sidePanelTitle | ReactNode | — | Side panel header title. |
| sidePanelOpen | boolean | uncontrolled | Controlled open state. |
| onSidePanelOpenChange | (open: boolean) => void | — | Fires on open/close. |
Other exports
Beyond the 8 components above, two hooks get their own subpath too, since they're useful on their own outside any one component: useMediaQuery (@vinyasa/patterns/use-media-query) and useBreakpoint (@vinyasa/patterns/use-breakpoint). ResizeHandle/useResizablePanel — the drag-resize primitive underlying SplitView, InspectorPanel, and MultiPanelLayout — stay internal-only as of this version: rspack can't build the same module as both a shared internal chunk and its own standalone entry, and every consumer-facing use of resizing already goes through those three components' own props anyway.
Subpath imports
Every component is imported by its own subpath (e.g. @vinyasa/patterns/app-shell) — there is no root @vinyasa/patterns entry, so import { X } from '@vinyasa/patterns' fails to resolve. See "This package has no root export" above for why.
dashboard-layout has no separate stylesheet — it's pure composition of @vinyasa/layout/@vinyasa/typography primitives with no vanilla-extract styles of its own, so there's nothing to bundle beyond what those packages already ship.
Development
From the repository root:
pnpm --filter @vinyasa/patterns build
pnpm --filter @vinyasa/patterns test
pnpm --filter @vinyasa/patterns lint
pnpm storybook # Patterns/<ComponentName>