Skip to content

Custom rendering ​

Cells ​

The easiest way to render custom content is a slot. #cell-<columnId> is used for one column, #cell for all columns.

The easiest way to render custom content is the cellRenderers prop: an object with a render function per column id, "*" is used for all columns.

Source
vue
<template>
  <Datagrid :columns="columns" :rows="rows" :options="options">
    <!-- slot for the cells of the column "name" -->
    <template #cell-name="{ data }">
      <span class="avatar">{{ data.firstName[0] }}{{ data.lastName[0] }}</span>
      <span>{{ data.firstName }} {{ data.lastName }}</span>
    </template>

    <!-- slot with access to the formatted text and the raw value -->
    <template #cell-rating="{ value }">
      <span class="stars" :title="`${value} / 5`">
        {{ "★".repeat(Math.round(value)) }}<span class="empty">{{ "★".repeat(5 - Math.round(value)) }}</span>
      </span>
    </template>

    <!-- custom header -->
    <template #header-salary="{ text }">💶 {{ text }}</template>
  </Datagrid>
</template>

<script setup lang="ts">
import { h } from "vue";
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createEmployees, type Employee } from "./data";

const rows = createEmployees(200);

// a cell component receives modelValue, text, item and column as props
const ProgressBar = (props: { modelValue: number }) =>
  h("div", { class: "progress" }, [
    h("div", { class: "progress__bar", style: { width: `${Math.min(100, props.modelValue / 1500)}%` } }),
  ]);

const columns: Array<ColumnConfig<Employee>> = [
  { id: "name", text: "Name", width: 220, valueGetter: ({ data }) => `${data.firstName} ${data.lastName}` },
  { field: "department", text: "Department", cellClass: ({ value }) => `dept-${String(value).toLowerCase()}` },
  { field: "rating", text: "Rating", type: "number", width: 120 },
  { field: "salary", text: "Salary", type: "number", valueFormatter: formatters.currency("EUR", { maximumFractionDigits: 0 }) },
  { id: "salaryBar", field: "salary", text: "Salary (component)", component: ProgressBar, width: 180 },
  { field: "email", text: "Email (tooltip)", tooltip: true, width: 180 },
];

const options: GridOptions<Employee> = {
  rowClass: (row) => (row.data.active ? undefined : "inactive-row"),
  defaultColumn: { sortable: true, resizeable: true, width: 150 },
};
</script>

<style>
.avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--ut-accent-color);
  color: white;
  font-size: 10px;
  font-weight: 700;
}

.stars {
  color: #e3b341;
  letter-spacing: 1px;
}

.stars .empty {
  color: var(--ut-border-color);
}

.progress {
  width: 100%;
  height: 8px;
  border-radius: 4px;
  background: var(--ut-row-border-color);
  overflow: hidden;
}

.progress__bar {
  height: 100%;
  background: var(--ut-accent-color);
}

.dept-engineering {
  color: #0969da;
}

.inactive-row {
  opacity: 0.55;
}
</style>
tsx
import {
  Datagrid,
  formatters,
  type ColumnConfig,
  type DatagridProps,
  type GridOptions,
} from "@datagrid/react-ui";
import { createEmployees, type Employee } from "../data";

const rows = createEmployees(200);

// a cell component receives value, text, row, data, column and setValue as props
const ProgressBar = (props: { value: number }) => (
  <div className="progress">
    <div className="progress__bar" style={{ width: `${Math.min(100, props.value / 1500)}%` }} />
  </div>
);

const columns: Array<ColumnConfig<Employee>> = [
  { id: "name", text: "Name", width: 220, valueGetter: ({ data }) => `${data.firstName} ${data.lastName}` },
  { field: "department", text: "Department", cellClass: ({ value }) => `dept-${String(value).toLowerCase()}` },
  { field: "rating", text: "Rating", type: "number", width: 120 },
  { field: "salary", text: "Salary", type: "number", valueFormatter: formatters.currency("EUR", { maximumFractionDigits: 0 }) },
  { id: "salaryBar", field: "salary", text: "Salary (component)", component: ProgressBar, width: 180 },
  { field: "email", text: "Email (tooltip)", tooltip: true, width: 180 },
];

const options: GridOptions<Employee> = {
  rowClass: (row) => (row.data.active ? undefined : "inactive-row"),
  defaultColumn: { sortable: true, resizeable: true, width: 150 },
};

const cellRenderers: DatagridProps<Employee>["cellRenderers"] = {
  // renderer for the cells of the column "name"
  name: ({ data }) => (
    <>
      <span className="avatar">
        {data.firstName[0]}
        {data.lastName[0]}
      </span>
      <span>
        {data.firstName} {data.lastName}
      </span>
    </>
  ),
  // renderer with access to the formatted text and the raw value
  rating: ({ value }) => (
    <span className="stars" title={`${value} / 5`}>
      {"★".repeat(Math.round(value))}
      <span className="empty">{"★".repeat(5 - Math.round(value))}</span>
    </span>
  ),
};

// custom header
const headerRenderers: DatagridProps<Employee>["headerRenderers"] = {
  salary: ({ text }) => <>💶 {text}</>,
};

export default function CustomRendering() {
  return (
    <>
      <Datagrid
        columns={columns}
        rows={rows}
        options={options}
        cellRenderers={cellRenderers}
        headerRenderers={headerRenderers}
      />
      <style>{`
        .avatar {
          display: inline-flex;
          align-items: center;
          justify-content: center;
          width: 22px;
          height: 22px;
          border-radius: 50%;
          background: var(--ut-accent-color);
          color: white;
          font-size: 10px;
          font-weight: 700;
        }

        .stars {
          color: #e3b341;
          letter-spacing: 1px;
        }

        .stars .empty {
          color: var(--ut-border-color);
        }

        .progress {
          width: 100%;
          height: 8px;
          border-radius: 4px;
          background: var(--ut-row-border-color);
          overflow: hidden;
        }

        .progress__bar {
          height: 100%;
          background: var(--ut-accent-color);
        }

        .dept-engineering {
          color: #0969da;
        }

        .inactive-row {
          opacity: 0.55;
        }
      `}</style>
    </>
  );
}

Parameters:

ParameterDescription
valueraw value
textformatted value
datadata object of the row
rowrow object
columncolumn
setValue(value)changes the value (like editing)
apigrid api

Badges, avatars and progress bars ​

Render the value as a visual element when users scan a list rather than read it: a colored status badge, an avatar with initials, an icon for the priority or a progress bar. The value stays the same, so sorting, filtering and the export still work with it – Priority is sorted by its meaning with a sortFunc, Progress shows the formatted text (text) next to the bar.

Source
vue
<template>
  <Datagrid :columns="columns" :rows="rows" :options="options">
    <template #cell-assignee="{ value }">
      <span class="rb-avatar" :style="{ background: avatarColor(value) }">{{ initials(value) }}</span>
      {{ value }}
    </template>

    <template #cell-status="{ value }">
      <span class="rb-badge" :class="`rb-badge--${value.replace(' ', '-')}`">{{ value }}</span>
    </template>

    <template #cell-priority="{ value }">
      <span class="rb-priority" :class="`rb-priority--${value}`">{{ priorityIcons[value] }} {{ value }}</span>
    </template>

    <template #cell-progress="{ value, text }">
      <div class="rb-progress" :title="text">
        <div class="rb-progress__bar" :style="{ width: `${value * 100}%` }"></div>
      </div>
      <span class="rb-progress__text">{{ text }}</span>
    </template>
  </Datagrid>
</template>

<script setup lang="ts">
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createTasks, type Task } from "./data";

const rows = createTasks(200);

const priorityIcons: Record<string, string> = { high: "▲", medium: "●", low: "▼" };
const colors = ["#0969da", "#8250df", "#bf3989", "#1a7f37", "#9a6700", "#cf222e"];

const initials = (name: string) => name.split(" ").map((part) => part[0]).join("");
const avatarColor = (name: string) => colors[name.length % colors.length];

const columns: Array<ColumnConfig<Task>> = [
  { field: "title", text: "Task", width: 210 },
  { field: "assignee", text: "Assignee", width: 190 },
  { field: "status", text: "Status", width: 130 },
  // sorting uses the value, not the rendered content
  {
    field: "priority",
    text: "Priority",
    width: 120,
    sortFunc: (a, b) => ["low", "medium", "high"].indexOf(a.value) - ["low", "medium", "high"].indexOf(b.value),
  },
  // the formatted text (percent) is available in the slot as "text"
  { field: "progress", text: "Progress", width: 170, valueFormatter: formatters.percent({ maximumFractionDigits: 0 }) },
  { field: "due", text: "Due", type: "date" },
];

