Skip to content

Styling and theming ​

Nori 0.1.0 exposes styling hooks for React applications. The full editor uses your application's CSS; the compact preview has built-in light and dark palettes. There is no theme picker in the playground, no full-editor theme prop, and no exported Nori stylesheet to import.

This guide describes the published API, including the limits of what can be customized without changing the renderer.

What className means ​

className is a React prop that adds a CSS class to a rendered HTML element. It is not a visible label, a workbook field, or a setting in Excel. You choose the name and write its rules in your application stylesheet:

tsx
import { Spreadsheet } from "@byfungsi/nori/react";
import "./spreadsheet.css";

// workbook is a live runtime created by createWorkbook(...).
<Spreadsheet workbook={workbook} className="my-sheet" />;
css
/* spreadsheet.css in your application */
.my-sheet {
  font-family: system-ui, sans-serif;
  color: #24372c;
  background: white;
  --nori-selection-border: #286b48;
}

Inspect the element in browser developer tools: the editor's outer div now has class="my-sheet". The CSS file must actually be loaded by your application. Installing Nori does not install these example styles automatically.

A supplied className replaces the component's default class. It is not appended. To retain the default root hook, use className="nori-workbook my-sheet". Changing the editor's root class does not change its children's default classes, such as .nori-grid.

Three separate styling layers ​

LayerExamplesWhere it lives
Application appearanceToolbar colors, tabs, borders, focus, font familyYour CSS and React styling props
Workbook formattingExplicit cell fill/text color, bold, wrapping, alignment, number formatCell.style in the snapshot/runtime
Layout and interaction geometryColumn widths, row heights, merged cells, frozen offsets, resize guidesWorkbook layout and the renderer's inline styles

Changing a theme does not mutate the workbook or its exported snapshot. Imported explicit cell colors take precedence over ordinary inherited application colors. A pale imported fill with no explicit text color can be hard to read in a dark application. Review representative workbooks; dark application chrome does not guarantee dark-compatible document formatting.

Component styling props ​

ComponentProps and target
SpreadsheetclassName, style target the outer div; renderCell customizes cell contents
SheetGridclassName targets the table, not its scrolling wrapper; supports renderCell; no style prop
FormulaBarclassName targets its outer div or form, depending on selection; no style prop
SelectionToolbarclassName targets the outer div; no style prop
WorkbookTabsclassName targets the nav; no style prop
SpreadsheetPreviewclassName, style target the outer figure; theme accepts light or dark
WorkbookProviderContext only; no visual wrapper or styling props

The editor does not forward arbitrary HTML attributes such as data-theme. Put those attributes on a host wrapper if you need them. The preview sets its own data-theme from its theme prop.

Complete editor CSS starter ​

Copy the following CSS into your application. It includes light and dark application palettes, formula controls, selection toolbar, grid borders, sheet tabs, keyboard focus, and narrow-screen wrapping. These are application-owned examples, not built-in Nori themes. Explicit workbook colors are preserved.

