Introducing Cairn: A Signal-Based Datatable That Gets Out of Your Way
Why we built a zero-dependency, zoneless datatable for Angular 21+, and how its two-layer architecture lets you take the headless logic, the ready-made component, or both.

Angular changed. Signals landed, zoneless change detection went from an experiment to the default recommendation, and the mental model of “something somewhere will re-render this” was finally replaced by an explicit dependency graph. Most table libraries did not change with it. They still ship a change detection strategy from the zone.js era, still pull in a dependency tree you never asked for, and still assume you want their opinion about what a table should look like.
Cairn is the table we wanted to use instead.
The problem we kept running into
Every data table we reached for fell into one of two buckets.
The first bucket is the batteries-included grid. It renders beautifully out of the box, and then the design review happens. You need a different border radius on the header, a badge in one cell, a custom empty state, and suddenly you are fighting a stylesheet with 400 selectors and !important scattered through it. The library owns your markup, and you rent it back one override at a time.
The second bucket is the headless engine. The logic is excellent and the styling freedom is total, but you write the component yourself, every time, in every project. And the ones that exist for Angular are usually ports of a framework-agnostic core, which means an adapter layer, an observable bridge, and a change detection story that never quite matches the way Angular works today.
Neither bucket asked the question we cared about: what if the logic and the component were the same library, but you could take only the half you need?
Two entry points, one library
That question is the whole architecture.
@gunerkaanalkim/cairn-datatable/core contains no component and no template. It exports a createTable factory, the type definitions and three default constants. It depends on Angular signals and nothing else, so it never touches the DOM (Document Object Model) and it runs during SSR (Server-Side Rendering) without a second thought.
@gunerkaanalkim/cairn-datatable re-exports every one of those symbols and adds the DataTable component, four template directives and the styling interfaces.
If you want a rendered table, import the root entry point and you are done in about fifteen lines.
import { Component, signal } from '@angular/core';
import { DataTable } from '@gunerkaanalkim/cairn-datatable';
import { createTable, type ColumnDef } from '@gunerkaanalkim/cairn-datatable/core';
@Component({
selector: 'app-people',
imports: [DataTable],
template: '<cairn-data-table [table]="table" [selectable]="true" />',
})
export class People {
readonly data = signal<Person[]>(PEOPLE);
readonly columns = signal<ColumnDef<Person>[]>([
{ id: 'name', header: 'Name' },
{ id: 'role', header: 'Role' },
]);
readonly table = createTable({
data: this.data,
columns: this.columns,
rowId: (row) => row.id,
});
}
If you want to render a card grid, a mobile accordion or a virtualised list instead, import only /core. The component, its template and the directives never enter your bundle, and the same table object hands you rows(), visibleColumns(), cellText() and every state setter. Going headless is an import change, not a rewrite.
What makes it fast: the derivation chain
createTable does not recompute a pipeline on every tick. It builds one computed per stage, and each stage depends only on the stage before it plus the state signals it actually reads.
data()— the rows you own, from any signal or plain getter.baseRows— rows wrapped with a stable identity and a source index.filteredRows— the global filter and per-column filters applied.sortedRows— the multi-column sort applied, with ties broken by source index.rows— the current page, with the selection flag merged in.
Change the page index and pagination recomputes alone. The sort result is untouched, because nothing in it depends on the page. That is not an optimisation we bolted on; it is what falls out of expressing the pipeline in signals in the first place.
sortedRows is deliberately public. It is the full result set after filtering and sorting but before the page slice, which is exactly what an export button or a “select all across pages” action needs.
Styling: three ways, none of them mandatory
The shipped stylesheet is a single optional import, and every rule lives inside an @layer cairn cascade layer, so any unlayered rule of yours wins regardless of specificity. Five custom properties cover the palette, so a dark theme is five re-declarations, not a fork.
Skipping the import entirely is a supported mode, not a broken one. You get a bare, unstyled HTML (HyperText Markup Language) table, which is the right starting point when your design system already styles table elements.
Beyond that you have a classNames input with nineteen keys, one per element the component renders — including the sorted header cell, the selected row, and the even and odd row variants. Values are appended to the built-in class rather than replacing it, so the default rules and the attribute hooks keep working alongside your utilities. Tailwind users can dress the entire table from the template.
And if you would rather not pass classes at all, every element publishes its state as an attribute: data-column-id, data-sorted, data-selected and data-align. A plain CSS (Cascading Style Sheets) file can do the whole job.
The rest of the highlights
- Zero runtime dependencies. The package depends on nothing but Angular itself, and the built component layer is roughly 21 KB before minification.
- Zoneless by design. No
zone.jsrequirement anywhere in the library. - You own the state. Six slices — sorting, global filter, column filters, pagination, selection and hidden columns — each readable as a signal, writable through a method, and readable as a whole via
state(). Restoring a saved view is a singlesetStatecall, which makes URL persistence or a “saved views” feature almost free. - Selection survives paging. It is keyed by row identity, not by index, so moving between pages does not quietly drop what the user picked.
- Template overrides for cells, headers, the empty state and the loading state, through
cairnCell,cairnHeader,cairnEmptyandcairnLoading. - Server-side ready. The
manualflags switch off sorting, filtering or pagination individually so your backend can own that stage while the rest of the pipeline keeps working. - Accessibility that is not an afterthought. Native table semantics,
scope="col"on every header,aria-sortmirrored intodata-sorted, a real<button>inside each sortable header,aria-busywhile loading, labelled checkboxes, and a<caption>input that gives the table a real accessible name.Enter,Space,Shift+Enterfor multi-sort andEscapeto clear are all wired up.
What Cairn does not do
Being honest about the edges is part of the pitch. This version has no virtual scrolling, no column resizing, no column drag and drop, and no row grouping or row spanning. Row spanning is planned for version 2. If you need a spreadsheet, Cairn is not it. If you need a table that reflects your data model and your design system without an argument, it is.
Try it
Every documentation page has a live, editable example sitting next to the code that produces it: https://gunerkaanalkim.github.io/cairn/
npm install @gunerkaanalkim/cairn-datatable
The source lives at github.com/gunerkaanalkim/cairn under the MIT (Massachusetts Institute of Technology) License. Issues, ideas and pull requests are all welcome — especially the ones that tell us which part of the API (Application Programming Interface) still feels heavier than it should.