@besaitech/ng-design-system
Reusable Angular 20 component library (sds- prefix) styled with Tailwind CSS v4, based on
the Saitech visual language. Buttons, inputs, selects, tables, tabs, accordions, a date picker,
phone input, plus the app shell (top bar + sidebar).
Important: components are styled with Tailwind utility classes that live in their templates — the package does not ship compiled CSS. Your app must run Tailwind v4, scan this package, and define the design tokens (see Tailwind setup). Without that step the components render unstyled.
Requirements
- Angular 20.3+, 21.x or 22.x (
@angular/common,core,forms,platform-browser,router) - Tailwind CSS v4 in the consuming app
1. Install
npm install @besaitech/ng-design-system
Public npm registry — no .npmrc, no scope mapping and no read token required.
2. Tailwind setup
The Angular peer dependencies (@angular/common, core, forms, …) are already present in any
Angular 20.3+ / 21 / 22 app. If you don't have Tailwind yet, add it as a dev dependency:
npm install -D tailwindcss @tailwindcss/postcss
3. Tailwind v4 setup
This is the step that makes the components look right. Tailwind v4 is CSS-first — there is no
tailwind.config.js.
a. Register the PostCSS plugin. Create .postcssrc.json at your project root:
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
b. Import Tailwind, scan the library, and define the tokens in your global stylesheet
(src/styles.css). The @source line is what tells Tailwind to generate the utility classes used
inside the library, and the @theme block defines the colors/radii those classes refer to —
both are required:
@import "tailwindcss";
/* Generate the utilities used inside @besaitech/ng-design-system templates.
Path is relative to THIS file (src/styles.css → ../node_modules). */
@source "../node_modules/@besaitech/ng-design-system";
/* Tokens. BOTH imports are required, and both must come AFTER the tailwindcss import —
Tailwind v4 processes @theme in imported files and later declarations win.
base.css = surfaces, ink, accents, status, radius, layout vars (15 tokens).
The brand preset = the 11 --color-brand-* steps, and nothing else. */
@import "@besaitech/ng-design-system/themes/base.css";
@import "@besaitech/ng-design-system/themes/saitech.css"; /* blues */
/* @import "@besaitech/ng-design-system/themes/belvigo.css"; black */
/* @import "@besaitech/ng-design-system/themes/belvigo-emerald.css"; emerald (Belvigo merchant);
keeps green primary fills in dark mode through the --sds-dark-* hooks listed in base.css */
/* Override any base default in your own @theme afterwards, e.g. the typeface. */
@theme {
/* Neutrals / surfaces (already provided by base.css — shown for reference) */
--color-canvas: #f5f6f8;
--color-surface: #ffffff;
--color-muted: #f8f9fb;
--color-border: #e6e9f0;
--color-ink: #1b2440;
--color-ink-soft: #5b6577;
--color-ink-faint: #8a93a6;
/* Accent icon-tiles (foreground + soft background) */
--color-accent-blue: #2f6bd4;
--color-accent-blue-soft: #e8effb;
--color-accent-green: #1f9d57;
--color-accent-green-soft: #e6f5ec;
--color-accent-amber: #e0902a;
--color-accent-amber-soft: #fdf0dd;
--color-accent-red: #df4f6b;
--color-accent-red-soft: #fde7ec;
--color-accent-indigo: #6c5ce7;
--color-accent-indigo-soft: #ece9fb;
/* Status */
--color-success: #27c06b;
--color-warning: #f3b73c;
--color-danger: #df4f6b;
/* Radius */
--radius-control: 0.5rem;
--radius-card: 0.75rem;
/* Typography */
--font-sans: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
}
/* Layout dimensions used by <sds-shell> / <sds-sidebar> / <sds-topbar>.
Only needed if you use the layout components. */
@layer base {
:root {
--sds-topbar-h: 64px;
--sds-sidebar-w: 256px;
--sds-sidebar-w-collapsed: 76px;
}
body {
margin: 0;
font-family: var(--font-sans);
background-color: var(--color-canvas);
color: var(--color-ink);
}
}
Why
@source? Tailwind v4 ignoresnode_modulesby default. The explicit@sourcere-includes this package so the classes baked into its compiled templates produce real CSS. If components show up unstyled, this line (or a wrong relative path) is almost always the cause.
4. Use a component
All components are standalone — import the ones you need directly into a component's imports:
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SdsButton, SdsInput, SdsIcon } from '@besaitech/ng-design-system';
@Component({
selector: 'app-member-form',
imports: [FormsModule, SdsButton, SdsInput, SdsIcon],
template: `
<sds-input label="Nom" [(ngModel)]="name" leadingIcon="search" clearable />
<sds-button variant="primary" (click)="save()">
<sds-icon sdsIconLeading name="user-plus" [size]="18" />
Créer un membre
</sds-button>
`,
})
export class MemberForm {
name = '';
save() {/* ... */}
}
Form controls (sds-input, sds-textarea, sds-select, sds-multi-select, sds-toggle-switch,
sds-date-picker, sds-phone-input) implement ControlValueAccessor, so they work with both
template-driven ([(ngModel)]) and reactive (formControlName) forms.
Component catalog
Import names are the Sl… classes; the markup selectors are sds-….
Controls & primitives
| Component | Selector | Notes |
|---|---|---|
SdsButton | <sds-button> | variant (primary·secondary·tertiary·ghost·danger), size, loading, block, iconOnly; [sdsIconLeading]/[sdsIconTrailing] slots |
SdsInput | <sds-input> | CVA. label, hint, error, size, appearance (default·on-brand), leading/trailingIcon, clearable, required, invalid, inputId |
SdsTextarea | <sds-textarea> | CVA. Auto-grow, label, hint, error, required |
SdsTimeInput | <sds-time-input> | CVA time of day, 24 h, value "HH:mm" | null; minuteStep, clearable, withSeconds |
SdsChipGroup / SdsChip | <sds-chip-group> / <sds-chip> | Filter/choice chips: one or multiple (array), counts, [(value)] or CVA; single toggle chip [(selected)] |
SdsToggleSwitch | <sds-toggle-switch> | CVA boolean toggle |
SdsSelect | <sds-select> | CVA single-select dropdown |
SdsMultiSelect | <sds-multi-select> | CVA multi-select with chips; (opened) / (closed) (emits the selection) |
SdsDatePicker / SdsCalendar | <sds-date-picker> / <sds-calendar> | CVA date field + popover calendar (date-utils helpers) |
SdsDateRangePicker / SdsRangeCalendar | <sds-date-range-picker> / <sds-range-calendar> | Range field: presets column + two-month range calendar, pending until Apply; [(value)] is an SdsDateRange | null ({ start, end }, end inclusive); (presetSelect) emits the preset id; helpers sdsLastDays(n), sdsLastMonths(n), formatDateRange() |
SdsPhoneInput | <sds-phone-input> | CVA phone field with country selector (phone-countries) |
SdsIcon | <sds-icon> | Inline-SVG icon; name, size, strokeWidth, filled |
SdsAvatar | <sds-avatar> | Image, initials or fallbackIcon + status dot; shape (circle·rounded·square), decorative; broken image falls back |
SdsBadge | <sds-badge> | Status pill; variant, dot |
SdsCard | <sds-card> | Surface container; variant, interactive, padding |
Composite
| Component | Selectors |
|---|---|
| Tabs | <sds-tabs> · <sds-tab-list> · <sds-tab> · <sds-tab-panels> · <sds-tab-panel> — controlled + (tabSelect) for query-param/guarded tabs; panels optional; <sds-tab-list [ariaLabel]> |
| Accordion | <sds-accordion> · <sds-accordion-panel> · <sds-accordion-header> · <sds-accordion-content> |
| Table | <sds-table> · ng-template[sdsCell] · [sdsHeader] · [sdsRowExpansion] · [sdsTableEmpty] — rowClickable, [rowClass] |
| Paginator | <sds-paginator> |
Layout (use the design tokens / layout vars from step 3b)
| Component | Selector | Notes |
|---|---|---|
SdsShell | <sds-shell> | Top bar + sidebar + scrollable content; [sdsTopbar]/[sdsSidebar] slots |
SdsTopbar | <sds-topbar> | appName, user, (logout); [sdsLogo] slot |
SdsSidebar + SdsNavGroup + SdsNavItem | <sds-sidebar> … | Collapsible nav rail; collapse state via root-provided SidebarStore; items render routerLink or <button> |
Charts (D3 does the maths, Angular renders the SVG, so they are OnPush, SSR-safe and themed by tokens)
— a secondary entry point, @besaitech/ng-design-system/charts:
import { SdsLineChart, SdsKpi, SdsChartSeries } from '@besaitech/ng-design-system/charts'; // components + chart data types
import { provideSdsCharts, SdsChartLabels } from '@besaitech/ng-design-system'; // config + strings (root)
The root entry is ONE module: whatever an app uses from it anywhere — even on a lazy route — lands in
the app's initial chunk. Keeping the charts (and d3) in their own entry lets the bundler leave them in
the lazy chunk of the page that draws them. The configuration (provideSdsCharts, SDS_CHART_CONFIG,
SdsChartConfig, SdsChartLabels, SDS_CHART_LABELS_EN and the label text types) stays in the root —
it is tiny and d3-free — so app.config can provide it eagerly without pulling the charts in. Since
0.0.68 the root no longer re-exports the chart components or chart types.
| Component | Selector | Notes |
|---|---|---|
SdsLineChart | <sds-line-chart> | Time / numeric / category x; multi-series, area, stack (stacked · percent), curve, log yScale, annotations, dashed forecast series, endLabels, crosshair tooltip |
SdsBarChart | <sds-bar-chart> | orientation, stack, negatives, showValues, sort, barColor, highlight, combo type: 'line' series |
SdsScatterChart | <sds-scatter-chart> | x/y, bubble size, group, trendline, log/time axes, nearest-point hover |
SdsDonutChart | <sds-donut-chart> | Donut · pie · half; tail folds into "Other" past maxSlices; centre total / hovered slice (centerLabel="" hides the centre readout) |
SdsHeatmap | <sds-heatmap> | Two categorical dims × a measure; scheme sequential · diverging, showValues |
SdsTreemap | <sds-treemap> | Nested { name, value?, children? }; branch colours, click-to-zoom breadcrumb |
SdsSparkline | <sds-sparkline> | Word-sized line · area · bar; band, reference, showMinMax |
SdsKpi | <sds-kpi> | Stat tile: value, change vs previous (good/bad via upIsGood), optional sparkline trend |
Every chart reads plain rows through accessors (a property name or (row, i) => value), so API data
plots without reshaping; has an interactive legend (colour follows the series, never its rank), a
keyboard-navigable tooltip, a data-table twin (tableToggle), loading / empty states, and a
(pointClick) output. Colours come from the colour-blind-safe --sds-chart-1…8 slots in base.css
(override any slot in your preset). Set app-wide defaults once:
providers: [provideSdsCharts({ locale: 'fr-FR', labels: CHART_LABELS_FR })]
// or, switching live with the app language (both functions are read inside computed()s):
providers: [provideSdsCharts({ locale: () => lang(), labels: () => chartLabelsFor(lang()) })]
Chart strings. Every built-in string — empty state, table toggle and headers, tooltip rows, the
spoken summaries, role descriptions, the KPI's change sentence — is an SdsChartLabels key with an
English default (SDS_CHART_LABELS_EN). Translate them once through provideSdsCharts({ labels })
(a Partial<SdsChartLabels>, merged over the English defaults); parameterised ones are functions
(sliceCount(n), gridSize(rows, columns), labelValue(label, value), zoomedTo(path),
versus(y, x), sparklineSummary({ label, first, last, min, max, count }),
kpiChange({ direction, delta, period, good }), …). Per-chart inputs (emptyText, otherLabel,
centerLabel, valueLabel, rootLabel, sizeLabel) still win for one chart; left unset (null)
they fall back to the configured labels.
<sds-line-chart
heading="Admissions"
[data]="rows"
x="date"
[series]="[{ key: 'urgences', label: 'Urgences' }, { key: 'programmees', label: 'Programmées' }]"
[annotations]="[{ type: 'line', value: 150, label: 'Capacité' }]"
/>
Custom tooltip body: <ng-template sdsChartTooltip let-tip>…</ng-template> inside any chart.
Rich text (Tiptap / ProseMirror) — a secondary entry point, @besaitech/ng-design-system/rich-text,
for the same reason as the charts: ProseMirror stays in the lazy chunk of the page that edits rich text.
import { SdsRichTextEditor, SDS_RICH_TEXT_LABELS } from '@besaitech/ng-design-system/rich-text';
| Component | Selector | Notes |
|---|---|---|
SdsRichTextEditor | <sds-rich-text-editor> | CVA over an HTML string ('' when empty). [tools] (headings, bold, italic, underline, strike, lists, quote, link, table, divider, clear, undo/redo) is the toolbar AND the schema; rows / maxRows, maxLength + counter, label, hint, error, invalid, required, readonly, disabled, appearance (default·filled), contentLanguage (spell-check language); focus(), insertText(text) |
SdsLocalizedRichTextEditor | <sds-localized-rich-text-editor> | CVA over an SdsLocalizedValue ({ fr: '<p>…</p>', de: … }), like sds-localized-input: languages, currentLanguage, requiredLanguages, switcher at the end of the toolbar with filled / required / missing dots, empty translations pruned. One editor per language underneath — undo, caret and spell-check stay per language. Same rich-text inputs as above (maxLength is per language) |
SdsRichTextToolbarEnd | [sdsRichTextToolbarEnd] | Marks content projected at the end of the toolbar (kept in readonly): a language switcher, "insert variable" buttons calling editor.insertText(…) |
The emitted HTML is clean by construction: no inline styles, nothing outside the enabled tools, and links
limited to http(s), mailto, tel, site-relative paths and anchors (sdsSafeHref) with
rel="noopener noreferrer nofollow" — still sanitise stored HTML when you display it. Tables are saved bare
(no <colgroup>, no inline widths). When loaded HTML holds content the tools cannot keep (<img>, <iframe>, a
table without the table tool…), a warning under the field names it before anyone edits — saving an edit would
drop it. ⌘/Ctrl+K opens the
link field and is not passed on to an app-wide ⌘K palette. Strings are English by default: translate them
per instance with [labels] (a Partial<SdsRichTextLabels>, follows a live language switch) or app-wide with
{ provide: SDS_RICH_TEXT_LABELS, useValue: { bold: 'Gras', … } }.
<sds-rich-text-editor
label="Présentation"
[tools]="['heading2', 'bold', 'italic', 'bulletList', 'orderedList', 'link', 'undo', 'redo']"
[maxLength]="2000"
formControlName="description"
/>
Theme & cross-app preferences
SdsThemeService holds the person's mode — 'system' | 'light' | 'dark', persisted to localStorage
under sds-theme — and resolves it to the painted theme (light/dark); system follows the OS live.
toggle() / setTheme() set an explicit theme, setMode('system') goes back to following the OS.
Apps of one product can carry the person's language and theme in the URL when they link to each other, so the page that opens matches. Sending side:
import { appendUiPrefs } from '@besaitech/ng-design-system';
appendUiPrefs('https://auth.example/continue#token=abc', { lang: translate.getCurrentLang(), theme: theme.mode() });
// → https://auth.example/continue?lang=fr&theme=dark#token=abc
Never put them inside a nested redirect a server matches exactly (return_to, redirect_uri) — append them to
the outer URL. Receiving side: an inline script in <head> of index.html applies, persists and strips them
before first paint (no flash, and no import of this library at the root). LANG_KEY is the app's own
language storage key; keep LANGS in step with the app's languages. readUiPrefs / stripUiPrefs are the same
logic for code and tests.
<script>
(function () {
var LANG_KEY = 'app.lang', LANGS = ['en', 'fr', 'nl'], THEME_KEY = 'sds-theme', MODES = ['system', 'light', 'dark'];
var url = new URL(location.href), q = url.searchParams, lang = q.get('lang'), mode = q.get('theme');
try {
if (LANGS.indexOf(lang) >= 0) localStorage.setItem(LANG_KEY, lang);
if (MODES.indexOf(mode) >= 0) localStorage.setItem(THEME_KEY, mode);
} catch (e) {}
try { lang = localStorage.getItem(LANG_KEY) || lang; mode = localStorage.getItem(THEME_KEY) || mode; } catch (e) {}
if (LANGS.indexOf(lang) >= 0) document.documentElement.lang = lang;
var dark = mode === 'dark' || (mode !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
if (q.has('lang') || q.has('theme')) {
q.delete('lang'); q.delete('theme');
history.replaceState(history.state, '', url.pathname + url.search + url.hash);
}
})();
</script>
Labels & languages
The library ships no translations of its own. Every visible or spoken string — placeholders,
empty states, validation messages and the accessible names of icon-only buttons — is an input with
an English default (since 0.0.54; earlier releases defaulted some components to French). An app
serving more than one language binds its translations, e.g.
<sds-select [placeholder]="'common.select' | translate" [clearLabel]="'common.clear' | translate" />.
Locale-aware parts take a locale input: sds-date-picker/sds-calendar (month and weekday names,
default en-GB), sds-input-number and sds-table (number/currency formatting), sds-phone-input
(country names via Intl.DisplayNames; empty = built-in English names).
Troubleshooting
| Symptom | Cause / fix |
|---|---|
403 insufficient_scope | Token is missing read_package_registry scope |
| Components render unstyled | Missing/incorrect @source line, or no theme preset imported (step 3b) |
| Brand colours are wrong / absent | The theme preset is imported BEFORE @import "tailwindcss", so it is overridden |
sds-icon shows nothing | The name isn't in the icon registry; check available names via the SdsIconName type |
Versioning
Semver. Each release is published from the workspace with npm run release:lib (bump → build →
publish). Pin a version in your app and bump deliberately.