css
/* Application-owned theme: this file is not an npm package export. */
.my-sheet {
  --app-surface: #ffffff;
  --app-chrome: #f3f6f2;
  --app-text: #24372c;
  --app-muted: #526457;
  --app-border: #cbd7ce;
  --app-active: #dceedd;
  --app-focus: #286b48;
  --nori-header-background: var(--app-chrome);
  --nori-cell-background: var(--app-surface);
  --nori-editor-background: var(--app-surface);
  --nori-editor-color: var(--app-text);
  --nori-selection-fill: rgb(40 107 72 / 14%);
  --nori-selection-border: var(--app-focus);
  --nori-resize-border: var(--app-focus);
  color-scheme: light;
  color: var(--app-text);
  background: var(--app-surface);
  font:
    14px/1.4 system-ui,
    sans-serif;
  min-width: 0;
  width: 100%;
  border: 1px solid var(--app-border);
  border-radius: 12px;
}
.my-sheet--dark {
  --app-surface: #18211c;
  --app-chrome: #243229;
  --app-text: #edf5ef;
  --app-muted: #bdcec1;
  --app-border: #4a6151;
  --app-active: #365b43;
  --app-focus: #8fe0ab;
  --nori-selection-fill: rgb(143 224 171 / 16%);
  color-scheme: dark;
}
.my-sheet .nori-formula-bar,
.my-sheet .nori-selection-toolbar,
.my-sheet .nori-view-status,
.my-sheet .nori-tabs {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  background: var(--app-chrome);
  border-bottom: 1px solid var(--app-border);
}
.my-sheet .nori-formula-bar,
.my-sheet .nori-selection-toolbar,
.my-sheet .nori-view-status {
  flex-wrap: wrap;
}
.my-sheet .nori-formula-bar input {
  flex: 1 1 140px;
  min-width: 0;
  padding: 8px;
  border: 1px solid var(--app-border);
  border-radius: 6px;
  background: var(--app-surface);
  color: var(--app-text);
  font: inherit;
}
.my-sheet .nori-formula-bar button,
.my-sheet .nori-selection-toolbar button,
.my-sheet .nori-tabs button {
  font: inherit;
  color: var(--app-text);
  background: var(--app-surface);
  border: 1px solid var(--app-border);
  border-radius: 6px;
  padding: 7px 10px;
  cursor: pointer;
}
.my-sheet button:disabled {
  opacity: 0.5;
  cursor: default;
}
.my-sheet button:focus-visible,
.my-sheet input:focus-visible {
  outline: 2px solid var(--app-focus);
  outline-offset: -2px;
}
.my-sheet .nori-view-status,
.my-sheet .nori-selection-toolbar output {
  color: var(--app-muted);
}
.my-sheet [role="alert"] {
  flex-basis: 100%;
  font-weight: 600;
}
.my-sheet .nori-grid {
  font: inherit;
  border-collapse: collapse;
  border-spacing: 0;
}
.my-sheet .nori-grid th,
.my-sheet .nori-grid td {
  border-right: 1px solid var(--app-border);
  border-bottom: 1px solid var(--app-border);
}
.my-sheet .nori-grid th {
  color: var(--app-muted);
  font-weight: 500;
}
.my-sheet .nori-grid td {
  background-color: var(--app-surface);
}
.my-sheet .nori-tabs {
  overflow-x: auto;
  border-top: 1px solid var(--app-border);
  border-bottom: 0;
}
.my-sheet .nori-tabs button {
  flex-shrink: 0;
  white-space: nowrap;
}
.my-sheet .nori-tabs button[aria-pressed="true"] {
  background: var(--app-active);
  font-weight: 700;
}
.my-sheet .nori-resize-handle:focus-visible {
  outline: 2px solid var(--app-focus);
}
@media (max-width: 480px) {
  .my-sheet .nori-formula-bar,
  .my-sheet .nori-selection-toolbar,
  .my-sheet .nori-tabs {
    padding: 8px;
  }
}

Use either appearance with the same runtime:

tsx
<Spreadsheet workbook={workbook} className="nori-workbook my-sheet" />

<Spreadsheet
  workbook={workbook}
  className="nori-workbook my-sheet my-sheet--dark"
/>

In a real application, render one editor and toggle the class from your existing theme state:

tsx
<Spreadsheet
  workbook={workbook}
  className={`nori-workbook my-sheet${darkMode ? " my-sheet--dark" : ""}`}
/>

darkMode is your application's boolean, not a Nori export. No workbook recreation is needed when it changes. The --app-* variables in this example belong to this stylesheet; Nori itself only reads the --nori-* variables listed below.

All editor CSS variables ​

Set these on the editor root or an ancestor so they inherit into the grid. They affect the full editor, including composed primitives, but do not theme SpreadsheetPreview.

VariableBuilt-in fallbackEffect
--nori-selection-fillrgba(107,151,71,0.13)Overlay fill on selected cells
--nori-selection-border#789a55Selection inset border and inline editor border
--nori-resize-border#4b7d34Full boundary guide while hovering/resizing
--nori-header-background#f6f8f1Row/column header backgrounds
--nori-cell-backgroundwhiteFrozen cells without an explicit workbook fill
--nori-editor-backgroundwhiteInline editing input background
--nori-editor-color#17251bInline editing input text

--nori-cell-background alone does not paint every ordinary cell. Set the root/table or ordinary td background in your CSS, as in the starter. Grid lines, header text, formula-bar input styling, tab colors, and font family also need your CSS; they are not additional built-in tokens.

