logoSemaphor

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 CaseExampleWhy Multi-Input
KPI + TrendRevenue headline with monthly sparklineKPI needs aggregation, trend needs time series
Metric ComparisonCurrent vs. previous period side by sideDifferent date filters per dataset
Dashboard WidgetsHero KPI with supporting metrics gridDifferent card types composed together
Multi-Series ChartsMultiple data sources overlaidIndependent 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 data prop 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 via cardMetadata, 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:

  1. Auto-creates tabs matching your slots array
  2. Sets each tab's card type from expectedType
  3. Shows slot labels and descriptions in the tab UI
  4. 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 1

When to use each:

Setting TypeUse ForExamples
Global (settings)Visual-wide configurationGrid lines, layout mode, animation
Per-Slot (slotSettings)Slot-specific overridesCustom 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:

PositionTypeMeaningExample
0FixedExactly position 0 (first tab)Hero KPI
1FixedExactly position 1 (second tab)Comparison metric
"0+"DynamicAll positions, repeatingGrid of equal KPIs
"1+"DynamicPosition 1 and all subsequentFixed hero + dynamic children
"0-2"RangePositions 0, 1, and 2 onlyExactly 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:

  1. Verify each tab has a data source configured
  2. Check query syntax in each tab
  3. Add console logging: console.log('data:', data)

Slot Settings Not Applying

Cause: Accessing wrong index or settings not defined in manifest.

Fix:

  1. Verify slotSettings is defined in your manifest
  2. Check you're accessing the correct array index
  3. 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": [...]
}