logoSemaphor

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.tsx

Step 2: Define the Component with SingleInputVisualProps

branded-kpi.tsx
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.

branded-kpi.tsx
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.

branded-kpi.tsx
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.

branded-kpi.tsx
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

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