Semaphor

Themes and Styles

Learn how to use themes and styles

Overview

Make an embedded dashboard look like the rest of your app. With the appearance prop you can set:

  • Colors for text, borders, surfaces and accents, in light and dark mode
  • Fonts for body text, headings and code
  • Spacing between cards and around the canvas
  • The canvas behind the cards: muted, the page color, or any color
  • Cards: border on or off, shadow, and corner radius
  • Controls: corner radius for inputs, tabs and date pickers
  • Charts: series colors, bar corners, grid and axis colors

Organization admins set the same things for every dashboard in Brand Studio, and dashboard authors set them per dashboard in the Style panel. Use appearance when your app decides the look at runtime.

Quick Start

Pass an appearance object to the dashboard. Every property is optional except version.

Dashboard.tsx
import { Dashboard, type AppearanceSpec, type AuthToken } from 'semaphor';

const appearance: AppearanceSpec = {
  version: 1,
  schemes: {
    light: {
      tokens: {
        color: { primary: '#1d4ed8' },
        radius: { card: 8 },
        spacing: { gridGap: 16 },
      },
    },
  },
};

export function OrdersDashboard({ authToken }: { authToken: AuthToken }) {
  return <Dashboard authToken={authToken} appearance={appearance} />;
}

Using Vue or a web component? See Vue and Web Component.

How It Works

An appearance has two parts per color scheme:

  • tokens are the base values the whole dashboard reads: colors, typography, radius, spacing and shadow.
  • components refine one surface: the dashboard canvas, cards, charts and tables.

You set them under schemes.light and schemes.dark. mode picks the scheme:

  • system (the default) follows the dashboard's theme, which you set with currentTheme.
  • light or dark always uses that scheme.

Each scheme stands alone. A value you set only in schemes.light doesn't reach dark mode, so repeat shared values such as fonts and spacing in both schemes.

Which setting wins

Four layers style a dashboard, each applied over the one before: your organization's brand from Brand Studio, the dashboard's own style from the Style panel, the embed token's config.appearance, and then the appearance prop. A later layer wins wherever it sets a property. Anything it leaves out comes from the layers below.

Example: A Full Brand

This example matches a blue brand in both light and dark mode. Shared tokens live in one object and are spread into each scheme.

appearance.ts
import type { AppearanceSpec } from 'semaphor';

// Values that stay the same in light and dark mode
const brandTokens = {
  typography: { fontFamily: 'Inter, system-ui, sans-serif' },
  radius: { card: 8, control: 6 },
  spacing: { gridGap: 16, dashboardPadding: 16 },
  shadow: { card: 'subtle' },
};

export const appearance: AppearanceSpec = {
  version: 1,
  mode: 'system',
  schemes: {
    light: {
      tokens: {
        ...brandTokens,
        color: {
          primary: '#1d4ed8',
          border: '#e2e8f0',
          positive: '#15803d',
          negative: '#b91c1c',
        },
      },
      components: {
        dashboard: { background: 'muted' },
        card: { borderWidth: 0 },
        chart: {
          palette: ['#1d4ed8', '#0891b2', '#f59e0b', '#dc2626', '#7c3aed'],
          barRadius: 4,
          gridColor: '#f1f5f9',
        },
      },
    },
    dark: {
      tokens: {
        ...brandTokens,
        color: {
          primary: '#60a5fa',
          background: '#0b1220',
          card: '#111a2e',
          border: '#1e293b',
        },
      },
      components: {
        dashboard: { background: 'background' },
        card: { borderWidth: 1 },
        chart: {
          palette: ['#60a5fa', '#22d3ee', '#fbbf24', '#f87171', '#a78bfa'],
          barRadius: 4,
          gridColor: '#1e293b',
        },
      },
    },
  },
};

In light mode, borderless cards with a subtle shadow sit on a muted canvas. In dark mode, bordered cards sit on the page color. A revenue-by-region bar chart uses the brand palette and rounded bars in both.

Settings

These paths go under schemes.light or schemes.dark. Sizes are numbers in pixels.

PathAcceptsDefault
tokens.color.primary, background, foreground, muted, border, card, positive, negative and the other color tokensAn opaque CSS colorNeutral grays
tokens.typography.fontFamily, headingFontFamily, monoFontFamilyA CSS font stackOpen Sans
tokens.typography.baseFontSizeA number above 014
tokens.radius.card, control, table, badgeA number, 0 or more5, 5, 5, 999
tokens.spacing.gridGapSpace between cards10
tokens.spacing.dashboardPaddingSpace between the canvas edge and the cards10
tokens.spacing.cardPaddingSpace inside a card16
tokens.shadow.cardnone, subtle, raised or a CSS shadownone
components.dashboard.backgroundmuted, background or a colormuted
components.card.borderWidth0 hides the card border, 1 draws it1
components.card.background, borderColorA colorThe card and border tokens
components.chart.paletteA list of colors, one per seriesEight colors
components.chart.barRadiusBar corner radius, on every bar chart0
components.chart.gridColor, axisColorA colorThe chart's own colors
components.table, components.matrixTablecontainer, header, row, cell, subtotal, grandTotal and pagination settingsNeutral table styling

The full shape is the AppearanceSpec type exported from semaphor.

A few rules apply to every value:

  • Colors must be opaque. Hex, rgb(), hsl() and named colors work; transparent colors don't.
  • Your app loads the font. If it isn't loaded, the browser falls back along the stack.
  • The dashboard checks the object when it renders. An unknown field or an invalid value raises an error that names its path, such as $.schemes.light.tokens.radius.card.

Moving from customStyle

The customStyle prop and its TStyle shape are retired. In TypeScript, passing customStyle is now a type error; in JavaScript and on the web component, the dashboard ignores it and renders as if it weren't there. Move each setting to appearance:

customStyle pathappearance pathNotes
default.gridLayout.margintokens.spacing.gridGapOne number instead of [x, y]. Set tokens.spacing.dashboardPadding for the space at the canvas edge.
default.canvasTheme, dark.canvasThemecomponents.dashboard.background in schemes.light, schemes.darkmuted stays muted. clear becomes background. A color stays a color.
default.cardBorderRadiustokens.radius.card
default.controlInputRadiustokens.radius.control
default.chart.dataset.backgroundColor, borderColor, and their dark copiescomponents.chart.palette in each schemeOne list per scheme colors each series.
default.chart.options.borderRadiuscomponents.chart.barRadius
default.chart.font.familytokens.typography.fontFamilyApplies to the whole dashboard, charts included.
chart.options.scales.*.grid.colorcomponents.chart.gridColor
Axis text colors under chart.options.scales.*components.chart.axisColor
Tooltip colors under chart.options.plugins.tooltipNo settingTooltips follow light and dark mode.
Other chart.options, such as legend, data labels and titlesNo settingSet them per card in the card editor.
default.table.tableHeaderColorcomponents.table.header.backgroundTakes a color, not a class.
dashboardPanel, dashboardTabsContainer, dashboardCardContainer, dashboardCard, gridLayout.classNameNo replacementSee Targeting elements with CSS.

Dark mode no longer falls back

TStyle.dark fell back to default for anything it didn't set. Schemes don't. Copy shared values into both schemes.light and schemes.dark.

Targeting elements with CSS

The class-name hooks from customStyle have no replacement. Use appearance for anything it covers. For the rest, write CSS in your app that targets the attributes the dashboard renders:

SelectorElement
[data-semaphor-theme]The dashboard's root, and its menus and popovers. The value is light or dark.
[data-role='semaphor-container']The dashboard container
[data-role='dashboard-tabs-container']The sheet tabs and the sheet below them
[data-sheet-id]Each sheet tab, by sheet ID
[data-role='dashboard-tabs-content']The selected sheet's content area
[role='dashboard-frame-container']Each grid cell that holds a card
[role='card-frame']Each card. Carries data-frame-id and data-active-card-id.
[role='card-title-container']A card's title row
[role='header-container']A card's header
[data-role='card-content']A card's body: the chart, table or text
styles.css
/* Outline one card. Copy its data-frame-id from your browser's dev tools. */
[role='card-frame'][data-frame-id='YOUR_FRAME_ID'] {
  outline: 2px solid #1d4ed8;
}

/* A dark-mode-only rule */
[data-semaphor-theme='dark'] [role='card-title-container'] {
  letter-spacing: 0.01em;
}

This works for React, Vue and web component embeds, which render inside your page. Your CSS can't reach into an iframe embed.

On this page