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 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:
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 section of the UI extensions guide for the limits.
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:
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.
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.
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.
<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.
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.
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.
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.
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, andvalidateStepOnNextso a user cannot advance past an invalid step.dependsOnon 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.
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.
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:
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.
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:
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:
KeyValueListandSectionHeader— the two you will use most; a labelled property list and a section rule.CrmRecordPickerandCrmLookupSelect— 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.
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.