Single-Input Visuals
Build custom visualizations that display data from a single card
Single-input visuals receive data from one query and render it however you want. They're the most common type of custom visual.
When to Use Single-Input
Build a single-input visual when you need:
- Custom KPIs - Branded metric cards with unique layouts, animations, or comparison displays
- Specialized charts - Visualizations not available in Semaphor's built-in library (Sankey diagrams, gauge charts, custom maps)
- Branded components - Match your product's design system exactly
- Domain-specific displays - Healthcare timelines, financial tickers, logistics trackers
If your visual needs to combine data from multiple queries (like a KPI with a trend chart), see Multi-Input Visuals.
Tutorial: Build a Custom KPI Card
This tutorial walks through creating a KPI card that displays a primary metric with comparison data.
Step 1: Create the Component File
mkdir src/components/semaphor-components/branded-kpi
touch src/components/semaphor-components/branded-kpi/branded-kpi.tsxStep 2: Define the Component with SingleInputVisualProps
import { SingleInputVisualProps } from '../../config-types';
export function BrandedKPI({
data,
settings,
cardMetadata,
theme,
}: SingleInputVisualProps) {
// Handle empty state
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
No data available
</div>
);
}
// Get the first row (KPI queries typically return one row)
const row = data[0];
const columns = Object.keys(row);
const primaryValue = row[columns[0]];
return (
<div className="flex flex-col items-center justify-center h-full p-6">
<span className="text-4xl font-bold">{primaryValue}</span>
</div>
);
}Step 3: Access Card Metadata
The cardMetadata prop gives you read-only context about the card: its title, description, type, and KPI configuration.
export function BrandedKPI({
data,
settings,
cardMetadata,
theme,
}: SingleInputVisualProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
No data available
</div>
);
}
// Read card metadata
const title = cardMetadata?.title ?? 'Metric';
const description = cardMetadata?.description;
const row = data[0];
const columns = Object.keys(row);
const primaryValue = row[columns[0]];
return (
<div className="flex flex-col h-full p-6">
{/* Header from card metadata */}
<div className="mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{/* Primary value */}
<div className="flex-1 flex items-center justify-center">
<span className="text-5xl font-bold">{primaryValue}</span>
</div>
</div>
);
}Step 4: Apply Number Formatting
Use formatConfig from cardMetadata to format numbers according to the user's configuration in Semaphor.
import { SingleInputVisualProps } from '../../config-types';
import { formatKPIValue } from '../../kpi-utils';
export function BrandedKPI({
data,
settings,
cardMetadata,
theme,
}: SingleInputVisualProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
No data available
</div>
);
}
const title = cardMetadata?.title ?? 'Metric';
const description = cardMetadata?.description;
// Get format configuration for KPI primary value (new formatConfig with legacy fallback)
const primaryFormat =
cardMetadata?.formatConfig?.kpi?.primary ??
cardMetadata?.kpiConfig?.formatNumber;
const row = data[0];
const columns = Object.keys(row);
const rawValue = row[columns[0]];
// Format the value using Semaphor's configuration
const formattedValue = formatKPIValue(rawValue as number, primaryFormat);
return (
<div className="flex flex-col h-full p-6">
<div className="mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<div className="flex-1 flex items-center justify-center">
<span className="text-5xl font-bold">{formattedValue}</span>
</div>
</div>
);
}Step 5: Handle Theming
Use the theme prop to match your visual with the dashboard's color scheme and dark mode.
export function BrandedKPI({
data,
settings,
cardMetadata,
theme,
}: SingleInputVisualProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
No data available
</div>
);
}
const title = cardMetadata?.title ?? 'Metric';
const description = cardMetadata?.description;
const primaryFormat = cardMetadata?.formatConfig?.kpi?.primary;
// Theme colors
const primaryColor = theme?.colors?.[0] ?? '#3b82f6';
const isDarkMode = theme?.mode === 'dark';
const row = data[0];
const columns = Object.keys(row);
const rawValue = row[columns[0]];
const formattedValue = formatValue(rawValue as number, primaryFormat);
return (
<div
className="flex flex-col h-full p-6 rounded-lg"
style={{
backgroundColor: isDarkMode ? '#1f2937' : '#f8fafc',
}}
>
<div className="mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<div className="flex-1 flex items-center justify-center">
<span
className="text-5xl font-bold"
style={{ color: primaryColor }}
>
{formattedValue}
</span>
</div>
</div>
);
}Step 6: Register in components.config.ts
import { ComponentsConfig } from './config-types';
export const config: ComponentsConfig = {
visuals: [
{
name: 'Branded KPI',
component: 'BrandedKPI',
componentType: 'chart',
chartType: 'branded-kpi',
icon: 'Gauge',
settings: {
showTrend: {
title: 'Show Trend Indicator',
defaultValue: 'true',
ui: 'select',
options: [
{ label: 'Yes', value: 'true' },
{ label: 'No', value: 'false' },
],
},
},
docs: {
description: 'A branded KPI card with customizable styling.',
dataSchema: `
### Expected Data Format
| Column | Type | Required | Description |
|--------|------|----------|-------------|
| value | number | Yes | The primary metric value |
| comparison | number | No | Previous period value for comparison |
### Example Query
\`\`\`sql
SELECT
SUM(revenue) as value,
LAG(SUM(revenue)) OVER (ORDER BY month) as comparison
FROM sales
WHERE month = CURRENT_MONTH
\`\`\`
`.trim(),
useCases: [
'Revenue tracking',
'User count displays',
'Performance metrics',
],
},
},
],
filters: [],
};SingleInputVisualProps Reference
Every single-input visual receives these props from Semaphor:
type SingleInputVisualProps = {
/** Query results - array of row objects */
data: Record<string, string | number | boolean>[];
/** User-configurable settings from your manifest */
settings?: Record<string, string | number | boolean>;
/** Card metadata (read-only context from Semaphor) */
cardMetadata?: CardMetadata;
/** Dashboard theme configuration */
theme?: {
colors?: string[];
mode?: 'light' | 'dark' | 'system';
};
/** Active filter definitions */
filters?: DashboardFilter[];
/** Current filter values */
filterValues?: ActiveFilterValue[];
/** Pre-rendered inline filter components */
inlineFilters?: ReactNode[];
};CardMetadata
Semaphor automatically builds cardMetadata from the source card's configuration. This is read-only context your visual can use.
type CardMetadata = {
/** Card type: 'kpi', 'bar', 'line', 'table', etc. */
cardType: string;
/** Card title (uses tabTitle if set, otherwise title) */
title: string;
/** Card description */
description?: string;
/** Number formatting configuration */
formatConfig?: CustomVisualFormatConfig;
/** KPI-specific configuration */
kpiConfig?: {
comparisonMetadata?: ComparisonMetadataMap;
options?: {
lowerIsBetter?: boolean;
showTrendline?: boolean;
showComparison?: boolean;
};
formatNumber?: LegacyFormatNumber;
};
};Using cardMetadata
function MyKPI({ data, cardMetadata }: SingleInputVisualProps) {
// Display the card's title
const title = cardMetadata?.title ?? 'Metric';
// Show description if configured
const description = cardMetadata?.description;
// Check if lower values are better (for coloring trends)
const lowerIsBetter = cardMetadata?.kpiConfig?.options?.lowerIsBetter ?? false;
return (
<div>
<h2>{title}</h2>
{description && <p>{description}</p>}
{/* ... */}
</div>
);
}FormatConfig for Number Formatting
The formatConfig object contains formatting rules configured by users in Semaphor's visual editor. Use it to format numbers consistently.
Structure
type CustomVisualFormatConfig = {
kpi?: {
primary?: FormatOptions; // Primary KPI value
comparison?: FormatOptions; // Comparison value
colorRanges?: ColorRange[]; // Conditional coloring
};
axes?: {
xAxis?: FormatOptions;
yAxis?: FormatOptions;
secondaryYAxis?: FormatOptions;
};
dataLabels?: FormatOptions;
tables?: {
columns?: Array<{
id?: string;
label?: string;
position?: number;
numberFormat?: ColumnNumberFormat | FormatOptions;
}>;
columnMap?: Record<string, { numberFormat?: ColumnNumberFormat | FormatOptions }>;
comparison?: FormatOptions;
defaultNumberFormat?: FormatOptions;
};
};Applying KPI Formatting
function formatKPIValue(
value: number,
format?: FormatOptions
): string {
if (!format) {
return value.toLocaleString();
}
const options: Intl.NumberFormatOptions = {};
const decimals =
typeof format.decimalPlaces === 'number' ? format.decimalPlaces : undefined;
// Handle currency
if (format.type === 'currency' && format.currency) {
options.style = 'currency';
options.currency = format.currency;
} else if (format.type === 'percent') {
options.style = 'percent';
}
if (decimals !== undefined) {
options.minimumFractionDigits = decimals;
options.maximumFractionDigits = decimals;
}
// Handle compact notation (1K, 1M, 1B)
if (format.useSuffix) {
options.notation = 'compact';
options.compactDisplay = 'short';
}
return new Intl.NumberFormat(format.locale ?? 'en-US', options).format(value);
}
export function RevenueKPI({ data, cardMetadata }: SingleInputVisualProps) {
const primaryFormat = cardMetadata?.formatConfig?.kpi?.primary;
const value = data[0]?.revenue as number;
return (
<div className="text-4xl font-bold">
{formatKPIValue(value, primaryFormat)}
</div>
);
}Color Ranges
Apply conditional coloring based on value thresholds:
function getColorForValue(
value: number,
colorRanges?: ColorRange[]
): string | undefined {
if (!colorRanges || colorRanges.length === 0) return undefined;
for (const range of colorRanges) {
if (value >= range.start && value <= range.end) {
return range.color;
}
}
return undefined;
}
export function ColoredKPI({ data, cardMetadata }: SingleInputVisualProps) {
const colorRanges = cardMetadata?.formatConfig?.kpi?.colorRanges;
const value = data[0]?.score as number;
const color = getColorForValue(value, colorRanges);
return (
<span style={{ color: color ?? 'inherit' }}>
{value}
</span>
);
}Inline Filters
Semaphor passes pre-rendered filter components via inlineFilters. You decide where they appear in your layout.
export function FilterableKPI({
data,
cardMetadata,
inlineFilters = [],
}: SingleInputVisualProps) {
return (
<div className="flex flex-col h-full">
{/* Render filters at the top */}
{inlineFilters.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 mb-4 bg-muted/30 rounded-lg border">
{inlineFilters}
</div>
)}
{/* KPI content */}
<div className="flex-1 flex items-center justify-center">
<span className="text-5xl font-bold">
{data[0]?.value}
</span>
</div>
</div>
);
}Inline filters differ from dashboard filters: they only affect the card they're in, not the entire dashboard.
Common Patterns
Empty State
Always handle missing or empty data gracefully:
export function SafeKPI({ data }: SingleInputVisualProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full min-h-[120px] text-muted-foreground">
<p>No data available</p>
</div>
);
}
// Render with data...
}Loading State
Check the editing prop to show placeholder content in the editor:
export function EditorAwareKPI({ data, editing }: SingleInputVisualProps) {
if (!data || data.length === 0) {
if (editing) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground border-2 border-dashed rounded-lg">
<p>Configure a data source to see your KPI</p>
</div>
);
}
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
No data available
</div>
);
}
// Render with data...
}Responsive Layout
Use flex and grid layouts to adapt to different card sizes:
export function ResponsiveKPI({ data, cardMetadata }: SingleInputVisualProps) {
const title = cardMetadata?.title ?? 'Metric';
const value = data[0]?.value;
const comparison = data[0]?.previous;
return (
<div className="flex flex-col h-full p-4 min-h-[100px]">
{/* Title - always visible */}
<h3 className="text-sm font-medium text-muted-foreground truncate">
{title}
</h3>
{/* Value - scales with container */}
<div className="flex-1 flex items-center">
<span className="text-2xl sm:text-3xl lg:text-4xl font-bold">
{value}
</span>
</div>
{/* Comparison - hides on small containers */}
{comparison && (
<div className="text-sm text-muted-foreground hidden sm:block">
Previous: {comparison}
</div>
)}
</div>
);
}Examples
Basic KPI Card
A minimal KPI that displays a single value with title:
import { SingleInputVisualProps } from '../../config-types';
export function SimpleKPI({ data, cardMetadata }: SingleInputVisualProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
No data
</div>
);
}
const title = cardMetadata?.title ?? 'Value';
const columns = Object.keys(data[0]);
const value = data[0][columns[0]];
return (
<div className="flex flex-col items-center justify-center h-full p-4">
<span className="text-sm text-muted-foreground mb-2">{title}</span>
<span className="text-4xl font-bold">{value}</span>
</div>
);
}KPI with Comparison and Trend
A KPI card showing current value, comparison, and a trend indicator:
import { SingleInputVisualProps } from '../../config-types';
import { formatKPIValue } from '../../kpi-utils';
export function ComparisonKPI({ data, cardMetadata, theme }: SingleInputVisualProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
No data
</div>
);
}
const title = cardMetadata?.title ?? 'Metric';
const lowerIsBetter = cardMetadata?.kpiConfig?.options?.lowerIsBetter ?? false;
const primaryColor = theme?.colors?.[0] ?? '#3b82f6';
// Expect columns: value, previous
const current = Number(data[0].value) || 0;
const previous = Number(data[0].previous) || 0;
// Format config (use Semaphor settings when available)
const primaryFormat =
cardMetadata?.formatConfig?.kpi?.primary ?? {
type: 'currency',
currency: 'USD',
decimalPlaces: 1,
useSuffix: true,
};
const comparisonFormat =
cardMetadata?.formatConfig?.kpi?.comparison ?? primaryFormat;
// Calculate change
const change = previous !== 0 ? ((current - previous) / previous) * 100 : 0;
const isPositive = change > 0;
const isImproving = lowerIsBetter ? !isPositive : isPositive;
return (
<div className="flex flex-col h-full p-6">
{/* Title */}
<h3 className="text-sm font-medium text-muted-foreground mb-4">
{title}
</h3>
{/* Current value */}
<div className="flex-1 flex flex-col justify-center">
<span
className="text-4xl font-bold mb-2"
style={{ color: primaryColor }}
>
{formatKPIValue(current, primaryFormat)}
</span>
{/* Comparison */}
<div className="flex items-center gap-2 text-sm">
<span
className={`flex items-center gap-1 font-medium ${
isImproving ? 'text-green-600' : 'text-red-600'
}`}
>
{isPositive ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
)}
{Math.abs(change).toFixed(1)}%
</span>
<span className="text-muted-foreground">
vs {formatKPIValue(previous, comparisonFormat)}
</span>
</div>
</div>
</div>
);
}KPI with Currency Formatting from formatConfig
A KPI that uses Semaphor's format configuration for proper currency display:
import { SingleInputVisualProps } from '../../config-types';
import { formatKPIValue } from '../../kpi-utils';
export function FormattedKPI({ data, cardMetadata }: SingleInputVisualProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
No data
</div>
);
}
const title = cardMetadata?.title ?? 'Revenue';
const description = cardMetadata?.description;
// Get formatting from Semaphor configuration
const primaryFormat = cardMetadata?.formatConfig?.kpi?.primary;
const comparisonFormat = cardMetadata?.formatConfig?.kpi?.comparison;
const current = Number(data[0].revenue) || 0;
const previous = Number(data[0].previous_revenue);
return (
<div className="flex flex-col h-full p-6">
<div className="mb-4">
<h3 className="text-lg font-semibold">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<div className="flex-1 flex flex-col justify-center">
{/* Primary value with currency formatting */}
<span className="text-5xl font-bold">
{formatKPIValue(current, primaryFormat)}
</span>
{/* Previous period with comparison formatting */}
{!isNaN(previous) && (
<span className="text-sm text-muted-foreground mt-2">
Previous: {formatKPIValue(previous, comparisonFormat ?? primaryFormat)}
</span>
)}
</div>
</div>
);
}Next Steps
- Multi-Input Visuals - Combine data from multiple cards
- Custom Components Guide - Full plugin development reference
- Theming - Match your visuals to dashboard themes