# Custom cells

> Draw any cell your own way with a Svelte snippet, a component or a plain render function, and add styled tooltips to cells and headers.

When a built-in [column type](https://bonguynvan.github.io/bo-grid/docs/column-types/) is not enough, draw the cell yourself. There are three ways, and each changes only how the cell looks: sorting, filtering, copying and export still use the cell's value (and `format`, if set).

- A **`cell` snippet** on the grid, for `type: 'custom'` columns, in Svelte.
- A **`component`** on a column: a Svelte component per column, reusable across grids.
- A **`render(ctx)`** function on a column, which returns a DOM node or an HTML string. It works from React, Vue and plain JavaScript as well.

The Move column below is a `cell` snippet; hover the ⓘ in its header, or a cut-off company name, for a tooltip:

Example: A cell snippet and tooltips (custom-cells.svelte)

```svelte
<script lang="ts">
  import { Grid, type ColumnDef, type GridRow } from 'bo-grid';
  import { quotes } from './data';

  const columns: ColumnDef[] = [
    { type: 'text', key: 'symbol', header: 'Symbol', width: 90 },
    // A styled tooltip shows the full name when it is cut off.
    { type: 'text', key: 'name', header: 'Company', width: 120, tooltip: true },
    { type: 'price', key: 'price', header: 'Price', width: 90 },
    // `custom`: the grid hands the cell to the `cell` snippet below.
    {
      type: 'custom',
      key: 'change',
      header: 'Move',
      width: 120,
      headerTooltip: 'Change since the previous close, in percent.',
      headerInfo: true,
    },
    // A `format` function covers text-only cases without a snippet.
    { type: 'volume', key: 'volume', header: 'Volume', flex: 1, format: (v) => `${(Number(v) / 1e6).toFixed(2)} M` },
  ];

  let rows = $state(quotes());
</script>

{#snippet cell({ row }: { row: GridRow; column: ColumnDef; value: unknown })}
  {@const up = Number(row.change) >= 0}
  <span class="move" class:up>
    {up ? '▲' : '▼'}
    {Math.abs(Number(row.change)).toFixed(2)}%
  </span>
{/snippet}

<Grid {rows} {columns} {cell} height={300} />

<style>
  .move {
    padding: 1px 8px;
    font-weight: 600;
    color: var(--bo-down);
    background: color-mix(in srgb, currentColor 14%, transparent);
    border-radius: 999px;
  }
  .move.up {
    color: var(--bo-up);
  }
</style>
```

data.ts:

```ts
// Small, fixed data for the docs' examples: every example reads the same
// symbols, so a reader compares features, not datasets.
// A type, not an interface: it then fits GridRow's index signature.
export type Quote = {
  id: number;
  symbol: string;
  name: string;
  sector: string;
  price: number;
  change: number;
  volume: number;
};

const BASE: Omit<Quote, 'id'>[] = [
  { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 },
  { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 },
  { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 },
  { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 },
  { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 },
  { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 },
  { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 },
  { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 },
  { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 },
  { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 },
  { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 },
  { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 },
];

/** A fresh copy of the quotes, so one example's edits never reach another. */
export function quotes(): Quote[] {
  return BASE.map((q, i) => ({ id: i + 1, ...q }));
}
```

## A cell snippet

Give the column `type: 'custom'` and pass a `cell` snippet to the grid: declared inside `<Grid>`, it is the `cell` prop, and its parameters get their types from it. It receives `{ row, column, value }`, so one snippet can serve several custom columns by checking `column.key`:

```svelte
<Grid {rows} {columns} height={400}>
  {#snippet cell({ row })}
    {@const change = Number(row.change)}
    <span class="move" class:up={change >= 0}>
      {change >= 0 ? '▲' : '▼'} {Math.abs(change).toFixed(2)}%
    </span>
  {/snippet}
</Grid>
```

## A component per column

Set `component` on any column. The component receives `value`, `row`, `column` and `text` (the value formatted as copy and export see it):

```ts
import StatusPill from './StatusPill.svelte';

const columns: ColumnDef[] = [
  { type: 'text', key: 'status', header: 'Status', component: StatusPill },
];
```

```svelte
<!-- StatusPill.svelte -->
<script lang="ts">
  import type { CellTypeProps } from 'bo-grid';
  let { value, text }: CellTypeProps = $props();
</script>

<span class="pill" data-status={value}>{text}</span>
```

The component updates in place as its props change. Under `api.patchRows`, `row` is a fresh view of the patched row on each patch, so read it reactively (in markup or `$derived`) rather than once when the component mounts.

A component is usually part of a [registered cell type](https://bonguynvan.github.io/bo-grid/docs/column-types/#registered-cell-types), so every grid that shows, say, a country uses the same cell. The **Team** example below does that for its Country column.

## A render function

Snippets and components are Svelte features, so outside Svelte (the React and Vue components, or the `<bo-grid>` element) give the column a `render(ctx)` function. `ctx` is `{ value, row, column }`. Return a DOM node, which is appended as it is, or an HTML string:

```js
{
  type: 'custom',
  key: 'trend',
  header: 'Trend',
  render: ({ row }) => {
    const el = document.createElement('span');
    const up = Number(row.change) >= 0;
    el.textContent = up ? '▲' : '▼';
    el.style.color = up ? 'var(--bo-up)' : 'var(--bo-down)';
    return el;
  },
}
```

> An HTML string is inserted as `innerHTML`. If it contains data you do not control, escape it first (`escapeHTML` is exported) or return a node instead.

For text-only changes, a `format(value, row)` function is simpler than any of these, and it also changes what copy and export produce.

## Tooltips and truncation

Long values end in an ellipsis. Set `tooltip` on a column to show the full text in a styled tooltip on hover: `true` shows the formatted value, and a function builds the text from the value and the row. Return `''` to show no tooltip for a cell.

```ts
const columns: ColumnDef[] = [
  { type: 'text', key: 'note', header: 'Notes', width: 220, tooltip: true },
  { type: 'badge', key: 'status', header: 'Status', tooltip: (value, row) => `${value} · ${row.role}` },
  { type: 'text', key: 'bio', header: 'Bio', flex: 1, wrap: true },
];
```

`wrap: true` lets a column's text wrap onto more lines instead of truncating; pair it with a taller [row height](https://bonguynvan.github.io/bo-grid/docs/row-height/).

## Header tooltips

`headerTooltip` shows the same tooltip on a column's header: what the column means, its units, its caveats. Add `headerInfo: true` for a small ⓘ that shows a tooltip is there:

```ts
{
  type: 'progress',
  key: 'workload',
  header: 'Workload',
  headerTooltip: 'Share of capacity allocated this sprint (0–100%).',
  headerInfo: true,
}
```

## In the gallery

Rank medals and score bars drawn with custom cells:

Live example: https://bonguynvan.github.io/bo-grid/examples/leaderboard/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/)

Rich cell types, a registered country type, tooltips and wrapping:

Live example: https://bonguynvan.github.io/bo-grid/examples/team/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/)

## Reference

- `cell`: `Snippet<[{ row: GridRow; column: ColumnDef; value: unknown }]>` — Render content for `type: 'custom'` columns.

  ```svelte
  <Grid {rows} {columns} height={480}>
    {#snippet cell({ row, column })}{#if column.key === 'chart'}<MiniChart sym={row.sym} />{/if}{/snippet}
  </Grid>
  ```


- `render`: `(ctx: CellRenderContext) => string | Node | null | undefined` — JS cell renderer — the framework-agnostic alternative to the `cell` snippet, for React/Vue/vanilla (and the `<bo-grid>` web component). Return an `HTMLElement`/`Node` (safe — appended as-is) or an HTML **string** (inserted as innerHTML — YOU must sanitize any untrusted data; prefer returning a Node for that). Overrides the cell's display only — sort, filter, tooltip, copy and export still use the value/`format`.

  ```ts
  { type: 'text', key: 'sym', header: 'Symbol', render: ({ value }) => Object.assign(document.createElement('b'), { textContent: String(value) }) }
  ```

- `component`: `Component<CellTypeProps>` — Svelte component that draws this cell (receives `value`, `row`, `column` and the formatted `text`). Display only, like `render`. Usually supplied by a registered `cellTypes` entry, but any column can set it. It updates in place as its props change; under `api.patchRows`, `row` is a fresh view of the patched row on each patch (same fields, new identity), so read it reactively (`$derived`, markup) rather than once at mount.

  ```ts
  { type: 'text', key: 'country', header: 'Country', component: CountryCell }
  ```

- `format`: `(value: unknown, row?: GridRow) => string` — Custom display formatter, overriding the built-in type formatter. Applies to display, tooltip, copy and (formatted) export. `row` is absent for aggregate cells.

  ```ts
  { type: 'number', key: 'qty', header: 'Qty', format: (v) => Number(v).toLocaleString('vi-VN') }
  ```

- `tooltip`: `boolean | ((value: unknown, row: GridRow) => string)` (default `false`) — Show a styled floating tooltip on each cell (themed, instant — replaces the native `title`). `true` shows the full formatted value (handy when content truncates); a function returns custom text per value/row (any column type, including `custom`). Return '' to suppress for a given cell.

  ```ts
  { type: 'text', key: 'sym', header: 'Symbol', tooltip: (_, row) => String(row.name ?? '') }
  ```

- `wrap`: `boolean` (default `false`) — Let long content wrap to multiple lines instead of truncating with an ellipsis. Pair with a taller `rowHeight`.
- `headerTooltip`: `string` (default `undefined`) — Show a styled tooltip on the column header (the same themed bubble as cell `tooltip`) — describe what the column means, units, caveats. Hovering anywhere on the header reveals it.
- `headerInfo`: `boolean` (default `false`) — Render a small ⓘ info icon in the header as a visible cue that a `headerTooltip` is available. Ignored without `headerTooltip`.

---
Page: https://bonguynvan.github.io/bo-grid/docs/custom-cells/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt
