# hs-uix components

HubSpot's own UI extension primitives stop at the widget layer: text, tiles, buttons, a flat table. hs-uix is the layer above — collections that already know about sorting, filtering, pagination, selection, inline editing, WIP limits, swimlanes, and the shape HubSpot's CRM search API returns. This guide is the map: which component fits which shape of data, the handful of props on each that actually change the design, and the CRM-connected variants that skip the fetch entirely.

## A collection component owns the chrome, you own the data

Every component in this guide takes the same shape: **an array of rows and a description of how to display them.** None of them fetch. `data` in, JSX out.

That split is deliberate, and it is the thing to internalise before reading the rest of this page. Fetching inside a UI extension means `runServerless` against your card backend, which means auth, the sandbox, and a request budget — all of which belong to your Worker, not to a table. So `DataTable`, `Kanban`, `Feed`, and `Calendar` all take `data` (or `items` / `events`) plus `loading` and `error`, and never a `fetch` prop. If you are looking for one, you want the [CRM-connected components](#skip-the-fetch-with-the-crm-connected-components) at the end of this guide.

What the component *does* own is everything between the raw array and the pixels: search, filters, sort, pagination, selection and bulk actions, grouping, empty and loading and error states, and i18n through a `labels` prop. That is the reason to reach for one instead of mapping over an array yourself — not the markup, which is easy, but the twenty pieces of state around it, which are not.

The subpaths are separate entry points, so you only pay for what you import:

```tsx
import { DataTable } from 'hs-uix/datatable';
import { Kanban } from 'hs-uix/kanban';
import { FormBuilder } from 'hs-uix/form';
import { Feed } from 'hs-uix/feed';
import { Calendar } from 'hs-uix/calendar';
import { FilterBuilder } from 'hs-uix/filter';
import { KeyValueList, SectionHeader } from 'hs-uix/common-components';
import { formatCurrency } from 'hs-uix/utils';
```

Bundle size is a real constraint inside HubSpot's sandbox, so import from the subpath rather than the package root. See the [performance budget](/docs/guides/ui-extensions) section of the UI extensions guide for the limits.

## Step 1 — Pick the component that matches the shape of your data

The choice is almost always decided by one question: **what is the primary axis your user reads along?**

| Your data is… | Reach for | Because |
| --- | --- | --- |
| A list of records with comparable fields | `DataTable` | Columns make values comparable down a column |
| A list of records that live in *stages* | `Kanban` | The stage becomes the axis; moving between them is the interaction |
| A list of events with *timestamps* | `Feed` | Chronology is the axis; new items arrive at the top |
| A list of events with *start and end times* | `Calendar` | Duration and collision only read on a time grid |
| Fields a user has to fill in | `FormBuilder` | Validation, steps, and conditional fields are the hard part |
| A query the user is *composing* | `FilterBuilder` | The output is a filter tree, not a rendering |

Two of these overlap more than the table suggests, and it is worth being explicit about it. `DataTable` and `Kanban` are the same data with different axes — the same deal list is a table when you want to compare amounts and a board when you want to move things through a pipeline. They are close enough that `hs-uix/utils` ships `deriveCardFieldsFromColumns`, which projects a `DataTable` `columns` config into `Kanban` `cardFields` in one call, so offering both views of one collection is not two configs to maintain:

```tsx
import { deriveCardFieldsFromColumns } from 'hs-uix/utils';

const CARD_FIELDS = deriveCardFieldsFromColumns(COLUMNS, { titleField: 'name' });
```

Similarly, `Feed` and `Calendar` are both time, but a feed is *when it happened* and a calendar is *how long it takes and what it collides with*. If your records have no end time, you want a feed.

## Step 2 — Render a collection with DataTable

`DataTable` is the workhorse, and it has by far the largest surface — around seventy props. You will use six of them.

```tsx
import { DataTable } from 'hs-uix/datatable';
import { formatCurrency } from 'hs-uix/utils';

<DataTable
  data={deals}
  loading={loading}
  error={error}
  columns={[
    { id: 'name', label: 'Deal', sortable: true },
    { id: 'amount', label: 'Amount', render: (v) => formatCurrency(v) },
    { id: 'stage', label: 'Stage' },
  ]}
  searchFields={['name']}
  pageSize={25}
/>
```

That is the whole basic case. Everything below is the reason to use it over a hand-rolled table.

### Selection and bulk actions

`selectable` turns on checkboxes and a selection bar. `selectionActions` are the buttons that appear in it. The prop worth knowing about is `onSelectAllRequest`: when a user ticks "select all" on a server-paginated table they mean *all matching rows*, not *the 25 on screen*, and this callback hands you the current search and filter state so you can act on the real set rather than the visible page.

```tsx
<DataTable
  selectable
  rowIdField="id"
  selectionActions={[{ label: 'Change owner', onClick: (ids) => reassign(ids) }]}
  onSelectAllRequest={(payload) => reassignMatching(payload)}
  recordLabel={{ singular: 'deal', plural: 'deals' }}
/>
```

### Inline editing

`editMode` puts a row into an editable state and `onRowEdit` receives the result. This is the difference between a report and a tool — a rep can fix an amount without leaving the record.

### Grouping

`groupBy` buckets rows under collapsible headers. Grouping deals by stage inside a table is often the right answer when a board would be too much furniture for four records.

### Server-side mode

Set `serverSide` and the component stops filtering and paging in the browser; `onParamsChange` then fires with the full `{ search, filters, sort, page }` state and you re-fetch. Use it once your collection outgrows what is sensible to ship to the iframe in one response.

### Empty, loading, and error states

`emptyTitle` / `emptyMessage` cover the common case; `renderEmptyState`, `renderLoadingState`, and `renderErrorState` take over entirely when you need something bespoke. Do fill these in. An empty table with no explanation is the most common way a good card feels broken.

## Step 3 — Turn the same collection into a board with Kanban

`Kanban` takes the same rows and swaps the axis for a stage. What it adds over a table is the stage model itself.

```tsx
import { Kanban } from 'hs-uix/kanban';
import { formatCurrency } from 'hs-uix/utils';

const STAGES = [
  { value: 'discovery', label: 'Discovery' },
  { value: 'proposal', label: 'Proposal sent', wipLimit: 3, variant: 'warning' },
  { value: 'negotiation', label: 'Negotiation' },
  { value: 'closedwon', label: 'Closed won', variant: 'success', terminal: true },
];

<Kanban
  data={deals}
  stages={STAGES}
  groupBy="stage"
  cardFields={[
    { field: 'name', placement: 'title' },
    { field: 'amount', placement: 'meta', render: (v) => formatCurrency(v) },
    { field: 'owner', placement: 'footer' },
  ]}
  onStageChange={(row, newStage, oldStage) => advance(row.id, newStage)}
/>
```

### cardFields is the whole card design

Each field declares a `placement` — `title`, `subtitle`, `meta`, `body`, or `footer` — and optionally a `render`, an `href`, `truncate`, and a `visible` predicate. There is no card template to write; you are describing where values land.

### WIP limits are a signal, not a gate

Set `wipLimit` on a stage and the header renders `count / limit` with an "Over WIP" tag once exceeded. `onWipExceeded` fires **once per crossing**, including on a board that mounts already over the limit — not on every render.

Read the rest of that behaviour carefully before you build on it: **an over-limit transition still completes.** The server is the source of truth, and the board will not block a drag. If you need a hard gate, enforce it in the handler behind `onStageChange` and let the rejection come back, rather than expecting the component to refuse.

### Stage transition prompts

A stage can require input before a card enters it — declared per stage via `stage.onEnterRequired.render`, which lets you capture a close reason or a lost-to competitor before the move commits. `onStageChange` then receives that result as its fourth argument.

### Swimlanes

`swimlaneBy` groups the board vertically as well as horizontally — deals by stage across, by owner down. With `metricsPerLane` the metrics panel renders inside each lane instead of globally. This is the point at which a board stops being a prettier list and starts being a management view.

### Per-stage pagination

`stageMeta` plus `onLoadMore` lets each column paginate independently, so you can mix client-side columns and server-loaded ones on the same board.

## Step 4 — Build a form from your portal’s own properties

`FormBuilder` takes a `fields` array and handles validation, layout, and submission. The reason to use it inside a HubSpot extension specifically is `fieldsFromHubSpotProperties`.

```tsx
import { FormBuilder, fieldsFromHubSpotProperties } from 'hs-uix/form';

const fields = fieldsFromHubSpotProperties(properties);

<FormBuilder fields={fields} onSubmit={(values) => save(values)} />
```

Hand it the property definitions your portal returns and it produces the matching field set — an enumeration property becomes a `select` with its real options, a date property becomes a date field, a currency property becomes a currency field. You are not restating your portal's schema in a second place where it can drift.

The field types available are `text`, `password`, `textarea`, `number`, `stepper`, `currency`, `date`, `time`, `datetime`, `select`, `multiselect`, `toggle`, `checkbox`, `checkboxGroup`, `radioGroup`, `display`, `slot`, `repeater`, and `fieldGroup`.

Beyond that, four features carry most forms:

- **`steps`** — a multi-step form with an indicator, and `validateStepOnNext` so a user cannot advance past an invalid step.
- **`dependsOn` on a field** — conditional visibility, so a "lost reason" appears only when the stage is closed-lost.
- **`repeater`** — a repeating group, for line items or contacts.
- **`useFormPrefill(properties, mapping)`** — a hook that maps a record's existing properties onto initial values, so an edit form opens populated.

`confirmDiscard` is worth turning on for anything longer than three fields. Inside a CRM record a user's attention leaves often, and losing a half-filled form to a stray click is a bad way to learn that.

## Step 5 — Compose a filter that runs as a CRM search

`FilterBuilder` is the odd one out here: it does not render your data, it renders a *query*. The user composes nested AND/OR conditions, and you get back a filter tree.

```tsx
import { FilterBuilder, toCrmSearchFilterGroups } from 'hs-uix/filter';

<FilterBuilder
  properties={[
    { name: 'dealstage', label: 'Stage', type: 'enumeration', options: stageOptions },
    { name: 'amount', label: 'Amount', type: 'number' },
    { name: 'closedate', label: 'Close date', type: 'date' },
  ]}
  value={tree}
  onChange={setTree}
/>
```

The payoff is the second import. `toCrmSearchFilterGroups(tree)` compiles that tree into the `filterGroups` shape HubSpot's CRM search API expects, so the thing your user built in the UI goes straight to the API with no translation layer of your own:

```tsx
const filterGroups = toCrmSearchFilterGroups(tree);
await runServerless({ name: 'searchDeals', parameters: { filterGroups } });
```

`validateTree` returns the errors before you send it, and `countConditions` is useful for a "3 filters applied" badge. `maxDepth` caps how deeply a user can nest groups — worth setting, because a filter nobody can read is a support ticket.

## Step 6 — Skip the fetch with the CRM-connected components

Everything above assumes you fetch. For the specific and very common case of *"show me records from this portal"*, `hs-uix/utils` ships two components that close that loop for you:

```tsx
import { CrmDataTable, CrmKanban } from 'hs-uix/utils';

<CrmDataTable
  objectType="deals"
  properties={['dealname', 'amount', 'dealstage', 'closedate']}
  columns={COLUMNS}
  serverSide
/>

<CrmKanban objectType="deals" groupBy="dealstage" cardFields={CARD_FIELDS} />
```

These take an `objectType` and a property list and handle the CRM search themselves — including pagination, the search and filter parameter mapping, and normalising HubSpot's `{ id, properties: { … } }` response shape into flat rows. `CrmKanban` will even derive its stages from the returned batch when you do not pass `stages`, though you should pass them for real pipeline labels rather than raw internal values.

If you want that data handling without their rendering, the same machinery is exposed as hooks:

- **`useCrmSearchDataSource(params, options)`** — returns `{ data, pagination, hasMore, loading, error, totalCount }`.
- **`useCrmSearchOptions(params, options)`** — the same, shaped as `{ label, value }` options for a select.
- **`buildCrmSearchConfig` / `normalizeCrmSearchRows`** — the two halves on their own, if you are driving the request yourself.

One caveat to size up before adopting these: they move the CRM query into the extension. That is exactly what you want for a read-only view of standard objects, and exactly what you do not want when the query needs a secret, a third-party call, or business logic you would rather not ship into an iframe. Those still belong in a card backend on your Worker.

## The smaller pieces

`hs-uix/common-components` holds the parts that show up inside the collections above, and are worth knowing individually:

- **`KeyValueList`** and **`SectionHeader`** — the two you will use most; a labelled property list and a section rule.
- **`CrmRecordPicker`** and **`CrmLookupSelect`** — record association pickers, so "attach this to a company" is not a text field.
- **`DateRangePicker`**, **`CollectionToolbar`**, **`ActiveFilterChips`**, **`CollectionCount`**, **`CollectionSortSelect`**, **`CollectionFilterControl`** — the toolbar furniture the collections assemble internally, exposed so a custom view can match.
- **`AvatarStack`**, **`AutoTag`**, **`AutoStatusTag`**, **`Icon`**, **`Spinner`**, **`StyledText`**.

`hs-uix/utils` holds the formatters and the glue: `formatCurrency`, `formatCurrencyCompact`, `formatDate`, `formatDateTime`, `formatPercentage`, `buildOptions`, `sumBy`, `deriveCardFieldsFromColumns`, and the filter helpers (`filterRows`, `searchRows`, `buildActiveFilterChips`).

Use the formatters rather than `Intl` directly. They accept the value shapes HubSpot actually returns — including its date objects — so `formatDate` on a HubSpot date property does the right thing without a conversion step.

**Where next**

Pick the component by the axis your user reads along, let it own the state around the data, and keep the fetching in your Worker unless the CRM-connected variants genuinely fit. The components are deliberately boring at the edges — `data`, `loading`, `error` — so that swapping a table for a board is a change of import and a change of config, not a rewrite.

- [UI extensions — declare a surface, read CRM context, hit the bundle budget](/docs/guides/ui-extensions)
- [Local dev — hot-reload a card while you build it](/docs/guides/dev-mode)
- [SDK reference — card declarations and card backends](/docs/sdk)