Inline styles and TypeScript ​

style is useful for per-instance values. TypeScript's CSSProperties does not directly list custom properties; explicitly extend it:

tsx
import type { CSSProperties } from "react";

const appearance: CSSProperties & {
  "--nori-selection-border": string;
  "--nori-selection-fill": string;
} = {
  "--nori-selection-border": "#7856d8",
  "--nori-selection-fill": "rgb(120 86 216 / 15%)",
  borderRadius: 8,
};

<Spreadsheet workbook={workbook} className="my-sheet" style={appearance} />;

Use style for the root only. It does not automatically override inline styles on descendants.

CSS hooks and interaction states ​

Scope your selectors beneath your editor class to avoid changing other tables or buttons in your app.

HookWhat it selects
.nori-workbookDefault editor root
.nori-formula-barFormula display/edit controls
.nori-selection-toolbarSelection summary and merge/unmerge controls
.nori-view-statusImported filter/freeze status, when present
.nori-scrollScroll container generated by SheetGrid
.nori-gridDefault grid table
.nori-tabsSheet navigation
.nori-tabs button[aria-pressed="true"]Active sheet button
.nori-grid td[data-selected]Selected cells; attribute is absent when unselected
.nori-grid td[data-merged]Merged anchor cells
.nori-grid td[data-address="B2"]A rendered cell by its A1 address
.nori-grid button[data-cell-address]Cell interaction buttons
.nori-selection-fillNoninteractive selection overlay
.nori-resize-handleHeader resize handle; axis classes are .nori-resize-column and .nori-resize-row
.nori-resize-guideWhole-border hover/drag indicator
[role="alert"]Error text; scope this to your editor

Do not remove pointer-event behavior, change sticky positioning/z-index, or repurpose resize handles to draw ordinary grid borders. Keep the selection overlay noninteractive. Do not hide keyboard focus outlines without providing an equally visible replacement.

These hooks describe the current 0.1.0 DOM. Prefer public props and variables; audit descendant selectors when upgrading, especially selectors tied to internal nesting.

Compose a custom toolbar layout ​

Use the primitives when the default editor arrangement does not fit your application:

tsx
import {
  WorkbookProvider,
  FormulaBar,
  SelectionToolbar,
  SheetGrid,
  WorkbookTabs,
} from "@byfungsi/nori/react";

<div className="my-sheet my-sheet--dark">
  <WorkbookProvider workbook={workbook}>
    <WorkbookTabs className="nori-tabs project-tabs" />
    <FormulaBar className="nori-formula-bar project-formula" />
    <SelectionToolbar />
    <SheetGrid className="nori-grid project-grid" />
  </WorkbookProvider>
</div>;

Retaining the default class names allows the starter stylesheet to continue matching. The same styling works in a readOnly editor; read-only mode changes interaction policy, not appearance. Style disabled actions and read-only inputs if you want a visual distinction.

Custom cell content ​

renderCell changes display content inside an existing cell button. It does not replace the td, change the stored value, or change calculations/exported formatting.

tsx
import { Spreadsheet, formatCellValue } from "@byfungsi/nori/react";

<Spreadsheet
  workbook={workbook}
  className="my-sheet"
  renderCell={({ value, cell }) => (
    <span style={{ fontVariantNumeric: "tabular-nums" }}>
      {formatCellValue(value, cell)}
    </span>
  )}
/>;

The context includes sheet, address, cell, calculated value, selected, and mergedRange. value may be a scalar, error, or array result. formatCellValue handles these current result forms; avoid calling numeric methods without narrowing the value first. Merged cells use their anchor's value.

Render noninteractive content: nested buttons, links, and inputs inside the cell button interfere with semantics and editing. The built-in title and accessible cell label continue to use the default formatted value, so custom content should preserve its meaning. The preview has no renderCell prop in 0.1.0.

Styling the chat preview ​

tsx
import { SpreadsheetPreview } from "@byfungsi/nori/react";

<SpreadsheetPreview
  workbook={workbook}
  title="sales.xlsx"
  theme="dark"
  className="nori-preview chat-attachment"
  style={{ borderRadius: 16, maxWidth: 720 }}
  maxRows={8}
  maxColumns={6}
/>;

