Multi-Input Visuals
Combine data from multiple cards into composite visualizations
Multi-input visuals combine data from multiple card tabs into a single composite visualization. A KPI header paired with a trend chart. A comparison view with multiple metrics side by side. A dashboard widget that aggregates insights from different queries.
This guide covers everything you need to build multi-input custom visuals.
When to Use Multi-Input Visuals
Multi-input visuals solve problems that single queries cannot:
| Use Case | Example | Why Multi-Input |
|---|---|---|
| KPI + Trend | Revenue headline with monthly sparkline | KPI needs aggregation, trend needs time series |
| Metric Comparison | Current vs. previous period side by side | Different date filters per dataset |
| Dashboard Widgets | Hero KPI with supporting metrics grid | Different card types composed together |
| Multi-Series Charts | Multiple data sources overlaid | Independent queries that share a visualization |
If your visual needs data from a single query, use a single-input visual instead.
Mental Model: Slots and Tabs
Multi-input visuals use a slot system where each slot maps to a tab in the editor.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Editor Tabs │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 📊 KPI │ │ 📈 Trend │ │ + │ │
│ │ (Slot 0) │ │ (Slot 1) │ │ Add Tab │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ data[0] data[1] │
│ cardMetadata[0] cardMetadata[1] │
│ slotSettings[0] slotSettings[1] │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Key concepts:
- The config card is the tab that stores the custom visual preferences (URL, component, settings).
By default it starts as slot 0 when tabs are auto-created, but it can move if users reorder tabs. - Slot 1+ are input cards. Each provides data to the composite visual.
- The
dataprop is positional:data[0]is tab 0’s query results,data[1]is tab 1’s results, and so on. - Each slot can have its own settings via
slotSettings, metadata viacardMetadata, and effective card type.
When users add your multi-input visual to a dashboard, Semaphor auto-creates tabs based on your slot definitions.
Tutorial: Build a KPI + Trend Chart
Let's build a practical example: a KPI header with a multi-series area chart below it.
Step 1: Define Slots in the Manifest
In components.config.ts, declare your visual as multi-input with slot definitions:
{
name: 'KPI Area Chart',
component: 'KpiAreaChart',
componentType: 'chart',
chartType: 'kpi-area-chart',
icon: 'TrendingUp',
// Multi-input configuration
visualType: 'multiple',
minInputs: 2,
maxInputs: 2,
// Slot definitions guide users on what data each tab needs
slots: [
{
position: 0,
label: 'KPI',
description: 'KPI metric with current/comparison values using segment convention.',
expectedType: 'kpi',
required: true
},
{
position: 1,
label: 'Trend Data',
description: 'Time series data. First column = label, remaining columns = series.',
expectedType: 'bar',
required: true
}
],
// Global settings (apply to the whole visual)
settings: {
showGrid: {
title: 'Show Grid Lines',
defaultValue: 'true',
ui: 'select',
options: [
{ label: 'Yes', value: 'true' },
{ label: 'No', value: 'false' }
]
}
},
// Per-slot settings (configurable per tab)
slotSettings: {
comparisonLabel: {
title: 'Comparison Label',
defaultValue: '',
ui: 'input',
docs: {
description: 'Override the comparison label (e.g., "vs Last Month")'
}
}
}
}Step 2: Handle the Data Array
Your component receives data as an array of datasets:
import { MultiInputVisualProps } from '../../config-types';
export function KpiAreaChart({
data = [],
slotSettings,
cardMetadata,
theme,
}: MultiInputVisualProps) {
// Handle incomplete data
if (!data || data.length < 2) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Configure both KPI and Trend tabs to see the visualization
</div>
);
}
// data[0] = KPI slot results
// data[1] = Trend slot results
const kpiData = data[0] || [];
const trendData = data[1] || [];
// ... render your visualization
}Step 3: Access Per-Slot Card Metadata
Each slot has metadata including title, card type, and formatting configuration:
export function KpiAreaChart({
data = [],
cardMetadata,
tabMetadata,
}: MultiInputVisualProps) {
// cardMetadata[0] contains metadata for the KPI slot
const kpiMeta = cardMetadata?.[0];
// Access the card title (with fallbacks)
const title =
kpiMeta?.title ||
tabMetadata?.titles?.[0] ||
'KPI';
// Access KPI-specific configuration
const kpiConfig = kpiMeta?.kpiConfig;
const lowerIsBetter = kpiConfig?.options?.lowerIsBetter;
const showComparison = kpiConfig?.options?.showComparison !== false;
// Access number formatting
const formatConfig = kpiMeta?.formatConfig?.kpi?.primary;
// formatConfig contains: currency, decimalPlaces, locale, suffix, etc.
// ... use in your rendering
}Step 4: Global Settings vs. Slot Settings
Settings are split between global (applies to entire visual) and per-slot:
export function KpiAreaChart({
settings,
slotSettings,
}: MultiInputVisualProps) {
// Global settings from the config card
const showGrid = settings?.showGrid !== 'false';
// Per-slot settings (aligned with data array)
const kpiComparisonLabel = slotSettings?.[0]?.comparisonLabel as string;
const trendTitle = slotSettings?.[1]?.title as string;
// Fallback pattern: slotSettings -> cardMetadata -> tabMetadata -> default
const title =
(slotSettings?.[0]?.title as string) ||
cardMetadata?.[0]?.title ||
tabMetadata?.titles?.[0] ||
'KPI';
}Step 5: Provide Slot Guidance
Slot definitions help users understand what each tab expects. When they select your visual, Semaphor:
- Auto-creates tabs matching your
slotsarray - Sets each tab's card type from
expectedType - Shows slot labels and descriptions in the tab UI
- Validates required slots before rendering
┌─────────────────────────────────────────────────────────────────────────────┐
│ When user selects "KPI Area Chart": │
│ │
│ Before: [Current Card] │
│ │
│ After: [📊 KPI] [📈 Trend Data] [+] │
│ ↑ Tab created with type=kpi, label from slot definition │
│ ↑ Tab created with type=bar │
│ │
│ Tooltip on "KPI" tab: │
│ "KPI metric with current/comparison values using segment convention." │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Complete Example: KPI Area Chart
Here's the full implementation:
import { useMemo } from 'react';
import { MultiInputVisualProps } from '../../config-types';
import { Area, AreaChart, XAxis, YAxis } from 'recharts';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '../../ui/chart';
export function KpiAreaChart({
data = [],
slotSettings,
tabMetadata,
cardMetadata,
theme,
}: MultiInputVisualProps) {
const colors = theme?.colors || ['#3b82f6', '#10b981', '#f59e0b'];
// Require both slots
if (!data || data.length < 2) {
return (
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground">
<p>Configure both KPI and Trend tabs to see the visualization</p>
</div>
);
}
const kpiData = data[0] || [];
const trendData = data[1] || [];
const kpiMeta = cardMetadata?.[0];
// Parse KPI data (segment convention: { segment: 'current'|'comparison', value: number })
const currentRow = kpiData.find((r) => r.segment === 'current');
const comparisonRow = kpiData.find((r) => r.segment === 'comparison');
const currentValue = currentRow?.value as number | undefined;
const comparisonValue = comparisonRow?.value as number | undefined;
// Calculate percent change
const percentChange = useMemo(() => {
if (currentValue == null || comparisonValue == null || comparisonValue === 0) {
return null;
}
return ((currentValue - comparisonValue) / comparisonValue) * 100;
}, [currentValue, comparisonValue]);
// Title with fallback chain
const title =
(slotSettings?.[0]?.title as string) ||
kpiMeta?.title ||
tabMetadata?.titles?.[0] ||
'KPI';
// Process trend data for chart
const { chartData, seriesKeys, labelKey } = useMemo(() => {
if (!trendData.length) {
return { chartData: [], seriesKeys: [], labelKey: '' };
}
const keys = Object.keys(trendData[0]);
const label = keys[0];
const series = keys.slice(1);
return {
chartData: trendData.map((row) => ({
[label]: row[label],
...Object.fromEntries(series.map((k) => [k, Number(row[k]) || 0])),
})),
seriesKeys: series,
labelKey: label,
};
}, [trendData]);
return (
<div className="flex flex-col h-full p-4">
{/* KPI Header */}
<div className="mb-4">
<div className="text-sm text-muted-foreground font-medium">{title}</div>
<div className="text-3xl font-bold mt-1">
{currentValue?.toLocaleString() ?? '—'}
</div>
{percentChange !== null && (
<div className="flex items-center gap-2 mt-1 text-sm">
<span className={percentChange >= 0 ? 'text-emerald-600' : 'text-rose-600'}>
{percentChange >= 0 ? '↑' : '↓'} {Math.abs(percentChange).toFixed(1)}%
</span>
<span className="text-muted-foreground">
{slotSettings?.[0]?.comparisonLabel || 'vs previous'}
</span>
</div>
)}
</div>
{/* Area Chart */}
<div className="flex-1 min-h-[120px]">
{chartData.length > 0 ? (
<ChartContainer config={{}} className="h-full w-full">
<AreaChart data={chartData}>
<XAxis dataKey={labelKey} axisLine={false} tickLine={false} />
<YAxis axisLine={false} tickLine={false} width={50} />
<ChartTooltip content={<ChartTooltipContent />} />
{seriesKeys.map((key, i) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={colors[i % colors.length]}
fill={colors[i % colors.length]}
fillOpacity={0.2}
/>
))}
</AreaChart>
</ChartContainer>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No trend data available
</div>
)}
</div>
</div>
);
}Sample Data File
// kpi-area-chart.data.ts
export const sampleData = [
// Slot 0: KPI data (segment convention)
[
{ segment: 'current', value: 125000 },
{ segment: 'comparison', value: 98000 },
],
// Slot 1: Trend data (first col = label, rest = series)
[
{ month: 'Jan', revenue: 85000, profit: 12000 },
{ month: 'Feb', revenue: 92000, profit: 15000 },
{ month: 'Mar', revenue: 88000, profit: 13500 },
{ month: 'Apr', revenue: 95000, profit: 16000 },
{ month: 'May', revenue: 102000, profit: 18500 },
{ month: 'Jun', revenue: 125000, profit: 28000 },
],
];
export const sampleTabMetadata = {
titles: ['Total Revenue', 'Monthly Trend'],
cardTypes: ['kpi', 'bar'],
cardIds: ['kpi-revenue', 'trend-revenue'],
};
export const sampleCardMetadata = [
{
cardType: 'kpi',
title: 'Total Revenue',
kpiConfig: {
options: { lowerIsBetter: false, showComparison: true },
formatNumber: { currency: 'USD', decimalPlaces: 0 },
},
},
{
cardType: 'bar',
title: 'Monthly Trend',
},
];
export const sampleSettings = {};
export const sampleTheme = {
colors: ['#3b82f6', '#10b981', '#f59e0b'],
mode: 'light' as const,
};MultiInputVisualProps Reference
Full TypeScript interface for multi-input visual props:
type MultiInputVisualProps = {
/** Array of datasets, one per slot (indexed by tab position) */
data: Record<string, string | number | boolean>[][];
/** Global settings from the config card */
settings?: Record<string, string | number | boolean>;
/** Per-slot settings (aligned with data array) */
slotSettings?: Array<Record<string, string | number | boolean> | undefined>;
/** Theme configuration from the dashboard */
theme?: {
colors?: string[];
mode?: 'light' | 'dark' | 'system';
};
/** Active filters on the card */
filters?: DashboardFilter[];
/** Current filter values */
filterValues?: ActiveFilterValue[];
/** Pre-rendered inline filter components */
inlineFilters?: ReactNode[];
/** Tab metadata (titles, types, IDs) */
tabMetadata?: TabMetadata;
/** Rich metadata for each slot */
cardMetadata?: CardMetadata[];
};Settings vs. Slot Settings
Multi-input visuals support two levels of settings:
Global Settings
Defined in settings in the manifest. Stored on the config card. Apply to the entire visual.
// Manifest
settings: {
showGrid: { title: 'Show Grid', defaultValue: 'true', ui: 'select', /* ... */ }
}
// Component
const showGrid = settings?.showGrid !== 'false';Slot Settings
Defined in slotSettings in the manifest. Configurable per tab in the editor. Accessed via slotSettings[index].
// Manifest
slotSettings: {
comparisonLabel: { title: 'Comparison Label', defaultValue: '', ui: 'input' },
lineColor: { title: 'Line Color', defaultValue: '#3b82f6', ui: 'input' }
}
// Component (access per-slot)
const kpiLabel = slotSettings?.[0]?.comparisonLabel as string; // Slot 0
const lineColor = slotSettings?.[1]?.lineColor as string; // Slot 1When to use each:
| Setting Type | Use For | Examples |
|---|---|---|
Global (settings) | Visual-wide configuration | Grid lines, layout mode, animation |
Per-Slot (slotSettings) | Slot-specific overrides | Custom labels, colors per series |
Card Metadata
cardMetadata provides rich context about each slot's card configuration:
type CardMetadata = {
/** Card type (kpi, bar, table, etc.) */
cardType: string;
/** Card title */
title: string;
/** Card description */
description?: string;
/** Number formatting configuration */
formatConfig?: {
kpi?: {
primary?: FormatOptions;
comparison?: FormatOptions;
};
axes?: {
xAxis?: FormatOptions;
yAxis?: FormatOptions;
};
dataLabels?: FormatOptions;
};
/** KPI-specific configuration (when cardType is 'kpi') */
kpiConfig?: {
comparisonMetadata?: ComparisonMetadataMap;
options?: {
lowerIsBetter?: boolean;
showTrendline?: boolean;
showComparison?: boolean;
};
formatNumber?: LegacyFormatNumber;
};
};Access metadata by slot index:
const kpiMeta = cardMetadata?.[0];
const trendMeta = cardMetadata?.[1];
// Use for formatting
const format = kpiMeta?.formatConfig?.kpi?.primary;
const currency = format?.currency || 'USD';Tab Metadata
tabMetadata provides lightweight tab context:
type TabMetadata = {
/** Tab titles for labels */
titles: string[];
/** Card types per tab */
cardTypes: string[];
/** Card IDs per tab (for interaction callbacks) */
cardIds: string[];
};Useful for rendering tab labels or debugging:
// Log tab structure
console.log('Tabs:', tabMetadata?.titles);
// ['Revenue KPI', 'Monthly Trend', 'Regional Breakdown']
// Get card type for slot 2
const slotType = tabMetadata?.cardTypes[2]; // 'table'Slot Configuration Modes
Different visuals need different slot structures. The manifest supports three modes:
Mode 1: Fixed Slots Only
For visuals with exact position requirements:
{
"name": "Comparison Dashboard",
"visualType": "multiple",
"minInputs": 3,
"maxInputs": 3,
"slots": [
{ "position": 0, "label": "Current Period", "required": true },
{ "position": 1, "label": "Previous Period", "required": true },
{ "position": 2, "label": "Target", "required": true }
]
}Result: Exactly 3 tabs, each with specific meaning. Users cannot add more.
Mode 2: Fully Dynamic (Repeating Pattern)
For visuals where all tabs follow the same pattern:
{
"name": "Multi-KPI Grid",
"visualType": "multiple",
"minInputs": 1,
"maxInputs": 12,
"slots": [
{
"position": "0+",
"label": "KPI",
"description": "Each tab renders as a KPI card in the grid",
"expectedType": "kpi"
}
]
}Result: 1 to 12 tabs, all treated as equal KPI inputs. Users can add/remove freely.
Mode 3: Fixed + Dynamic (Structured with Dynamic Tail)
For visuals with specific first slots and flexible additional slots:
{
"name": "Hero KPI Dashboard",
"visualType": "multiple",
"minInputs": 2,
"maxInputs": 8,
"slots": [
{
"position": 0,
"label": "Hero KPI",
"description": "Primary metric displayed prominently at top",
"expectedType": "kpi",
"required": true
},
{
"position": "1+",
"label": "Supporting",
"description": "Additional metrics in the grid below",
"expectedType": "kpi"
}
]
}Result: Tab 0 shows "Hero KPI", tabs 1+ show "Supporting". User can add up to 7 supporting KPIs.
Position Format Reference
The position field in slot definitions supports several formats:
| Position | Type | Meaning | Example |
|---|---|---|---|
0 | Fixed | Exactly position 0 (first tab) | Hero KPI |
1 | Fixed | Exactly position 1 (second tab) | Comparison metric |
"0+" | Dynamic | All positions, repeating | Grid of equal KPIs |
"1+" | Dynamic | Position 1 and all subsequent | Fixed hero + dynamic children |
"0-2" | Range | Positions 0, 1, and 2 only | Exactly 3 inputs |
Matching priority: exact > range > dynamic
// Internal matching logic
function getSlotForPosition(slots, position) {
// 1. Exact numeric match
const exact = slots.find(s => s.position === position);
if (exact) return exact;
// 2. Range match ("0-2")
const range = slots.find(s => {
const match = String(s.position).match(/^(\d+)-(\d+)$/);
return match && position >= Number(match[1]) && position <= Number(match[2]);
});
if (range) return range;
// 3. Dynamic tail match ("1+")
return slots.find(s => {
const match = String(s.position).match(/^(\d+)\+$/);
return match && position >= Number(match[1]);
});
}Data Flow Diagram
How data flows from user configuration to your component:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. User selects multi-input visual │
│ → Semaphor auto-creates tabs from slot definitions │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2. User configures each tab │
│ Tab 0: KPI query → SELECT 'current' AS segment, SUM(revenue) AS value │
│ Tab 1: Trend query → SELECT month, SUM(revenue) as revenue FROM ... │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 3. Semaphor fetches data for each tab in parallel │
│ → Results stored by card ID │
│ → Metadata extracted from card configurations │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 4. Props assembled and passed to your component │
│ │
│ data: [ │
│ [{segment:'current',value:125000}, {segment:'comparison',value:98000}],│
│ [{month:'Jan',revenue:85000}, {month:'Feb',revenue:92000}, ...] │
│ ] │
│ cardMetadata: [{ cardType:'kpi', title:'Revenue', kpiConfig:{...} }, ...]│
│ slotSettings: [{ comparisonLabel:'vs Last Year' }, { lineColor:'blue' }] │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 5. Your component renders the composite visualization │
│ → Parse data[0] for KPI header │
│ → Parse data[1] for chart series │
│ → Apply cardMetadata formatting │
│ → Respect slotSettings overrides │
└─────────────────────────────────────────────────────────────────────────────┘Best Practices
Handle Incomplete Data
Multi-input visuals may render with partial data while users configure tabs. Always validate:
// Check minimum slots are populated
if (!data || data.length < 2) {
return <EmptyState message="Configure all required tabs" />;
}
// Check each slot has data
const kpiData = data[0] || [];
const trendData = data[1] || [];
if (kpiData.length === 0) {
return <EmptyState message="KPI tab needs data" />;
}Use the Fallback Pattern for Titles
Always provide fallbacks for user-facing text:
// Priority: slotSettings → cardMetadata → tabMetadata → default
const title =
(slotSettings?.[0]?.title as string) ||
cardMetadata?.[0]?.title ||
tabMetadata?.titles?.[0] ||
'KPI';Keep Slots Focused
Each slot should have a clear purpose. Don't overload a single slot with multiple data expectations.
// Good: Clear separation of concerns
"slots": [
{ "position": 0, "label": "KPI", "expectedType": "kpi" },
{ "position": 1, "label": "Trend", "expectedType": "bar" }
]
// Avoid: Ambiguous slot purpose
"slots": [
{ "position": 0, "label": "Data" } // What kind of data?
]Document Expected Data Shapes
In your manifest's docs.dataSchema, clearly describe what each slot expects:
docs: {
dataSchema: `
### Slot 0: KPI Data
| Column | Type | Required |
|--------|------|----------|
| segment | text | Yes |
| value | number | Yes |
### Slot 1: Trend Data
| Position | Purpose |
|----------|---------|
| 1st column | Label (date/month) |
| 2nd+ columns | Series values |
`.trim()
}Troubleshooting
Visual Shows "Configure tabs" Message
Cause: Required slots don't have data.
Fix: Ensure users have configured queries for all required slots. Check your empty state logic matches your slot requirements.
Data Array is Empty
Cause: Tab queries haven't executed or returned no rows.
Fix:
- Verify each tab has a data source configured
- Check query syntax in each tab
- Add console logging:
console.log('data:', data)
Slot Settings Not Applying
Cause: Accessing wrong index or settings not defined in manifest.
Fix:
- Verify
slotSettingsis defined in your manifest - Check you're accessing the correct array index
- Remember:
slotSettings[0]is slot 0, not slot 1
Tabs Not Auto-Creating
Cause: Missing visualType: 'multiple' or no slots array.
Fix: Ensure your manifest includes:
{
"visualType": "multiple",
"slots": [...]
}Related Topics
- Single-Input Visuals - Building standard custom visuals
- Number Formatting - Formatting numeric values
- Props Reference - Complete API documentation
- Card Tabs - How tabs work in Semaphor