@divami-labs/experiential-ui
v1.0.2
Published
AI-powered dynamic dashboard renderer for React — renders DashboardSpec JSON into fully interactive charts, tables, cards and lists with cross-widget selection, drilldowns and theming.
Maintainers
Readme
@divami-labs/experiential-ui
A React component library that turns a JSON spec into a fully interactive dashboard — charts, tables, cards, and lists — with cross-widget selection, drilldown navigation, and theming built in.
How it works
Your data / user context
│
▼
[Backend AI agent] ──generates──▶ DashboardSpec JSON
│
▼
<DashboardRenderer data={spec} />
│
▼
Interactive dashboard UI- Backend agent (
divami-labs-experiential-ui-agent) — an optional Python package that takes your raw data and a user role, then uses an LLM to produce theDashboardSpecJSON automatically. See Generating JSON with the AI agent. - Frontend renderer (
@divami-labs/experiential-ui) — this package. Pass any validDashboardSpecJSON and it renders the dashboard. You can also hand-write the JSON yourself without the agent.
Table of contents
- Installation
- Quick start
- Generating JSON with the AI agent
- DashboardRenderer props
- Standalone Chart component
- DashboardSpec schema
- Cross-widget interactivity
- Drilldown navigation
- Theming
- TypeScript types
- Build and publish
Installation
# npm
npm install @divami-labs/experiential-ui react-plotly.js plotly.js
# yarn
yarn add @divami-labs/experiential-ui react-plotly.js plotly.js
# pnpm
pnpm add @divami-labs/experiential-ui react-plotly.js plotly.jsPeer dependencies — install alongside the package:
| Package | Version |
|---------|---------|
| react | ≥ 18 |
| react-dom | ≥ 18 |
| plotly.js | ≥ 2 |
| react-plotly.js | ≥ 2 |
Quick start
import { DashboardRenderer } from '@divami-labs/experiential-ui';
import '@divami-labs/experiential-ui/styles';
import type { DashboardSpec } from '@divami-labs/experiential-ui';
const spec: DashboardSpec = {
title: 'Sales Overview',
subtitle: 'Q1 2026 · AI-generated',
sections: [
{
cols: 3,
widgets: [
{
type: 'card',
title: 'Revenue',
metrics: [
{ label: 'MRR', value: '$128K', trend: 'up', change: '+18%' },
{ label: 'Churn', value: '2.1%', trend: 'up', change: '-0.4%' },
],
},
{
type: 'chart',
title: 'Monthly Revenue',
chartType: 'bar',
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
values: [92, 97, 104, 112, 121, 128],
unit: 'K',
},
{
type: 'list',
title: 'Top Deals',
style: 'kv',
items: [
{ label: 'Acme Corp', value: '$48,000' },
{ label: 'Globex Inc', value: '$32,500' },
],
},
],
},
],
};
export default function App() {
return <DashboardRenderer data={spec} />;
}CSS — always import
'@divami-labs/experiential-ui/styles'once in your app entry point. Without it, no design tokens or component styles will apply.
Generating JSON with the AI agent
Instead of writing the DashboardSpec JSON by hand, you can use the companion Python package divami-labs-experiential-ui-agent. It takes your raw data and a user role, calls an LLM, and returns a ready-to-render DashboardSpec — no manual JSON authoring needed.
The agent is built on pydantic-ai, so the model parameter accepts any pydantic-ai model string — bring your own provider (Google, OpenAI, Anthropic, Mistral, and more). See the full list of supported model strings in the pydantic-ai models documentation.
Requires Python ≥ 3.11.
Install the backend package
# Google Gemini
pip install "divami-labs-experiential-ui-agent[google]"
# OpenAI
pip install "divami-labs-experiential-ui-agent[openai]"
# Both providers + Logfire observability
pip install "divami-labs-experiential-ui-agent[all]"Set the matching API key in your environment:
| Provider | pydantic-ai model string | Env var |
|----------|--------------------------|---------|
| Google Gemini | google-gla:gemini-2.0-flash | GOOGLE_API_KEY |
| OpenAI | openai:gpt-4o | OPENAI_API_KEY |
| Logfire observability | — | LOGFIRE_TOKEN |
Generate a dashboard spec
from dynamic_ui import generate_dashboard, DashboardSpec
# The `model` string follows pydantic-ai conventions: "<provider>:<model-name>"
# Full list: https://ai.pydantic.dev/models/
spec: DashboardSpec = await generate_dashboard(
model="google-gla:gemini-2.0-flash", # or "openai:gpt-4o", "anthropic:claude-3-5-sonnet", etc.
context={
"sales": [...], # your raw data
"emails": [...],
},
user_role="VP of Sales",
user_persona="Prefers high-level KPI cards before detailed charts.",
user_prompt="What are my top priorities today?", # optional
)
# spec.model_dump() returns a plain dict — pass it straight to the frontend
payload = spec.model_dump()Serve it from a FastAPI endpoint
from fastapi import FastAPI
from dynamic_ui import generate_dashboard
app = FastAPI()
@app.post("/dashboard")
async def create_dashboard(role: str, context: dict) -> dict:
spec = await generate_dashboard(
model="openai:gpt-4o", # any pydantic-ai model string
context=context,
user_role=role,
)
return spec.model_dump()Then fetch and render on the frontend:
const res = await fetch('/dashboard', { method: 'POST', body: JSON.stringify({ role, context }) });
const spec = await res.json();
<DashboardRenderer data={spec} />Backend README — full documentation for the Python package lives in
backend/package/README.md.
DashboardRenderer props
<DashboardRenderer
data={spec} // required
theme={myTheme} // optional — Partial<Theme>
rootClassName="..." // optional — extra class(es) on the root wrapper
onResolve={fn} // optional — async page resolver for drilldowns
/>| Prop | Type | Default | Description |
|------|------|---------|-------------|
| data | DashboardSpec | — | Required. Dashboard JSON to render. |
| theme | Partial<Theme> | dark theme | Override any theme tokens or component styles. |
| rootClassName | string | '' | Extra class names on the .dui-root wrapper. |
| onResolve | PageResolver | — | Loads a DashboardSpec by pageId for drilldown navigation. |
Standalone Chart component
Use Chart and ChartProvider independently of DashboardRenderer — embed charts anywhere in your app without a full dashboard layout.
| Scenario | What to use |
|---|---|
| Single chart, no cross-chart interaction | <Chart> directly |
| Multiple charts that interact with each other | <ChartProvider> wrapping them |
| Full dashboard layout | <DashboardRenderer> |
Example 1 — without interaction
A single <Chart> with no providers. It renders a static chart; clicking has no effect on other components.
import { Chart } from '@divami-labs/experiential-ui';
import '@divami-labs/experiential-ui/styles';
import type { ChartWidget } from '@divami-labs/experiential-ui';
const revenueChart: ChartWidget = {
type: 'chart',
title: 'Monthly Revenue',
chartType: 'bar',
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
values: [92, 97, 104, 112, 121, 128],
unit: 'K',
};
export default function RevenueWidget() {
return (
<div style={{ width: '100%', height: 320 }}>
<Chart widget={revenueChart} />
</div>
);
}No context or provider required. Drop it anywhere.
Example 2 — with interaction
Wrap multiple <Chart> components in a single <ChartProvider>. Charts linked by matching broadcastOn / listenTo keys will react to each other when clicked — the same mechanism that powers cross-widget selection inside a dashboard.
import { ChartProvider, Chart } from '@divami-labs/experiential-ui';
import '@divami-labs/experiential-ui/styles';
import type { ChartWidget } from '@divami-labs/experiential-ui';
// Clicking a bar on this chart broadcasts the selected month
const revenueByMonth: ChartWidget = {
type: 'chart',
title: 'Revenue by Month',
chartType: 'bar',
broadcastOn: 'month-select',
dataset: [
{ month: 'Jan', mrr: 92, starter: 40, pro: 35, enterprise: 17 },
{ month: 'Feb', mrr: 97, starter: 38, pro: 37, enterprise: 22 },
{ month: 'Mar', mrr: 104, starter: 36, pro: 38, enterprise: 30 },
{ month: 'Apr', mrr: 112, starter: 34, pro: 40, enterprise: 38 },
{ month: 'May', mrr: 121, starter: 32, pro: 42, enterprise: 47 },
{ month: 'Jun', mrr: 128, starter: 30, pro: 43, enterprise: 55 },
],
labelField: 'month',
valueField: 'mrr',
unit: 'K',
};
// This donut highlights the slice for the selected month
const planMix: ChartWidget = {
type: 'chart',
title: 'Plan Mix',
chartType: 'donut',
listenTo: 'month-select',
reactionMode: 'highlight',
dataset: revenueByMonth.dataset,
labelField: 'month',
series: [
{ name: 'Starter', field: 'starter' },
{ name: 'Pro', field: 'pro' },
{ name: 'Enterprise', field: 'enterprise' },
],
};
export default function RevenueBreakdown() {
return (
// All charts inside one ChartProvider share the same selection state
<ChartProvider>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div style={{ height: 320 }}>
<Chart widget={revenueByMonth} />
</div>
<div style={{ height: 320 }}>
<Chart widget={planMix} />
</div>
</div>
</ChartProvider>
);
}Click any bar in the left chart — the donut on the right highlights the matching month's slice. Click the same bar again to deselect.
Isolation — each
<ChartProvider>instance has its own selection state. Charts in separate providers never affect each other.
Chart props:
| Prop | Type | Description |
|------|------|-------------|
| widget | ChartWidget | Full chart widget definition. |
| style | React.CSSProperties | Optional inline styles on the container. |
ChartProvider props:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| children | ReactNode | — | Required. Chart components to render. |
| theme | Partial<Theme> | dark theme | Override any theme tokens or component styles. |
| className | string | '' | Extra class names on the .dui-root wrapper. |
| style | React.CSSProperties | — | Inline styles on the .dui-root wrapper. |
| onResolve | PageResolver | — | Async resolver for widget drilldown actions (OPEN_MODAL / OPEN_OVERLAY / OPEN_PAGE). Only needed if charts have action fields. |
DashboardSpec schema
interface DashboardSpec {
title?: string;
subtitle?: string;
sections: DashboardRow[];
}
interface DashboardRow {
title?: string; // Section label
cols?: number; // Grid column count (default: widget count)
rows?: number; // Explicit row count for spanning widgets
widgets: Widget[]; // card | chart | table | list
}Every widget supports two layout properties:
| Property | Type | Description |
|----------|------|-------------|
| colSpan | number | Columns this widget spans (default 1). |
| rowSpan | number | Rows this widget spans (default 1). |
CardWidget
interface CardWidget {
type: 'card';
id?: string;
title?: string;
content?: string;
contentField?: string;
bullets?: string[];
metrics?: Metric[];
listenTo?: string;
dataset?: DataRecord[];
fieldMap?: Record<string, string>; // { "Display Label": "datasetField" }
colSpan?: number;
rowSpan?: number;
action?: WidgetAction;
}
interface Metric {
label: string;
value: string | number;
unit?: string;
trend?: 'up' | 'down' | 'neutral';
change?: string;
}// Static KPI tiles
{ type: 'card', title: 'Revenue',
metrics: [{ label: 'MRR', value: '$128K', trend: 'up', change: '+18%' }] }
// Prose + bullets
{ type: 'card', title: 'Summary',
content: 'Revenue grew 18% QoQ...',
bullets: ['Enterprise ARR crossed $10M', 'Churn at all-time low'] }
// Dataset-driven — swaps metric values when a chart bar is clicked
{ type: 'card', title: 'Month Detail',
listenTo: 'month-select',
dataset: [{ month: 'Jan', mrr: 92000, nps: 42 }, ...],
fieldMap: { 'MRR': 'mrr', 'NPS': 'nps' },
metrics: [{ label: 'MRR', value: '—' }, { label: 'NPS', value: '—' }] }ChartWidget
interface ChartWidget {
type: 'chart';
id?: string;
title?: string;
chartType: ChartType;
// Static single-series
labels?: string[];
values?: number[];
totals?: boolean[];
colors?: string[];
// Static or dataset-driven multi-series
series?: SeriesConfig[];
// Dataset-driven
dataset?: DataRecord[];
labelField?: string;
valueField?: string;
unit?: string;
// Interactivity
broadcastOn?: string;
listenTo?: string;
reactionMode?: 'highlight' | 'filter';
// CTA insert
insight?: string;
ctaLabel?: string;
ctaHref?: string;
colSpan?: number;
rowSpan?: number;
action?: WidgetAction;
}
interface SeriesConfig {
name: string;
values?: number[];
field?: string;
color?: string;
}Supported ChartType values:
| Value | Renders |
|-------|---------|
| bar | Vertical bar chart |
| bar_horizontal | Horizontal bar chart |
| line | Line / area chart |
| waterfall | Waterfall bridge chart |
| stacked_bar | Stacked bar (requires series) |
| grouped_bar | Grouped bar (requires series) |
| pie | Pie chart |
| donut | Donut chart |
| scatter | Scatter plot |
| funnel | Funnel chart |
// Static bar
{ type: 'chart', chartType: 'bar',
labels: ['Jan','Feb','Mar'], values: [92, 97, 104], unit: 'K' }
// Dataset-driven multi-series line
{ type: 'chart', chartType: 'line',
dataset: [{ month: 'Jan', actual: 92, target: 100 }, ...],
labelField: 'month',
series: [
{ name: 'Actual', field: 'actual', color: '#6366f1' },
{ name: 'Target', field: 'target', color: '#94a3b8' },
] }
// Broadcasts selection on click
{ type: 'chart', chartType: 'bar_horizontal',
broadcastOn: 'region-select',
dataset: [{ region: 'APAC', revenue: 780000 }, ...],
labelField: 'region', valueField: 'revenue' }TableWidget
interface TableWidget {
type: 'table';
id?: string;
title?: string;
columns: string[];
rows?: (string | number | boolean | null)[][];
dataset?: DataRecord[];
rowFields?: string[];
listenTo?: string;
reactionMode?: 'highlight' | 'filter';
colSpan?: number;
rowSpan?: number;
action?: WidgetAction;
}// Static rows
{ type: 'table', columns: ['Account', 'ARR', 'Status'],
rows: [['Acme Corp', '$420K', 'Closed Won']] }
// Dataset-driven, filters to the selected row on chart click
{ type: 'table', listenTo: 'deal-select', reactionMode: 'filter',
dataset: [...], rowFields: ['account', 'arr', 'status'],
columns: ['Account', 'ARR', 'Status'] }ListWidget
interface ListWidget {
type: 'list';
id?: string;
title?: string;
style?: 'bullet' | 'numbered' | 'kv' | 'badge';
items: ListItem[];
colSpan?: number;
rowSpan?: number;
action?: WidgetAction;
}
interface ListItem {
label: string;
value?: string | number;
badge?: string;
badgeColor?: 'green' | 'red' | 'yellow' | 'blue' | 'gray';
description?: string;
href?: string;
}| style | Renders |
|---------|---------|
| bullet (default) | Arrow bullet list |
| numbered | Numbered list |
| kv | Key → Value pairs |
| badge | Label with coloured badge pill |
Cross-widget interactivity
Link any chart, table, or card using string keys — no extra wiring needed. Works identically inside <DashboardRenderer> and <ChartProvider>.
chart (broadcastOn: "key") ──click──▶ table (listenTo: "key")
──click──▶ card (listenTo: "key")
──click──▶ chart (listenTo: "key")// 1. Chart emits "month-select" when a bar is clicked
{
type: 'chart', chartType: 'bar',
broadcastOn: 'month-select',
dataset: [...], labelField: 'month', valueField: 'mrr',
}
// 2. Card swaps its metrics when a month is selected
{
type: 'card', title: 'Month Snapshot',
listenTo: 'month-select',
dataset: [...],
fieldMap: { 'MRR': 'mrr', 'NPS': 'nps' },
metrics: [{ label: 'MRR', value: '—' }, { label: 'NPS', value: '—' }],
}
// 3. Table highlights the matching row
{
type: 'table', listenTo: 'month-select', reactionMode: 'highlight',
dataset: [...], rowFields: ['month', 'mrr', 'nps'],
columns: ['Month', 'MRR', 'NPS'],
}Drilldown navigation
Add an action to any widget to open a modal, side panel, or new tab on click.
type WidgetAction =
| { action: 'OPEN_MODAL'; target: NavigationTarget; width?: string; height?: string }
| { action: 'OPEN_OVERLAY'; target: NavigationTarget; width?: string }
| { action: 'OPEN_PAGE'; target: NavigationTarget }
interface NavigationTarget {
spec?: DashboardSpec; // Inline drilldown spec
pageId?: string; // Loaded via onResolve
title?: string;
params?: Record<string, string | number | boolean>;
}// Modal with an inline drilldown dashboard
{
type: 'card', title: 'Revenue',
metrics: [...],
action: {
action: 'OPEN_MODAL', width: '800px',
target: {
title: 'Revenue Deep Dive',
spec: { sections: [{ widgets: [{ type: 'chart', chartType: 'line', ... }] }] },
},
},
}
// Side panel loaded dynamically via onResolve
{
type: 'table', title: 'Deals',
rows: [...],
action: {
action: 'OPEN_OVERLAY', width: '520px',
target: { pageId: 'deal-detail', title: 'Deal Detail' },
},
}async function myResolver(pageId, params) {
const res = await fetch(`/api/pages/${pageId}`);
return res.json(); // DashboardSpec
}
<DashboardRenderer data={spec} onResolve={myResolver} />Theming
The renderer ships a dark theme by default and is fully self-contained.
Pass any Partial<Theme> to theme to override individual tokens.
Theme tokens
interface Theme {
primary: string; // Accent colour — default '#6366f1'
primaryDark: string;
primaryLight: string;
secondary: string;
success: string;
successLight: string;
warning: string;
warningLight: string;
danger: string;
dangerLight: string;
gray50: string; // Page background
gray100: string; // Subtle surface
gray200: string; // Borders
gray300: string;
gray400: string;
gray500: string;
gray600: string;
gray700: string;
gray800: string;
gray900: string;
fontFamily: string; // e.g. "'Inter', sans-serif"
radius: string; // Widget border-radius
radiusSm: string;
sectionGap: string;
widgetGap: string;
widgetHeaderPadding: string;
widgetBodyPadding: string;
dashboardPadding: string;
shadowSm: string;
shadow: string;
shadowLg: string;
components?: ComponentStyles; // Per-element overrides
}Light vs dark detection — the renderer sets
data-theme="light"or"dark"automatically based on whethergray50is a light colour (starts with#For#f).
Component-level overrides
Every structural element accepts a StyleSlot (className? + style?) under theme.components:
widget · widgetHeader · widgetBody · section · sectionTitle · metricTile · metricsGrid · cardContent · cardBullets · table · tableHeader · tableCell · list · listItem · chartContainer
Applying a custom theme
import { DashboardRenderer } from '@divami-labs/experiential-ui';
import type { Theme } from '@divami-labs/experiential-ui';
import '@divami-labs/experiential-ui/styles';
const warmTheme: Partial<Theme> = {
primary: '#f59e0b',
primaryDark: '#d97706',
primaryLight: 'rgba(245, 158, 11, 0.12)',
secondary: '#fb923c',
// Light cream background
gray50: '#fdfaf5',
gray100: '#f5f0e8',
gray200: '#e8dfc8',
gray900: '#1a1508',
fontFamily: "'Inter', 'Helvetica Neue', Arial, sans-serif",
radius: '4px',
radiusSm: '2px',
components: {
widget: {
style: { background: '#ffffff', border: '1px solid #e8dfc8' },
},
widgetHeader: {
style: { background: '#fffdf7', borderBottom: '2px solid #f59e0b' },
},
},
};
export default function App() {
return <DashboardRenderer data={spec} theme={warmTheme} />;
}TypeScript types
import type {
// Data model
DashboardSpec, DashboardRow,
Widget, CardWidget, ChartWidget, ChartType, SeriesConfig,
TableWidget, ListWidget, ListItem, ListVariant,
Metric, DataRecord, WidgetAction,
// Component props
DashboardRendererProps, ChartProviderProps, ChartProps,
// Theming
Theme, ComponentStyles, StyleSlot,
} from '@divami-labs/experiential-ui';Build and publish
# Build library (ESM + CJS + types + CSS)
npm run build:lib
# Pack for local testing
npm pack
# Publish to npm (build:lib runs automatically via prepublishOnly)
npm publishLicense
MIT © Divami Design Labs