The preview is always read-only, with local sheet navigation. Its built-in palette styles the header, grid lines, tabs, footer, and text. style is merged last on the outer figure, so outer sizing, border, background, and font family can be customized there. The internal table uses a fixed 12px font size; changing the figure's font size does not scale all descendants.

The preview uses inline colors on internal elements. A root background override or the editor's CSS variables will not replace that full palette. Version 0.1.0 has no custom palette object, theme provider, or automatic system-theme detection. Pass theme from your host's theme state. Extensive internal recoloring would require overriding inline declarations; prefer the built-in palettes or a host-owned renderer for that level of control.

The card fits its container and scrolls columns horizontally. Default limits are 8 rows and 6 columns; maximum limits are 20 rows and 12 columns. Column widths are clamped to 90–180px for a compact card. Imported row heights are not reproduced; text is ellipsized. Workbook fills and text colors remain separate from its palette. See chat preview for the full prop reference.

Responsive layout, sizing, and specificity ​

  • Give flex/grid children containing the editor min-width: 0. For CSS grid, minmax(0, 1fr) prevents a wide sheet from stretching the page.
  • Let .nori-scroll scroll horizontally. Do not squeeze all spreadsheet columns to fit a phone or override the renderer's calculated table/column widths.
  • Row heights, column widths, frozen offsets, and merge geometry come from the workbook. Change them through supported runtime commands, not arbitrary cell CSS. Font-size increases do not auto-fit rows.
  • SheetGrid renders a bounded window (up to 100 visible rows and 26 visible columns); styling does not enable virtualization or remove that limit.
  • The scroll container has an inline max-height: 520px. There is no public height prop in 0.1.0. If necessary, a scoped .my-sheet .nori-scroll { max-height: 65vh !important; } overrides that one declaration. Treat it as a version-specific workaround and test frozen panes and resize guides afterward.
  • Essential cell padding, positioning, button geometry, and workbook formatting are inline. Ordinary CSS rules do not override inline declarations. Avoid broad !important rules that erase workbook styles or break interaction geometry.
  • The starter uses background-color on td so an explicit inline workbook background wins. Avoid blanket td { color: ... } if you intend unformatted cells to inherit root text color.

CSS Modules and utility CSS ​

With CSS Modules, use className={styles.sheet} and :global(.nori-grid) inside the scoped rules to target Nori's global descendant classes. Import the module in your application; there is no module export from Nori.

Utility classes can style the root through className. For the many descendant hooks and custom properties, a scoped stylesheet is usually clearer. No Tailwind plugin or theme adapter ships with Nori.

Troubleshooting and validation ​

SymptomCheck
My class does nothingConfirm the CSS file is loaded and the rendered element has that class. A class name does not generate styles by itself.
Default selector stopped matchingA custom className replaces the default; pass both names if needed.
Grid headers remain lightSet --nori-header-background; their background is assigned inline using this variable.
Frozen cells remain whiteSet --nori-cell-background; also style ordinary cells separately.
Toolbar input remains lightStyle .nori-formula-bar input; editor variables only style the inline cell editor.
Imported cell looks wrong in dark modeInspect its explicit workbook fill and text color; application theme changes preserve them.
My row size or padding rule is ignoredInspect inline layout styles. Use layout commands for dimensions and avoid breaking cell geometry.
Preview ignores editor variablesUse its theme prop; preview colors are a separate implementation.
CSS affects another workbookScope rules beneath a per-instance class; avoid global table, button, or td rules.

Before shipping your stylesheet, check keyboard focus, selected ranges, active tabs, disabled/read-only controls, formula input, inline editing, error text, hover/drag resize borders, frozen rows/columns, merged cells, long text, and imported explicit colors. Check narrow screens and horizontal scrolling as well as a desktop viewport. Maintain readable contrast; do not use color alone to communicate errors or selection.

There is currently no unified theme object, full-editor light/dark preset, theme persistence, exported CSS bundle, or automatic contrast correction for imported formatting. The React integration guide, interaction guide, and Excel support matrix describe the surrounding capabilities.

Currency display is supported since 0.2.0 through Cell.style.numberFormat. See Currency formatting for syntax, imports, and locale limits.

Nori 0.2.0 · Initial milestone · Explicit compatibility boundaries.