@tx-angular-design-system/ui
v1.0.3
Published
TraXtion design system and Angular UI component library — design tokens, global theme and standalone components for TraXtion applications.
Readme
@tx-angular-design-system/ui
The TraXtion design system and Angular UI component library — design tokens, a global theme, and standalone components for TraXtion applications.
Built for dense operational software: failure reporting, dispositions, part catalogues. It assumes screens full of records rather than pages of prose.
Install
ng add @tx-angular-design-system/uiThat single command configures the application. It:
- registers the global theme, which applies the type ramp, spacing scale and colour tokens to bare HTML — headings, body text, tables and form controls are styled before you use a single component;
- loads the type stack (Archivo, IBM Plex Sans, IBM Plex Mono) by adding
the font links to
index.html; - points Sass at
node_modules, so component stylesheets can reach the token API.
Every step is idempotent — re-running it after an upgrade changes nothing that is already correct.
npm install @tx-angular-design-system/uiImport the theme at the top of src/styles.scss:
@use '@tx-angular-design-system/ui/styles/theme';Not using Sass? Add the precompiled stylesheet to angular.json instead:
"styles": ["node_modules/@tx-angular-design-system/ui/styles/theme.css", "src/styles.scss"]Then add the fonts to index.html:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>Use
Every component is standalone. Import the ones a template actually uses:
import { Component, signal } from '@angular/core';
import { TxButton, TxPanel, TxBadge, TxIcon } from '@tx-angular-design-system/ui';
@Component({
selector: 'app-reports',
standalone: true,
imports: [TxPanel, TxButton, TxBadge, TxIcon],
template: `
<tx-panel heading="Failure reports">
<button txPanelActions txButton variant="outline" size="sm">
<tx-icon name="download" /> Export
</button>
<tx-badge status="warn">Awaiting disposition</tx-badge>
</tx-panel>
`,
})
export class Reports {}There is no NgModule, and no barrel that drags the whole library into your bundle.
Design tokens
Everything is published as a --tx-* custom property, in two layers.
Primitives are the raw palette and scales — --tx-color-brand,
--tx-space-4, --tx-text-base.
Semantics are what components actually read — --tx-text-color,
--tx-bg, --tx-border, --tx-focus-ring-color. Retheming means overriding
this layer, not the palette.
// Anywhere after the theme import
:root {
--tx-color-brand: #0a7cff; // rebrand
--tx-radius-md: 4px; // sharper corners
--tx-body-size: 14px; // denser type
}Colour
| Group | Tokens |
| ------- | ------------------------------------------------------------------ |
| Brand | --tx-color-brand, -deep, -dark, -tint, -edge |
| Ink | --tx-color-ink, -navy, -navy-2, -muted, -subtle, -faint |
| Surface | --tx-color-surface, -canvas, -sunken, -line, -line-2 |
| Status | --tx-color-{success,danger,warn,info,neutral} + -tint, -edge, -ink |
--tx-color-brand is the identity green. Use --tx-color-brand-deep for text
on light surfaces — the base green does not meet contrast on white.
Typography
Archivo for display, IBM Plex Sans for body, IBM Plex Mono for data and labels.
| Token | Size | Used for |
| ----------------- | ---- | --------------------------------- |
| --tx-text-3xs | 10px | mono micro-labels |
| --tx-text-xs | 12px | captions, hints |
| --tx-text-sm | 13px | dense table text, small buttons |
| --tx-text-md | 14px | controls, table body |
| --tx-text-base | 15px | body copy |
| --tx-text-lg | 17px | wordmark, section lead |
| --tx-text-xl | 20px | sub-headings |
| --tx-text-2xl | 25px | page titles |
| --tx-text-3xl | 32px | stat values |
| --tx-text-4xl | 40px | display |
The mono micro-label — tiny, uppercase, widely tracked — marks every piece of metadata in the system. It is what makes data read as an instrument panel rather than as prose.
Spacing
A 4px grid: the token number times four is the pixel value.
--tx-space-0 · -05 (2px) · -1 (4px) · -15 (6px) · -2 (8px) ·
-25 (10px) · -3 (12px) · -35 (14px) · -4 (16px) · -5 (20px) ·
-6 (24px) · -7 (28px) · -8 (32px) · -10 (40px) · -12 (48px) ·
-16 (64px)
The half-steps exist because this UI is dense enough to need the odd 2, 6, 10 and 14px.
The Sass API
For media queries and the mixin library:
@use '@tx-angular-design-system/ui/styles' as tx;
.my-widget {
padding: var(--tx-space-4);
@include tx.panel;
@include tx.below('md') {
padding: var(--tx-space-3);
}
}| Mixin | Purpose |
| -------------------- | ------------------------------------------------ |
| below($bp) / from($bp) | Media queries — xs sm md lg xl |
| label | The mono micro-label treatment |
| heading($size) | Display face, tightened tracking, navy |
| panel | The standard bordered, raised surface |
| focus-ring | Keyboard-only focus outline |
| truncate, line-clamp, visually-hidden, scroll-x | Layout helpers |
| groove | The tread-groove brand ornament |
| transition(...) | Tokenised transition, reduced-motion aware |
Use the custom properties for values and the mixins for patterns. The Sass
functions (space(), radius(), breakpoint()) exist for the places CSS
variables cannot go — chiefly media queries.
Components
Layout
| Component | Notes |
| --- | --- |
| tx-shell | Application frame. |
| tx-topbar | Brand mark, app name, account controls. |
| tx-sidebar | Collapsible navigation rail. Icon-only when collapsed; a drawer with a backdrop below md, opened by [txSidebarToggle]. |
| tx-nav-group, [txNavItem] | Groups and items inside the sidebar. |
| [txShellContent] | The main content region. |
| tx-page-header | Page title, one line of context, page-level actions. |
| tx-panel, tx-panel-header, tx-panel-footer | The primary content surface. |
| tx-tabs, tx-tab | Full ARIA tabs pattern with arrow-key navigation. |
Actions
button[txButton], a[txButton] — variants primary, accent, outline,
ghost, danger; sizes sm, md, lg; plus loading, selected,
iconOnly, fullWidth.
A loading button keeps its label's width and stays focusable. Disabling a
button mid-interaction drops it from the tab order and moves the user's focus,
so loading sets aria-disabled and swallows clicks instead.
Data display
tx-badge · tx-chip · tx-avatar · tx-tile · tx-meter ·
tx-key-value / tx-key-value-row · tx-spinner · tx-empty-state
A chip is normally a token the user can dismiss. Add clickable and it becomes
a real <button> — focusable, keyboard-operable, and reporting selected as
aria-pressed — which is what a row of chips needs to work as a filter bar:
<tx-chip
clickable
[removable]="false"
[selected]="filter() === status"
(activated)="filter.set(status)"
>
{{ status }}
</tx-chip>Do not bind (click) on the host instead: <tx-chip> is an element, not a
control, so it takes no focus, answers no keyboard and is announced as nothing.
Reorder list
A list whose rows the user can reorder by dragging — or entirely by keyboard.
<tx-reorder-list
[(items)]="stages"
[itemLabel]="stageLabel"
[trackBy]="stageId"
ariaLabel="Approval stages"
(reordered)="save($event)"
>
<ng-template [txReorderItem]="stages()" let-stage let-index="index">
<span class="tx-label">{{ index + 1 }}</span>
<span>{{ stage.name }}</span>
</ng-template>
</tx-reorder-list>Dragging alone is not enough: a drag-only list is unusable by keyboard, and
awkward with a screen reader or a tremor. Every row's handle is a real button —
Space picks the row up, arrows move it, Home/End send it to the ends,
Escape cancels and restores the order it started in, and every position change
is announced through a live region.
The held row follows the pointer exactly, and the rows it passes slide one slot out of its way. The array itself is left alone until the row is dropped — reordering it on every pointer move makes the held row jump between slots instead of tracking the finger.
items and reordered therefore settle at the same moment. Persist from
reordered: it fires only when the user changed the order, whereas items
also changes when you load data into the list.
Bind the same array to [txReorderItem] as to items. The value is never read;
it exists so TypeScript can infer the row type and check let-stage for you.
Tree
A hierarchy of nodes — the parts breakdown of a product variant, nested to any depth.
<tx-tree [nodes]="parts()" [(selected)]="selectedPartId" ariaLabel="Parts breakdown">
<ng-template txTreeActions let-node>
<button txButton size="sm" variant="outline" (click)="rename(node)">Rename</button>
<button txButton size="sm" variant="outline" (click)="move(node)">Move</button>
</ng-template>
</tx-tree>readonly parts = signal<TxTreeNode[]>([
{
id: 'control-box',
label: 'Control Box',
badge: 'SUB',
badgeStatus: 'info',
meta: '2S · 2M',
children: [
{ id: 'chassis', label: 'Chassis', badge: 'CMP', badgeStatus: 'success', meta: '2S · 2M' },
],
},
]);The node shape is deliberately generic: the library knows about labels, badges
and a line of metadata, not about parts, symptoms or failure modes. Format your
domain data into badge and meta before handing it over.
Nodes are expanded by default; collapsible adds a disclosure control to every
node that has children. Expansion is stored as the exceptions — the ids in
collapsed — so a tree loaded from the server is fully open without the host
having to walk it and enumerate every branch.
Row actions stay hidden until a row is hovered, focused or selected. A tree of fifty parts with three buttons on every row is a wall of controls, not a hierarchy. (On touch, where there is no hover, they are always visible.)
Implements the ARIA tree pattern: one roving tab stop, so Tab moves past the
whole tree rather than through every node in it. Arrows move, Right opens a
closed branch then steps into it, Left closes one or jumps to the parent,
Home/End reach the ends, and typing a letter jumps to the next matching
label.
Tables are directives on native elements — table[txTable], th[txSortable],
[txAlign] — so any table library, virtual scroller or sorting strategy you
add later still works.
<div class="tx-table-scroll">
<table txTable hoverable>
<thead>
<tr>
<th txSortable="raised" [sort]="sort()" (sorted)="onSort($event)">Raised</th>
<th txAlign="end">Parts</th>
</tr>
</thead>
<tbody>…</tbody>
</table>
</div>Sorting cycles ascending → descending → unsorted. The third state matters: without it, a user who sorted by accident can never get back to the order the data arrived in.
Pagination
tx-paginator adds a range readout, a page-size chooser, numbered pages and
step buttons. Like the directives it owns no data — it reports the page to show
and leaves the slicing, or the request to the server, to you.
<tx-paginator
[length]="sorted().length"
[(pageIndex)]="pageIndex"
[(pageSize)]="pageSize"
[pageSizeOptions]="[10, 25, 50]"
itemLabel="reports"
showFirstLast
/>// In memory. Sort first, then page: the other order pages a moving target.
readonly visible = computed(() =>
txPaginate(this.sorted(), this.pageIndex(), this.pageSize()),
);
// Or against a server.
onPage(event: TxPageEvent) {
this.load(event.pageIndex, event.pageSize);
}Changing the page size keeps the first row you were looking at on screen rather than resetting to page one, so widening a page from 10 rows to 50 does not lose your place. If the collection shrinks under the current page, the paginator moves to the new last page and emits — otherwise the user is left on an empty table with every button disabled and nothing to click.
Row actions
tx-row-actions makes the actions column configuration rather than markup.
Every field takes either a value or a function of the row, so one definition
covers the whole column and each row resolves it for itself:
readonly rowActions: readonly TxRowAction<Project>[] = [
{ id: 'view', label: 'View', icon: 'view' },
{
id: 'archive',
label: (project) => (project.archived ? 'Restore' : 'Archive'),
icon: (project) => (project.archived ? 'refresh' : 'clipboard-check'),
},
{
id: 'delete',
label: 'Delete',
icon: 'trash',
variant: 'danger',
disabled: (project) => project.tasks > 0,
disabledReason: 'Close the open tasks first',
},
];<td class="tx-table__actions">
<tx-row-actions iconOnly [actions]="rowActions" [row]="project" (actioned)="run($event)" />
</td>Hide an action the user is not allowed to perform — a column of buttons that
always refuse teaches people to stop reading it. Disable one they could
perform if the row were in a different state, and give a disabledReason, so
the greyed button explains itself rather than looking like a bug.
revealOnHover keeps the buttons hidden until the row is hovered or holds
focus, which quietens a long table at the cost of discoverability. They stay
visible on touch, where there is no hover to reveal them with.
Forms
tx-form-field wraps a control with its label, hint and validation message and
wires the accessibility relationships — id, aria-describedby,
aria-invalid — so you never set them by hand.
<tx-form-field label="Report title" required [error]="titleError()">
<input txInput [(ngModel)]="title" />
</tx-form-field>Controls stay native: input[txInput], textarea[txInput], select[txSelect].
Validation, autofill, formControlName and browser behaviour are untouched.
Datepicker
<tx-form-field label="Due date" hint="Type 2026-08-21, or use the calendar">
<tx-datepicker [(value)]="dueDate" [min]="today" clearable />
</tx-form-field>The field is a real text input with a calendar attached, not a button that opens
one — somebody entering a date they already know types it far faster than they
can navigate to it. YYYY-MM-DD is always accepted; pass parse to accept
more. Free-text date parsing is guesswork (03/04/2026 is two different days
depending on which side of the Atlantic you are on), so anything looser is the
application's decision.
The value is a Date at local midnight. Time of day is discarded deliberately:
a date picked from a calendar is a calendar day, and keeping the hours around
invites comparisons that break twice a year when the clocks change. The
writeValue path also accepts an ISO string, which is what a JSON API returns.
The calendar is a grid, as the ARIA date-picker pattern requires: arrows move
by a day, PageUp/PageDown by a month (hold Shift for a year), Home/End
reach the ends of the week, and the focused day is the grid's only tab stop.
min and max disable out-of-range days rather than hiding them, so the shape
of the month survives.
Month names, weekday names and the display format all come from Intl; set
locale and weekStart (0 Sunday, 1 Monday — Monday by default, as ISO 8601
has it).
tx-checkbox and tx-radio-group / tx-radio implement ControlValueAccessor,
so the same component works with two-way binding and with reactive forms.
Hint and error share one slot: an error replaces the hint, because two competing lines of guidance under one input is one line too many.
Select
Two components, because they solve different problems.
<tx-select> renders its own dropdown panel, so it looks identical in every
browser — including versions of Chrome far older than the customizable-select
API — and it supports real multiple selection:
<tx-form-field label="Disposition">
<tx-select [(value)]="disposition" placeholder="Choose…" clearable>
<tx-option-group label="In house">
<tx-option value="repair">Repair</tx-option>
<tx-option value="scrap">Scrap</tx-option>
</tx-option-group>
<tx-option value="return">Return to supplier</tx-option>
</tx-select>
</tx-form-field>
<!-- Multiple selection: the value is an array -->
<tx-select multiple clearable [(value)]="symptoms" placeholder="Select symptoms">
<tx-option value="drift">Sensor drift</tx-option>
</tx-select>It implements the ARIA combobox pattern with a listbox popup. Focus stays on the
trigger and aria-activedescendant tracks the highlight, so nothing has to
juggle focus. Arrows move, Home/End jump to the ends, Enter/Space
select, Escape closes, and typing letters jumps to the matching option. In
multiple mode the panel stays open as you pick — that is the whole use case.
The panel is relocated to <body> and positioned in script, so no ancestor's
overflow: hidden or transform can clip it. It opens downward and flips above
only when it genuinely will not fit.
Pass compareWith when the option values are objects; the default identity
check will never match two separately-deserialised copies of the same record.
select[txSelect] styles a native <select>. Still the better choice for a
short, single-value list: it costs no JavaScript and gets the operating system's
picker, which is what mobile users expect. Its trigger is fully styled
everywhere, but the dropdown is drawn by the browser, so it can only be
themed in Chrome and Edge 135+ where the customizable-select API exists. The
library ships that enhancement inside @supports; everywhere else the native
picker appears.
The size input on
txInputandtxSelectistxSize, notsize—<input size>and<select size>are real HTML attributes, and shadowing them would take that behaviour away from you.<tx-select>is a custom element, so it uses plainsize.
tx-checkbox and tx-radio-group / tx-radio implement ControlValueAccessor,
so the same component works with two-way binding and with reactive forms.
Hint and error share one slot: an error replaces the hint, because two competing lines of guidance under one input is one line too many.
The select dropdown
A <select> is two things: a trigger, and a dropdown the browser owns. The
trigger is fully styled everywhere — brand chevron, hover and focus states, and
a placeholder treatment when the empty option is selected, so Choose… reads
like the placeholder in the field beside it.
The dropdown is styled where the browser allows it, via the customizable select
API (appearance: base-select). Where that is supported the picker becomes a
real design-system surface: panel background, radius and shadow, brand-tinted
selection with a check, styled optgroup headings, and a fade-in on open.
This needs Chrome or Edge 135+ (April 2025). Below that version — and in Firefox and Safari, which do not implement it at all — the browser draws its own dropdown and there is nothing CSS can do about it. That is not a misconfiguration; it is the feature's support baseline.
Seeing the system dropdown in Chrome but the styled one in Edge? The two update independently, so Edge is on a newer Chromium. Check
chrome://version— anything below 135 gets the native picker.
It is written as a progressive enhancement inside @supports, not a replacement:
nothing breaks in a browser that has never heard of it, and because no JavaScript
combobox is involved, keyboard behaviour, type-ahead, form autofill and mobile
native pickers all stay intact.
If you need the dropdown to look identical in every browser today, this approach cannot give you that — it would take a custom listbox component, trading away the native behaviour above.
The picker is pinned to open downward. Chromium's default is
position-try-order: most-block-size, which opens the menu into whichever side
has more room and so flips it above the field on a scrolled page even when it
would fit below; the library resets that to normal and flips only on genuine
overflow.
Feedback
tx-banner — something that matters while you work. danger and warn
announce assertively; info and success announce politely.
TxToastService — confirmation that something just happened. The outlet
attaches itself to <body> on first use; there is nothing to add to your root
template.
private readonly toast = inject(TxToastService);
this.toast.success('Report submitted');
this.toast.danger('Could not reach the disposition service');
this.toast.show('Part removed', {
action: { label: 'Undo', handler: () => this.restore(part) },
});Errors stay on screen longer than confirmations — an error the user has not finished reading should not evaporate.
tx-dialog — something that must be decided now. Render it conditionally;
it is open for exactly as long as it exists.
@if (confirmingDelete()) {
<tx-dialog heading="Delete this report?" (closed)="confirmingDelete.set(false)">
<p>This cannot be undone.</p>
<ng-container txDialogFooter>
<button txButton variant="outline" (click)="confirmingDelete.set(false)">Cancel</button>
<button txButton variant="danger" (click)="delete()">Delete report</button>
</ng-container>
</tx-dialog>
}It handles the whole modal contract: focus moves in on open and returns to the
element that opened it, Tab is trapped, Escape and backdrop clicks close it,
the page behind is locked from scrolling, and it relocates to <body> so no
ancestor's overflow or transform can clip it.
Icons
Stroke-only glyphs on a 24px grid that inherit currentColor.
<tx-icon name="edit" />
<tx-icon name="alert" size="sm" />
<tx-icon name="reports" label="Failure reports" />Icons are decorative by default. Pass label only for a standalone icon that
carries meaning on its own.
Icons are named for what they depict rather than for the screen they first
appeared on — sliders, not dispositions. A glyph named after one
application's workflow is only reusable inside that application.
Add your own, or override a shipped glyph by re-using its name:
provideTxIcons({
tyre: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="3"/>',
});The value is trusted verbatim, so author it yourself — never pass user input.
Utilities
A small, deliberate layer — not a utility-first framework.
Layout tx-stack · tx-cluster · tx-row · tx-spacer · tx-cols
(tx-cols-2, tx-cols-3, tx-cols-auto) · tx-master-detail · tx-tiles ·
tx-form-grid · tx-table-scroll
Type tx-mono · tx-label · tx-muted · tx-subtle · tx-truncate ·
tx-measure · tx-text-* · tx-font-*
Other tx-groove · tx-sunken · tx-visually-hidden · tx-skip-link ·
tx-gap-* · tx-mt-* / tx-mb-* / tx-p-*
Accessibility
Not a later pass — the components will not let you skip it.
- Focus is keyboard-only (
:focus-visible) and never suppressed without a replacement. tx-dialogimplements the full modal contract, including focus restoration.tx-tabsimplements the ARIA tabs pattern; only the active tab is in the tab order, so Tab moves into the panel.tx-form-fieldwires label and description relationships automatically.- Status is never carried by colour alone — badges and banners pair colour with an icon or text.
prefers-reduced-motionis honoured globally, and every transition routes through one mixin.
Two things the library cannot do for you: an icon-only button still needs an
aria-label, and a routed nav item needs ariaCurrentWhenActive="page"
alongside routerLinkActive — a class alone tells assistive technology nothing.
Theming
Override the semantic layer after importing the theme:
@use '@tx-angular-design-system/ui/styles/theme';
:root {
--tx-color-brand: #0a7cff;
--tx-color-brand-deep: #0060cc;
--tx-color-brand-tint: #e8f2ff;
--tx-color-brand-edge: #b8d8ff;
}Scoped themes work the same way — set the properties on any ancestor and everything inside inherits them.
Self-hosting the fonts
For air-gapped or privacy-sensitive deployments, drop the woff2 files in your assets and repoint three tokens:
:root {
--tx-font-display: 'Archivo', system-ui, sans-serif;
--tx-font-sans: 'IBM Plex Sans', system-ui, sans-serif;
--tx-font-mono: 'IBM Plex Mono', ui-monospace, monospace;
}Nothing else in the library needs to change.
Browser support
Evergreen Chrome, Edge, Firefox and Safari. The library uses CSS custom
properties, :focus-visible, aspect-ratio, :has() and CSS grid without
fallbacks.
One feature is deliberately a progressive enhancement rather than a baseline:
the styled dropdown on the native select[txSelect] needs the customizable
select API (Chrome and Edge 135+). If you need a dropdown that looks the same
everywhere, use <tx-select> — see Select.
Requirements
Angular 20 or later. @angular/forms is a peer dependency, required by the
checkbox and radio components.