const options: GridOptions<Task> = {
  defaultColumn: { sortable: true, resizeable: true },
};
</script>

<style>
.rb-avatar {
  display: inline-flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  margin-right: 8px;
  border-radius: 50%;
  color: white;
  font-size: 10px;
  font-weight: 700;
}
.rb-badge {
  padding: 1px 8px;
  border-radius: 10px;
  font-size: 12px;
  font-weight: 600;
  background: color-mix(in srgb, var(--rb-color) 15%, transparent);
  color: var(--rb-color);
}
.rb-badge--todo { --rb-color: #6e7781; }
.rb-badge--in-progress { --rb-color: #0969da; }
.rb-badge--review { --rb-color: #9a6700; }
.rb-badge--done { --rb-color: #1a7f37; }
.rb-priority--high { color: #cf222e; }
.rb-priority--medium { color: #9a6700; }
.rb-priority--low { color: var(--ut-muted-color); }
.rb-progress {
  flex: 1;
  height: 6px;
  border-radius: 3px;
  background: var(--ut-row-border-color);
  overflow: hidden;
}
.rb-progress__bar {
  height: 100%;
  background: var(--ut-accent-color);
}
.rb-progress__text {
  width: 40px;
  margin-left: 8px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
</style>
tsx
import {
  Datagrid,
  formatters,
  type ColumnConfig,
  type DatagridProps,
  type GridOptions,
} from "@datagrid/react-ui";
import { createTasks, type Task } from "../data";

const rows = createTasks(200);

const priorityIcons: Record<string, string> = { high: "▲", medium: "●", low: "▼" };
const colors = ["#0969da", "#8250df", "#bf3989", "#1a7f37", "#9a6700", "#cf222e"];

const initials = (name: string) => name.split(" ").map((part) => part[0]).join("");
const avatarColor = (name: string) => colors[name.length % colors.length];

const columns: Array<ColumnConfig<Task>> = [
  { field: "title", text: "Task", width: 210 },
  { field: "assignee", text: "Assignee", width: 190 },
  { field: "status", text: "Status", width: 130 },
  // sorting uses the value, not the rendered content
  {
    field: "priority",
    text: "Priority",
    width: 120,
    sortFunc: (a, b) => ["low", "medium", "high"].indexOf(a.value) - ["low", "medium", "high"].indexOf(b.value),
  },
  // the formatted text (percent) is available in the renderer as "text"
  { field: "progress", text: "Progress", width: 170, valueFormatter: formatters.percent({ maximumFractionDigits: 0 }) },
  { field: "due", text: "Due", type: "date" },
];

const options: GridOptions<Task> = {
  defaultColumn: { sortable: true, resizeable: true },
};

const cellRenderers: DatagridProps<Task>["cellRenderers"] = {
  assignee: ({ value }) => (
    <>
      <span className="rb-avatar" style={{ background: avatarColor(value) }}>
        {initials(value)}
      </span>
      {value}
    </>
  ),
  status: ({ value }) => <span className={`rb-badge rb-badge--${value.replace(" ", "-")}`}>{value}</span>,
  priority: ({ value }) => (
    <span className={`rb-priority rb-priority--${value}`}>
      {priorityIcons[value]} {value}
    </span>
  ),
  progress: ({ value, text }) => (
    <>
      <div className="rb-progress" title={text}>
        <div className="rb-progress__bar" style={{ width: `${value * 100}%` }} />
      </div>
      <span className="rb-progress__text">{text}</span>
    </>
  ),
};

export default function RenderBadges() {
  return (
    <>
      <Datagrid columns={columns} rows={rows} options={options} cellRenderers={cellRenderers} />
      <style>{`
        .rb-avatar {
          display: inline-flex;
          flex: none;
          align-items: center;
          justify-content: center;
          width: 24px;
          height: 24px;
          margin-right: 8px;
          border-radius: 50%;
          color: white;
          font-size: 10px;
          font-weight: 700;
        }
        .rb-badge {
          padding: 1px 8px;
          border-radius: 10px;
          font-size: 12px;
          font-weight: 600;
          background: color-mix(in srgb, var(--rb-color) 15%, transparent);
          color: var(--rb-color);
        }
        .rb-badge--todo { --rb-color: #6e7781; }
        .rb-badge--in-progress { --rb-color: #0969da; }
        .rb-badge--review { --rb-color: #9a6700; }
        .rb-badge--done { --rb-color: #1a7f37; }
        .rb-priority--high { color: #cf222e; }
        .rb-priority--medium { color: #9a6700; }
        .rb-priority--low { color: var(--ut-muted-color); }
        .rb-progress {
          flex: 1;
          height: 6px;
          border-radius: 3px;
          background: var(--ut-row-border-color);
          overflow: hidden;
        }
        .rb-progress__bar {
          height: 100%;
          background: var(--ut-accent-color);
        }
        .rb-progress__text {
          width: 40px;
          margin-left: 8px;
          text-align: right;
          font-variant-numeric: tabular-nums;
        }
      `}</style>
    </>
  );
}

Action buttons ​

A column without field can hold buttons for row actions. The render function receives the row and the api, so a button can start editing (api.startEdit), change values (api.setValue, which emits cell-value-changed like editing) or remove the row from your data. With a rowId the other rows keep their state when the rows change.

Source
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <button @click="rows = createTasks(20)">Reset</button>
      <span>{{ rows.length }} tasks</span>
    </div>
    <Datagrid :columns="columns" :rows="rows" :options="options" @cell-value-changed="onChanged">
      <template #cell-actions="{ row, data, api }">
        <span class="ra-actions">
          <button title="Edit title" @click="api.startEdit({ item: row, column: api.getColumn('title')! })">✏️</button>
          <button title="Mark as done" :disabled="data.status === 'done'" @click="complete(api, row)">✔️</button>
          <button title="Delete" class="ra-delete" @click="remove(data)">🗑️</button>
        </span>
      </template>
    </Datagrid>
    <div class="demo-log">{{ log }}</div>
  </div>
</template>

<script setup lang="ts">
import { ref, shallowRef } from "vue";
import {
  Datagrid,
  type CellValueChangedEvent,
  type ColumnConfig,
  type Core,
  type GridOptions,
  type Row,
} from "@datagrid/vue-ui";
import { createTasks, type Task } from "./data";

const rows = shallowRef(createTasks(20));
const log = ref("Use the buttons in the last column.");

function complete(api: Core, row: Row<Task>) {
  // setValue changes the data like editing (events, sorting, filtering)
  api.setValue(api.getColumn("status")!, row, "done");
}

function remove(task: Task) {
  // the grid shows the new array, the other rows keep their state (rowId)
  rows.value = rows.value.filter((el) => el.id !== task.id);
  log.value = `deleted "${task.title}"`;
}

function onChanged({ data, column, newValue }: CellValueChangedEvent<Task>) {
  log.value = `task ${data.id}: ${column.id} = ${newValue}`;
}

const columns: Array<ColumnConfig<Task>> = [
  { field: "id", text: "#", type: "number", width: 60 },
  { field: "title", text: "Task", flex: 1, editable: true },
  { field: "assignee", text: "Assignee", width: 160 },
  { field: "status", text: "Status", width: 120 },
  // a column without field only for the buttons
  { id: "actions", text: "Actions", width: 130, sortable: false, align: "center" },
];

const options: GridOptions<Task> = {
  rowId: (data) => data.id,
  defaultColumn: { sortable: true },
};
</script>

<style>
.ra-actions {
  display: flex;
  gap: 4px;
}
.ra-actions button {
  padding: 0 6px;
  border-radius: 4px;
  line-height: 24px;
}
.ra-actions button:hover:not(:disabled) {
  background: var(--ut-row-hover-background);
}
.ra-actions button:disabled {
  opacity: 0.3;
  cursor: default;
}
.ra-actions .ra-delete:hover {
  background: color-mix(in srgb, #cf222e 15%, transparent);
}
</style>
tsx
import { useMemo, useState } from "react";
import {
  Datagrid,
  type CellValueChangedEvent,
  type ColumnConfig,
  type Core,
  type DatagridProps,
  type GridOptions,
  type Row,
} from "@datagrid/react-ui";
import { createTasks, type Task } from "../data";

const columns: Array<ColumnConfig<Task>> = [
  { field: "id", text: "#", type: "number", width: 60 },
  { field: "title", text: "Task", flex: 1, editable: true },
  { field: "assignee", text: "Assignee", width: 160 },
  { field: "status", text: "Status", width: 120 },
  // a column without field only for the buttons
  { id: "actions", text: "Actions", width: 130, sortable: false, align: "center" },
];

const options: GridOptions<Task> = {
  rowId: (data) => data.id,
  defaultColumn: { sortable: true },
};

function complete(api: Core, row: Row<Task>) {
  // setValue changes the data like editing (events, sorting, filtering)
  api.setValue(api.getColumn("status")!, row, "done");
}

export default function RenderActions() {
  const [rows, setRows] = useState(() => createTasks(20));
  const [log, setLog] = useState("Use the buttons in the last column.");

  function remove(task: Task) {
    // the grid shows the new array, the other rows keep their state (rowId)
    setRows((current) => current.filter((el) => el.id !== task.id));
    setLog(`deleted "${task.title}"`);
  }

  const cellRenderers = useMemo<DatagridProps<Task>["cellRenderers"]>(
    () => ({
      actions: ({ row, data, api }) => (
        <span className="ra-actions">
          <button title="Edit title" onClick={() => api.startEdit({ item: row, column: api.getColumn("title")! })}>
            ✏️
          </button>
          <button title="Mark as done" disabled={data.status === "done"} onClick={() => complete(api, row)}>
            ✔️
          </button>
          <button title="Delete" className="ra-delete" onClick={() => remove(data)}>
            🗑️
          </button>
        </span>
      ),
    }),
    []
  );

  function onChanged({ data, column, newValue }: CellValueChangedEvent<Task>) {
    setLog(`task ${data.id}: ${column.id} = ${newValue}`);
  }

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <button onClick={() => setRows(createTasks(20))}>Reset</button>
        <span>{rows.length} tasks</span>
      </div>
      <Datagrid
        columns={columns}
        rows={rows}
        options={options}
        cellRenderers={cellRenderers}
        onCellValueChanged={onChanged}
      />
      <div className="demo-log">{log}</div>
      <style>{`
        .ra-actions {
          display: flex;
          gap: 4px;
        }
        .ra-actions button {
          padding: 0 6px;
          border-radius: 4px;
          line-height: 24px;
        }
        .ra-actions button:hover:not(:disabled) {
          background: var(--ut-row-hover-background);
        }
        .ra-actions button:disabled {
          opacity: 0.3;
          cursor: default;
        }
        .ra-actions .ra-delete:hover {
          background: color-mix(in srgb, #cf222e 15%, transparent);
        }
      `}</style>
    </div>
  );
}

Cell components ​

Alternatively a component can be set in the column definition with component. Components are useful when the column definitions are created dynamically or shared between grids.

It receives the props modelValue (the value), text, item (the row) and column and additional props from componentProps:

ts
import StatusBadge from "./StatusBadge.vue";

{
  field: "status",
  component: StatusBadge,
  componentProps: ({ value }) => ({ color: value === "done" ? "green" : "orange" }),
}

Emitting update:modelValue changes the value of the cell.

It receives the same parameters as cellRenderers as props (plus onChange to change the value) and additional props from componentProps:

tsx
{
  field: "status",
  component: StatusBadge,
  componentProps: ({ value }) => ({ color: value === "done" ? "green" : "orange" }),
}

Headers ​

#header-<columnId> (or #header) replaces the header text. It receives column, text and api.

vue
<template #header-price="{ text }">
  {{ text }} <small>(EUR)</small>
</template>

headerRenderers (by column id or "*") replaces the header text. The functions receive column, text and api.

tsx
<Datagrid
  headerRenderers={{ price: ({ text }) => <>{text} <small>(EUR)</small></> }}
  ...
/>

Sorting and the column menu keep working. Use the class ut-ignore for interactive elements which should not sort the column when clicked. headerComponent in the column definition is the component based alternative.

The demo uses a renderer for all headers which shows an icon from the column's meta data, and a special header for Salary with a second line and an info button (ut-ignore). For a plain tooltip no renderer is needed: headerTooltip sets the native tooltip of the header (hover Rating or Remote). headerRowHeight makes room for two lines.

Source
vue
<template>
  <div class="demo">
    <Datagrid :columns="columns" :rows="rows" :options="options">
      <!-- all headers: icon from the column meta data + text -->
      <template #header="{ column, text }">
        <span v-if="column.meta?.icon" class="rh-icon">{{ column.meta.icon }}</span>
        <span class="rh-text">{{ text }}</span>
      </template>

      <!-- a single header with a second line and an info button -->
      <template #header-salary="{ text }">
        <span class="rh-two-lines">
          <span>💶 {{ text }}</span>
          <small>EUR / year</small>
        </span>
        <!-- ut-ignore: clicking the button does not sort the column -->
        <button class="rh-info ut-ignore" title="What is included?" @click="info = !info">ⓘ</button>
      </template>
    </Datagrid>
    <div class="demo-log">
      {{ info ? "Salary: gross yearly salary without bonus." : "Hover the headers of Rating and Remote, click ⓘ or sort a column." }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createEmployees, type Employee } from "./data";

const rows = createEmployees(200);
const info = ref(false);

const columns: Array<ColumnConfig<Employee>> = [
  { field: "lastName", text: "Name", meta: { icon: "👤" } },
  { field: "department", text: "Department", meta: { icon: "🏢" } },
  { field: "city", text: "City", meta: { icon: "📍" } },
  {
    field: "salary",
    text: "Salary",
    type: "number",
    width: 170,
    valueFormatter: formatters.number({ maximumFractionDigits: 0 }),
  },
  // headerTooltip: native tooltip of the header cell
  { field: "rating", text: "Rating", type: "number", meta: { icon: "⭐" }, headerTooltip: "Average rating of the last review (0–5)" },
  { field: "remote", text: "Remote", type: "boolean", meta: { icon: "🏠" }, headerTooltip: "Works remotely at least 3 days a week" },
];

const options: GridOptions<Employee> = {
  headerRowHeight: 48,
  defaultColumn: { sortable: true, resizeable: true, width: 140 },
};
</script>

<style>
.rh-icon {
  margin-right: 6px;
}
.rh-text {
  overflow: hidden;
  text-overflow: ellipsis;
}
.rh-two-lines {
  display: flex;
  flex-direction: column;
  line-height: 1.2;
}
.rh-two-lines small {
  font-weight: normal;
  color: var(--ut-muted-color);
}
.rh-info {
  margin-left: 6px;
  color: var(--ut-accent-color);
  cursor: pointer;
}
</style>
tsx
import { useState } from "react";
import {
  Datagrid,
  formatters,
  type ColumnConfig,
  type DatagridProps,
  type GridOptions,
} from "@datagrid/react-ui";
import { createEmployees, type Employee } from "../data";

const rows = createEmployees(200);

const columns: Array<ColumnConfig<Employee>> = [
  { field: "lastName", text: "Name", meta: { icon: "👤" } },
  { field: "department", text: "Department", meta: { icon: "🏢" } },
  { field: "city", text: "City", meta: { icon: "📍" } },
  {
    field: "salary",
    text: "Salary",
    type: "number",
    width: 170,
    valueFormatter: formatters.number({ maximumFractionDigits: 0 }),
  },
  // headerTooltip: native tooltip of the header cell
  { field: "rating", text: "Rating", type: "number", meta: { icon: "⭐" }, headerTooltip: "Average rating of the last review (0–5)" },
  { field: "remote", text: "Remote", type: "boolean", meta: { icon: "🏠" }, headerTooltip: "Works remotely at least 3 days a week" },
];

const options: GridOptions<Employee> = {
  headerRowHeight: 48,
  defaultColumn: { sortable: true, resizeable: true, width: 140 },
};

export default function RenderHeaders() {
  const [info, setInfo] = useState(false);

  const headerRenderers: DatagridProps<Employee>["headerRenderers"] = {
    // all headers: icon from the column meta data + text
    "*": ({ column, text }) => (
      <>
        {column.meta?.icon && <span className="rh-icon">{column.meta.icon}</span>}
        <span className="rh-text">{text}</span>
      </>
    ),
    // a single header with a second line and an info button
    salary: ({ text }) => (
      <>
        <span className="rh-two-lines">
          <span>💶 {text}</span>
          <small>EUR / year</small>
        </span>
        {/* ut-ignore: clicking the button does not sort the column */}
        <button className="rh-info ut-ignore" title="What is included?" onClick={() => setInfo((value) => !value)}>
          ⓘ
        </button>
      </>
    ),
  };

  return (
    <div className="demo">
      <Datagrid columns={columns} rows={rows} options={options} headerRenderers={headerRenderers} />
      <div className="demo-log">
        {info
          ? "Salary: gross yearly salary without bonus."
          : "Hover the headers of Rating and Remote, click ⓘ or sort a column."}
      </div>
      <style>{`
        .rh-icon {
          margin-right: 6px;
        }
        .rh-text {
          overflow: hidden;
          text-overflow: ellipsis;
        }
        .rh-two-lines {
          display: flex;
          flex-direction: column;
          line-height: 1.2;
        }
        .rh-two-lines small {
          font-weight: normal;
          color: var(--ut-muted-color);
        }
        .rh-info {
          margin-left: 6px;
          color: var(--ut-accent-color);
          cursor: pointer;
        }
      `}</style>
    </div>
  );
}

Filters ​

A custom floating filter receives column, value, setValue(value) and api. The value can be of any type, combine it with a custom filter function. The CRM showcase uses a range slider as filter.

#filter-<columnId> (or #filter) replaces the floating filter:

vue
<template #filter-tags="{ value, setValue }">
  <select :value="value" @change="setValue($event.target.value || undefined)">
    <option value="">all</option>
    <option value="urgent">urgent</option>
  </select>
</template>

filterRenderers (by column id or "*") replaces the floating filter:

tsx
<Datagrid
  filterRenderers={{
    tags: ({ value, setValue }) => (
      <select value={String(value ?? "")} onChange={(e) => setValue(e.target.value || undefined)}>
        <option value="">all</option>
        <option value="urgent">urgent</option>
      </select>
    ),
  }}
  ...
/>

Group rows, details and editors ​

Overlays ​

SlotDescription
#emptyshown when there are no rows
#loadingshown while loading
#toolbararea above the grid, receives api
#footerarea below the grid, receives api
#overlayabsolutely positioned layer on top of the grid
PropDescription
renderEmptyshown when there are no rows
renderLoadingshown while loading
toolbararea above the grid, node or (api) => node
footerarea below the grid, node or (api) => node
overlayabsolutely positioned layer on top of the grid

Replace the default texts when the grid is part of a workflow: explain why the list is empty and offer the next step. The loading content is shown as long as rows is undefined (or a dataGetter loads the first page), the empty content when there are no rows to show – because there is no data or because the filters remove all rows. Click Reload, No rows or search for something that doesn't exist:

Source
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <button @click="load">Reload (1.5 s)</button>
      <button @click="rows = []">No rows</button>
      <input v-model="search" type="search" placeholder="Search…" />
    </div>
    <Datagrid :columns="columns" :rows="rows" :options="options" v-model:globalFilter="search">
      <!-- shown while rows is undefined (or while a dataGetter is loading) -->
      <template #loading>
        <span class="ro-spinner"></span>
        Loading employees…
      </template>

      <!-- shown when there are no rows (no data or everything filtered out) -->
      <template #empty>
        <div class="ro-empty">
          <div class="ro-empty__icon">🔍</div>
          <template v-if="search">
            No employee matches "{{ search }}".
            <button @click="search = ''">Clear search</button>
          </template>
          <template v-else>
            No employees yet.
            <button @click="load">Load employees</button>
          </template>
        </div>
      </template>
    </Datagrid>
  </div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, ref, shallowRef } from "vue";
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createEmployees, type Employee } from "./data";

// undefined = still loading
const rows = shallowRef<Array<Employee>>();
const search = ref("");
let timer: ReturnType<typeof setTimeout> | undefined;

function load() {
  rows.value = undefined;
  clearTimeout(timer);
  timer = setTimeout(() => (rows.value = createEmployees(50)), 1500);
}

load();
onBeforeUnmount(() => clearTimeout(timer));

const columns: Array<ColumnConfig<Employee>> = [
  { field: "firstName", text: "First name" },
  { field: "lastName", text: "Last name" },
  { field: "department", text: "Department" },
  { field: "city", text: "City" },
];

const options: GridOptions<Employee> = {
  defaultColumn: { flex: 1, sortable: true },
};
</script>

<style>
.ro-spinner {
  display: inline-block;
  width: 16px;
  height: 16px;
  margin-right: 8px;
  vertical-align: middle;
  border: 2px solid var(--ut-border-color);
  border-top-color: var(--ut-accent-color);
  border-radius: 50%;
  animation: ro-spin 0.8s linear infinite;
}
@keyframes ro-spin {
  to {
    transform: rotate(360deg);
  }
}
.ro-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  color: var(--ut-muted-color);
}
.ro-empty__icon {
  font-size: 28px;
}
.ro-empty button {
  padding: 2px 12px;
  border: 1px solid var(--ut-border-color);
  border-radius: 6px;
  color: var(--ut-color);
}
</style>
tsx
import { useEffect, useRef, useState } from "react";
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
import { createEmployees, type Employee } from "../data";

const columns: Array<ColumnConfig<Employee>> = [
  { field: "firstName", text: "First name" },
  { field: "lastName", text: "Last name" },
  { field: "department", text: "Department" },
  { field: "city", text: "City" },
];

const options: GridOptions<Employee> = {
  defaultColumn: { flex: 1, sortable: true },
};

export default function RenderOverlays() {
  // undefined = still loading
  const [rows, setRows] = useState<Array<Employee>>();
  const [search, setSearch] = useState("");
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

  function load() {
    setRows(undefined);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setRows(createEmployees(50)), 1500);
  }

  useEffect(() => {
    load();
    return () => clearTimeout(timer.current);
  }, []);

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <button onClick={load}>Reload (1.5 s)</button>
        <button onClick={() => setRows([])}>No rows</button>
        <input value={search} onChange={(e) => setSearch(e.target.value)} type="search" placeholder="Search…" />
      </div>
      <Datagrid
        columns={columns}
        rows={rows}
        options={options}
        globalFilter={search}
        onGlobalFilterChange={(value) => setSearch(String(value ?? ""))}
        // shown while rows is undefined (or while a dataGetter is loading)
        renderLoading={() => (
          <>
            <span className="ro-spinner"></span>
            Loading employees…
          </>
        )}
        // shown when there are no rows (no data or everything filtered out)
        renderEmpty={() => (
          <div className="ro-empty">
            <div className="ro-empty__icon">🔍</div>
            {search ? (
              <>
                No employee matches "{search}".
                <button onClick={() => setSearch("")}>Clear search</button>
              </>
            ) : (
              <>
                No employees yet.
                <button onClick={load}>Load employees</button>
              </>
            )}
          </div>
        )}
      />
      <style>{`
        .ro-spinner {
          display: inline-block;
          width: 16px;
          height: 16px;
          margin-right: 8px;
          vertical-align: middle;
          border: 2px solid var(--ut-border-color);
          border-top-color: var(--ut-accent-color);
          border-radius: 50%;
          animation: ro-spin 0.8s linear infinite;
        }
        @keyframes ro-spin {
          to {
            transform: rotate(360deg);
          }
        }
        .ro-empty {
          display: flex;
          flex-direction: column;
          align-items: center;
          gap: 8px;
          color: var(--ut-muted-color);
        }
        .ro-empty__icon {
          font-size: 28px;
        }
        .ro-empty button {
          padding: 2px 12px;
          border: 1px solid var(--ut-border-color);
          border-radius: 6px;
          color: var(--ut-color);
        }
      `}</style>
    </div>
  );
}

Classes and styles ​

ts
// columns
{ field: "amount", cellClass: ({ value }) => (value < 0 ? "negative" : undefined) }
{ field: "amount", cellStyle: ({ value }) => ({ color: value < 0 ? "red" : undefined }) }
{ field: "amount", headerCellClass: "money-header" }

// rows (grid options)
rowClass: (row) => (row.data.archived ? "archived" : undefined),
rowStyle: (row) => ({ fontWeight: row.data.important ? "bold" : undefined }),

Results of cellClass, cellStyle, rowClass and rowStyle are cached and recalculated when the row data changes.

Tooltips ​

ts
{ field: "description", tooltip: true } // formatted value
{ field: "status", tooltip: ({ data }) => `changed by ${data.user}` }
{ field: "status", headerTooltip: "Current processing status" }

Released under the ISC License.