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.
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:
tokensare the base values the whole dashboard reads: colors, typography, radius, spacing and shadow.componentsrefine 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 withcurrentTheme.lightordarkalways 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.
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.
| Path | Accepts | Default |
|---|---|---|
tokens.color.primary, background, foreground, muted, border, card, positive, negative and the other color tokens | An opaque CSS color | Neutral grays |
tokens.typography.fontFamily, headingFontFamily, monoFontFamily | A CSS font stack | Open Sans |
tokens.typography.baseFontSize | A number above 0 | 14 |
tokens.radius.card, control, table, badge | A number, 0 or more | 5, 5, 5, 999 |
tokens.spacing.gridGap | Space between cards | 10 |
tokens.spacing.dashboardPadding | Space between the canvas edge and the cards | 10 |
tokens.spacing.cardPadding | Space inside a card | 16 |
tokens.shadow.card | none, subtle, raised or a CSS shadow | none |
components.dashboard.background | muted, background or a color | muted |
components.card.borderWidth | 0 hides the card border, 1 draws it | 1 |
components.card.background, borderColor | A color | The card and border tokens |
components.chart.palette | A list of colors, one per series | Eight colors |
components.chart.barRadius | Bar corner radius, on every bar chart | 0 |
components.chart.gridColor, axisColor | A color | The chart's own colors |
components.table, components.matrixTable | container, header, row, cell, subtotal, grandTotal and pagination settings | Neutral 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 path | appearance path | Notes |
|---|---|---|
default.gridLayout.margin | tokens.spacing.gridGap | One number instead of [x, y]. Set tokens.spacing.dashboardPadding for the space at the canvas edge. |
default.canvasTheme, dark.canvasTheme | components.dashboard.background in schemes.light, schemes.dark | muted stays muted. clear becomes background. A color stays a color. |
default.cardBorderRadius | tokens.radius.card | |
default.controlInputRadius | tokens.radius.control | |
default.chart.dataset.backgroundColor, borderColor, and their dark copies | components.chart.palette in each scheme | One list per scheme colors each series. |
default.chart.options.borderRadius | components.chart.barRadius | |
default.chart.font.family | tokens.typography.fontFamily | Applies to the whole dashboard, charts included. |
chart.options.scales.*.grid.color | components.chart.gridColor | |
Axis text colors under chart.options.scales.* | components.chart.axisColor | |
Tooltip colors under chart.options.plugins.tooltip | No setting | Tooltips follow light and dark mode. |
Other chart.options, such as legend, data labels and titles | No setting | Set them per card in the card editor. |
default.table.tableHeaderColor | components.table.header.background | Takes a color, not a class. |
dashboardPanel, dashboardTabsContainer, dashboardCardContainer, dashboardCard, gridLayout.className | No replacement | See 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:
| Selector | Element |
|---|---|
[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 |
/* 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.