Spreadsheet Viewer SDK for rendering Excel files with Witan API
npm install @witan/xlsx-viewA client SDK for rendering Excel spreadsheets from the Witan API. The Witan API renders spreadsheet cells as image tiles server-side, and this SDK composites those tiles into an interactive viewer.
> Note: This package requires a Witan API subscription. The SDK connects to Witan's WebSocket API to stream pre-rendered spreadsheet tiles.
- Tile-based rendering: Server renders cells as images, client composites them for perfect visual fidelity
- Framework-agnostic core: XlsxClient class works with any framework
- React integration: Ready-to-use XlsxView component with hooks
- HiDPI support: Automatic device pixel ratio handling for crisp rendering
- Efficient caching: LRU tile cache with proper ImageBitmap cleanup
- Priority loading: Tiles closest to viewport center load first
- WebSocket streaming: Real-time tile updates with automatic reconnection
- Adaptive format: WebP tiles when browser supports it, PNG fallback
- Skeleton loading: Smooth loading states while tiles stream in
- Customizable: CSS variables for theming, render slots for custom UI
``bash`
pnpm add @witan/xlsx-view
1. A Witan API subscription (get one at witanlabs.com)
2. An uploaded XLSX file (via the Witan API)
3. An authentication token for the Witan API
`tsx
import { XlsxView, xlsxViewStyles } from "@witan/xlsx-view/react";
// Include default styles (or define your own CSS variables)
const styleSheet = document.createElement("style");
styleSheet.textContent = xlsxViewStyles;
document.head.appendChild(styleSheet);
function SpreadsheetViewer({
fileId,
revisionId,
}: {
fileId: string;
revisionId: string;
}) {
// getToken should return a valid Witan API access token
const getToken = async () => {
const response = await fetch("/api/witan-token");
const { token } = await response.json();
return token;
};
return (
fileId={fileId}
revisionId={revisionId}
getAccessToken={getToken}
/>
);
}
`
`typescript
import { XlsxClient } from "@witan/xlsx-view";
const client = new XlsxClient({
apiOrigin: "https://api.witanlabs.com", // Your Witan API endpoint
fileId: "your-file-id",
revisionId: "your-revision-id",
getAccessToken: () => fetchWitanToken(), // Return your Witan API token
});
client.on("connectionChange", ({ status }) => {
console.log("Connection:", status);
});
client.on("metadataLoaded", ({ metadata }) => {
console.log(
"Sheets:",
metadata.sheets.map((s) => s.name),
);
});
client.on("tileLoaded", ({ key }) => {
// Re-render when tiles arrive
const tiles = client.getTilesForViewport(viewport);
render(tiles);
});
client.connect();
// Set viewport when container is ready
client.setViewport({ scrollX: 0, scrollY: 0, width: 800, height: 600 });
// Cleanup
client.dispose();
`
Main component for rendering spreadsheets.
`tsx
interface XlsxViewProps {
// Connection (required)
apiOrigin: string;
fileId: string;
revisionId: string;
getAccessToken: () => Promise
// State (hybrid: uncontrolled by default, controlled if props provided)
selection?: Selection | null;
defaultSelection?: Selection;
onSelectionChange?: (selection: Selection | null) => void;
activeSheet?: string;
defaultSheet?: string;
onSheetChange?: (sheetName: string) => void;
// UI customization
formulaBar?: (props: FormulaBarProps) => ReactNode;
sheetTabs?: (props: SheetTabsProps) => ReactNode;
selectionAction?: (props: SelectionActionProps) => ReactNode;
// Callbacks
onError?: (error: Error) => void;
onReady?: () => void; // Called when viewer is ready (metadata loaded, tiles rendered)
onHyperlinkClick?: (link: HyperlinkInfo) => void; // Called when a hyperlink is clicked
// Styling
className?: string;
}
// Imperative handle (via ref)
interface XlsxViewHandle {
/* Scroll a cell into view. Returns true if successful, false if not ready. /
scrollToCell: (row: number, col: number) => boolean;
}
`
#### Usage Examples
Uncontrolled (simplest):
`tsx`
fileId="abc123"
revisionId="rev456"
getAccessToken={getToken}
/>
Controlled selection:
`tsx
const [selection, setSelection] = useState
selection={selection}
onSelectionChange={setSelection}
/>;
`
Custom UI slots:
`tsx`
// Custom sheet tabs
sheetTabs={({ sheets, activeSheet, onSheetChange }) => (
active={activeSheet}
onChange={onSheetChange}
/>
)}
// Custom action button on selection
selectionAction={({ selection, position }) => (
)}
/>
Programmatic scrolling with ref:
`tsx
import { useRef, useState } from "react";
import { XlsxView, type XlsxViewHandle } from "@witan/xlsx-view/react";
function SpreadsheetWithNavigation() {
const viewRef = useRef
const [ready, setReady] = useState(false);
const navigateToCell = (row: number, col: number) => {
// scrollToCell scrolls the minimum amount needed to bring the cell into view.
// If the cell is already visible, no scrolling occurs.
viewRef.current?.scrollToCell(row, col);
};
return (
<>
{...connectionProps}
onReady={() => setReady(true)}
/>
>
);
}
`
#### useXlsxClient
Access the XlsxClient instance and state.
`tsx`
const {
client, // XlsxClient instance
status, // ConnectionStatus
metadata, // ViewSheetsResponse | null
activeSheet, // string | null
selection, // Selection | null
error, // Error | null
setActiveSheet,
setSelection,
} = useXlsxClient({ apiOrigin, fileId, revisionId, getAccessToken });
#### useViewport
Manage viewport state and scroll handling.
`tsx`
const {
viewport, // { scrollX, scrollY, width, height }
setContainerRef, // Ref callback for scroll container
handleScroll, // onScroll handler
scrollTo, // (x, y) => void
} = useViewport();
#### useSelection
Handle cell selection with mouse interactions.
`tsx`
const {
selection, // Selection | null
isDragging, // boolean
handleMouseDown, // Event handler
handleMouseMove, // Event handler
handleMouseUp, // Event handler
} = useSelection({
client,
viewport,
initialSelection,
selection,
onSelectionChange,
});
Hyperlink handling:
`tsx
import type { HyperlinkInfo } from "@witan/xlsx-view/react";
onHyperlinkClick={(link: HyperlinkInfo) => {
if (link.type === "external") {
// Custom external link handling
window.open(link.target, "_blank");
} else {
// Internal link - link.target is like "Sheet2!A1"
console.log("Navigate to:", link.target);
}
}}
/>;
// Default behavior (when onHyperlinkClick not provided):
// - External links open in new tab
// - Internal links scroll to target cell (and switch sheets if needed)
`
For custom layouts, individual components are exported:
`tsx`
import {
TileCanvas,
ColumnHeaders,
RowHeaders,
SelectionOverlay,
HyperlinkOverlay,
LoadingSkeleton,
parseInternalLink,
} from "@witan/xlsx-view/react";
Framework-agnostic engine for managing spreadsheet state.
`typescript
class XlsxClient extends EventEmitter
// Readonly state
readonly status: ConnectionStatus;
readonly metadata: ViewSheetsResponse | null;
readonly activeSheet: string | null;
readonly selection: Selection | null;
readonly dpr: number;
constructor(options: XlsxClientOptions);
// Lifecycle
connect(): void;
dispose(): void;
// Navigation
setActiveSheet(sheetName: string): void;
setViewport(viewport: Viewport): void;
// Selection
setSelection(selection: Selection | null): void;
// Cell data (instant cached access from prefetched visible region)
getCell(row: number, col: number): CellData | undefined;
hasCell(row: number, col: number): boolean;
// Coordinate conversion
pixelToCell(x: number, y: number): CellPosition | null;
getCellBounds(row: number, col: number): CellBounds | null;
getSheetDimensions(): { width: number; height: number };
getPositions(): SheetPositions | null;
getActiveSheetMetadata(): SheetMetadata | null;
// Tiles
getTilesForViewport(viewport: Viewport): Map
}
`
`typescript`
interface XlsxClientOptions {
apiOrigin: string; // API server origin
fileId: string; // File identifier
revisionId: string; // Revision identifier
getAccessToken: () => Promise
maxCacheSize?: number; // Tile cache size (default: 200)
maxConcurrent?: number; // Concurrent requests (default: 6)
}
`typescript
type XlsxClientEvents = {
connectionChange: { status: ConnectionStatus; error?: Error };
metadataLoaded: { metadata: ViewSheetsResponse };
sheetChange: { sheet: string };
selectionChange: { selection: Selection | null };
tileLoaded: { key: string };
cellsLoaded: { range: RangeBounds };
error: { error: Error };
};
// Subscribe to events
const unsubscribe = client.on("tileLoaded", ({ key }) => {
console.log("Tile loaded:", key);
});
// Unsubscribe
unsubscribe();
`
`typescript`
import {
computeSheetPositions, // Compute position arrays from sheet metadata
getTilePosition, // Get pixel position of a tile
getTileSize, // Get pixel size of a tile
tileKey, // Generate cache key for a tile
parseTileKey, // Parse cache key back to coordinates
} from "@witan/xlsx-view";
Theme the viewer using CSS custom properties:
`css
.xlsx-view {
/ Headers /
--xv-header-bg: #f5f5f5;
--xv-header-bg-selected: #e9e9eb;
--xv-header-text: #666666;
--xv-header-border: #e0e0e0;
--xv-col-header-height: 24px;
--xv-row-header-width: 28px;
/ Selection /
--xv-selection-border: #166534;
--xv-selection-fill: rgba(22, 101, 52, 0.08);
--xv-selection-width: 2px;
/ Grid /
--xv-gridline-color: #e0e0e0;
--xv-cell-bg: #ffffff;
}
/ Custom theme example /
.my-dark-theme .xlsx-view {
--xv-header-bg: #2d2d2d;
--xv-header-text: #cccccc;
--xv-selection-border: #0066cc;
--xv-cell-bg: #1e1e1e;
}
`
The package exports default CSS as a string:
`tsx
import { xlsxViewStyles, loadingSkeletonStyles } from "@witan/xlsx-view/react";
// Option 1: Inject into document
const style = document.createElement("style");
style.textContent = xlsxViewStyles + loadingSkeletonStyles;
document.head.appendChild(style);
// Option 2: Import in CSS bundler (if supported)
// Add to your CSS: @import '@witan/xlsx-view/styles.css';
`
`typescript
type ConnectionStatus =
| "disconnected"
| "connecting"
| "connected"
| "reconnecting"
| "error";
interface Viewport {
scrollX: number;
scrollY: number;
width: number;
height: number;
}
interface Selection {
cell: CellPosition; // Anchor cell
range: RangeBounds | null; // Multi-cell range (null for single cell)
}
interface CellPosition {
row: number; // 0-indexed
col: number; // 0-indexed
}
interface CellBounds {
x: number;
y: number;
width: number;
height: number;
}
`
`typescript
type TileStatus = "pending" | "loading" | "loaded" | "error";
interface TileState {
status: TileStatus;
bitmap?: ImageBitmap; // Only when loaded
width?: number; // CSS pixels
height?: number; // CSS pixels
error?: Error; // Only when error
}
interface TileCoord {
tileRow: number;
tileCol: number;
}
`
`typescript
interface CellData {
row: number;
col: number;
text: string;
formula?: string;
error?: string;
richText?: RichTextRun[];
style?: CellStyle;
link?: {
type: "internal" | "external";
target: string;
tooltip?: string;
};
note?: {
author: string;
text: string;
};
thread?: {
resolved: boolean;
comments: {
authorId: string;
text: string;
createdAt: string;
}[];
};
}
interface HyperlinkInfo {
type: "internal" | "external";
target: string;
tooltip?: string;
cell: CellPosition;
}
`
`typescript
interface ViewSheetsResponse {
sheets: SheetMetadata[];
activeSheetIndex: number;
defaultStyle: CellStyle;
persons?: Record
tileRows: number; // Rows per tile
tileCols: number; // Columns per tile
}
interface SheetMetadata {
name: string;
hidden: boolean;
usedRange?: RangeBounds;
defaultRowHeight: number;
defaultColWidth: number;
rowHeights: Record
colWidths: Record
hiddenRows: number[];
hiddenCols: number[];
merges: MergeRange[];
frozenRows: number;
frozenCols: number;
showGridLines: boolean;
tileRowCount: number;
tileColCount: number;
tileRows: number; // Rows per tile
tileCols: number; // Columns per tile
}
`
`typescript`
import {
DEFAULT_COL_WIDTH, // 64px
DEFAULT_ROW_HEIGHT, // 15px
} from "@witan/xlsx-view";
The SDK uses a tile-based rendering approach with the Witan API:
``
┌─────────────────┐ WebSocket ┌─────────────────┐
│ Your App │ ←───────────────────────→ │ Witan API │
│ │ │ │
│ @witan/xlsx- │ - Upload XLSX files │ - Parses │
│ view SDK │ - Request tile images │ spreadsheet │
│ │ - Receive PNG/WebP tiles │ - Resolves & │
│ - Composites │ - Get cell metadata │ normalizes │
│ tiles │ │ styles │
│ - Handles UI │ │ - Renders │
│ │ │ image tiles │
└─────────────────┘ └─────────────────┘
1. Witan API renders cells as image tiles (50 rows x 26 columns each)
2. SDK requests tiles for visible viewport via WebSocket, prioritized by distance from center
3. Tiles are cached as ImageBitmap objects for efficient compositing
4. Headers are rendered client-side on separate canvases
5. Selection is drawn on an overlay canvas above tiles
The SDK operates on fileId/revisionId pairs. Revisions are immutable — when a file changes, a new revisionId is created. This enables aggressive caching:
- Tiles for a given revision never change, so they can be cached indefinitely
- Multiple users viewing the same revision benefit from shared CDN cache
- Return visits to a previously-viewed revision load instantly from cache
- No cache invalidation logic needed — just cache forever
Benefits:
- Perfect visual fidelity (fonts, colors, borders match Excel exactly)
- Lower client complexity (no font measurement, text wrapping, or cell rendering)
- Smaller bundle size (no heavy grid library dependency)
- Efficient caching and memory management
- Chrome/Edge 80+
- Firefox 75+
- Safari 14.1+
Requires:
- ImageBitmap` API
- WebSocket support
- CSS custom properties
MIT