@ngx-signals/forms
July 15, 2026 Β· View on GitHub
Declarative, signal-driven form library for Angular. Built entirely on native Angular Signals (no
@angular/forms, no RxJS), it provides a type-safe, reactive, and highly accessible way to build modern forms with Apple HIG + Material Design 3 aesthetics.Naming note: the npm package is
@ngx-signals/forms; the source repository isngx-signal-forms. They are the same project.
β‘ Live Demo
Try the library immediately on StackBlitz:
π Key Features
- Signals-First: True reactive state management using native Angular Signals.
- Typed Forms β
ngxForm(): schema-first models with nested groups and[field]bindings β field-path typos are compile errors. - Three Usage Modes: Typed (schema-first), Declarative (zero boilerplate), or Explicit Adapter (maximum control).
- 15+ Built-in Renderers: From text and selects to M3-spec DatePickers, TimePickers, and Color pickers.
- Declarative Validation: Apply rules directly in templates using directives like
ngxRequired,ngxEmail, etc. - Rich UI Toolkit: Support for Floating Labels, Prefixes/Suffixes, Supporting Text, and Inline Errors.
- Material Design 3: Pixel-perfect adherence to M3 specs for interactions, states, and accessibility.
- Maximum A11y: Built-in ARIA management, keyboard navigation, and screen reader announcements.
- Full Strict Type-Safety: End-to-end typing for your form models.
- Form Serialization: Safely serialize form values, including
Fileobjects.
β‘οΈ Quick Start (Local Development)
To get the project up and running locally for development or to explore the demo:
# 1. Install dependencies and build the library
npm run setup
# 2. Start the demo application
npm start
π¦ Installation
npm install @ngx-signals/forms
2. Import Styles
Add the library styles to your angular.json file. The ngx-signal-forms.css entry point includes all tokens, base layout, and component styles (theme alternatives: ngx-signal-forms-material.css, ngx-signal-forms-ios.css, ngx-signal-forms-ionic.css, or the bare ngx-signal-forms-base.css):
"styles": [
"node_modules/@ngx-signals/forms/styles/ngx-signal-forms.css",
"src/styles.scss"
],
π Usage Modes
1. Declarative Mode (Recommended)
Zero boilerplate. Define your form structure, values, and validation rules directly in the template.
<ngx-form [formValue]="{ speed: 50 }" (submitted)="save($event)">
<ngx-control-text
name="username"
label="Username"
ngxRequired
ngxMinLength="3"
/>
<ngx-control-slider name="speed" label="Max Speed" [min]="0" [max]="100" />
<button type="submit">Save</button>
</ngx-form>
2. Typed Mode β ngxForm()
Schema-first and fully type-safe: initial values and validators live in
TypeScript, and [field] replaces the stringly name attribute β a typo
on a field path is a compile error. Groups nest arbitrarily and map to
dotted adapter paths (address.city).
import { field, group, ngxForm, ngxRequired, ngxEmail, ngxMin } from "@ngx-signals/forms";
export class SignupComponent {
readonly form = ngxForm({
email: field("", [ngxRequired(), ngxEmail()]),
age: field<number | null>(null, [ngxMin(18)]),
address: group({ city: field("Rome"), zip: field("") }),
});
save = async (): Promise<void> => {
await this.form.submit(async (value) => {
// value: { email: string; age: number | null; address: { city: string; zip: string } }
return api.signup(value); // return NgxFormError[] to show server errors on fields
});
};
}
<ngx-form [form]="form" (submitted)="onSubmitted($event)">
<ngx-control-text [field]="form.f.email" label="Email" />
<ngx-control-number [field]="form.f.age" label="Age" />
<ngx-control-text [field]="form.f.address.city" label="City" />
<button type="submit" [disabled]="!form.state.canSubmit()">Sign up</button>
</ngx-form>
Every handle on form.f is reactive and typed: value(), errors(),
touched(), dirty(), valid(), pending(), set(v). The model API is
typed end-to-end too: getValue(), setValue(v), patch({ address: { city: "Milan" } }),
reset(), state.canSubmit(). Async validators go in the schema
(field("", [], { asyncValidators: [checkUnique] })) and drive the real
pending signal. name-based controls and validator directives keep working
inside the same <ngx-form [form]> β adoption is incremental.
Undo/redo & minimal patches β pass history: true and the form records
value snapshots: form.undo(), form.redo(), with reactive canUndo()/
canRedo() for your toolbar buttons. form.getChanges() returns the minimal
nested patch (only fields that differ from the schema initials) β ready for
an HTTP PATCH:
const form = ngxForm(schema, { history: true });
form.getChanges(); // { address: { zip: "00100" } } β only what changed
Draft autosave β ngxForm(schema, { draft: "signup" }) (or
[draftKey]="'signup'" on a declarative <ngx-form>) persists the value to
localStorage on every debounced change and restores it on reload: the user
never loses what they typed. The draft clears itself after an error-free
submit; form.hasDraft() and form.clearDraft() give you UI control, and a
custom NgxDraftStorage swaps the backend (IndexedDB, server, β¦).
Multi-step wizard β <ngx-form-wizard> splits one form into steps with
per-step validation, progress header and navigation:
<ngx-form [form]="form">
<ngx-form-wizard (finished)="save()">
<ngx-wizard-step label="Account" [fields]="[form.f.email, form.f.password]">
<ngx-control-text [field]="form.f.email" label="Email" />
</ngx-wizard-step>
<ngx-wizard-step label="Address" [fields]="[form.f.address.city]">β¦</ngx-wizard-step>
</ngx-form-wizard>
</ngx-form>
"Next" is gated on the active step's [fields] (invalid fields get marked
touched), steps stay alive when hidden (values survive navigation), the step
tabs allow jumping backwards freely and forwards only across valid steps.
Combine with draft: for long forms that survive a browser crash.
2b. Schema-first β Zod adapter (@ngx-signals/forms/zod)
One source of truth for types, validators, messages and required flags β the
same schema your backend/tRPC layer already uses. Ships as a secondary entry
point with zod as an optional peer: zero weight if you don't use it.
import { ngxFormFromSchema } from "@ngx-signals/forms/zod";
import { z } from "zod";
readonly form = ngxFormFromSchema(
z.object({
email: z.string().email(),
age: z.number().min(18).default(18),
address: z.object({ city: z.string().min(1), zip: z.string().default("") }),
}).refine(v => v.age >= 21 || v.address.city !== "", {
path: ["address", "city"],
message: "City required under 21",
}),
);
Nested z.object()s become groups, .default()/.optional() seed initial
values, pieces that reject empty values drive aria-required, and
object-level refine()/superRefine() issues surface as cross-field errors
on the path they declare. The result is a regular NgxTypedForm: bind it
with [form] and [field] exactly like ngxForm().
3. Explicit Adapter Mode
Full control. Create the adapter in your component to seed values, register validators programmatically, and drive submit with an async action (whose returned NgxFormError[] are shown on the matching fields):
import { NgxDeclarativeAdapter, ngxRequired, ngxMin } from "@ngx-signals/forms";
export class Component {
private readonly injector = inject(Injector);
readonly adapter = new NgxDeclarativeAdapter(
signal({ name: "", age: 18 }), // seed values
signal("valid-only"), // submit mode
this.injector, // enables async validators
);
constructor() {
this.adapter.upsertValidators("name", "cmp", [ngxRequired()], true);
this.adapter.upsertValidators("age", "cmp", [ngxMin(18)]);
// Async validation with a real pending state:
this.adapter.upsertAsyncValidators("name", "cmp", [
async (v) => ((await isNameTaken(v)) ? ["Name already taken"] : []),
]);
}
readonly save = async (value: Record<string, unknown>) => {
const res = await api.save(value);
// Return server errors with a path to show them on the field:
return res.ok ? [] : [{ path: "name", kind: "server", message: res.error }];
};
}
<ngx-form [adapter]="adapter" [action]="save">
<ngx-control-text name="name" label="Full Name" />
<ngx-control-number name="age" label="Age" />
</ngx-form>
Any object implementing the exported NgxFormAdapter interface works too.
π§ Headless Mode β @ngx-signals/forms/core
Bring your own design system: the core entry point exposes the engine only
β ngxForm(), the declarative adapter, validators, field/form state types,
DI tokens, i18n and utilities β with no renderer components and no CSS.
import { ngxForm, field, ngxRequired } from "@ngx-signals/forms/core";
const form = ngxForm({ email: field("", [ngxRequired()]) });
// form.f.email.value(), errors(), pending() β¦ drive your own widgets
Same module instances and DI tokens as the primary entry point, so you can mix headless fields with the ready-made M3/HIG renderers during migration.
π¨ Component Catalog
| Selector | Component | Value Type |
|---|---|---|
ngx-control-text | NgxTextComponent | string |
ngx-control-number | NgxNumberComponent | number | null |
ngx-control-datepicker | NgxDatePickerComponent | string (ISO) |
ngx-control-daterange | NgxDateRangePickerComponent | NgxDateRange |
ngx-control-timepicker | NgxTimepickerComponent | string (HH:mm AM/PM) |
ngx-control-select | NgxSelectComponent | TValue | null |
ngx-control-multiselect | NgxMultiselectComponent | TValue[] |
ngx-control-colors | NgxColorsComponent | string (Hex) |
ngx-control-checkbox | NgxCheckboxComponent | boolean |
ngx-control-toggle | NgxToggleComponent | boolean |
ngx-control-radio | NgxRadioGroupComponent | TValue | null |
ngx-control-segmented | NgxSegmentedButtonComponent | TValue | null |
ngx-control-slider | NgxSliderComponent | number |
ngx-control-textarea | NgxTextareaComponent | string |
ngx-control-file | NgxFileComponent | File | File[] | null |
β¨ UI Enhancements
Every control supports rich UI decorations to match modern Design Systems:
Prefixes & Suffixes
Add icons or text before or after the input.
<ngx-control-text name="price" label="Price">
<span ngxPrefix>$</span>
<span ngxSuffix>.00</span>
</ngx-control-text>
Floating Labels & Supporting Text
Enable Material-style floating labels and provide helper text.
<ngx-form [ngxFloatingLabels]="true" [ngxFloatingLabelsDensity]="-2">
<ngx-control-text name="email" label="Email">
<small ngxSupportingText>We'll never share your email.</small>
</ngx-control-text>
</ngx-form>
Inline Errors
Show errors immediately via the ngxInlineErrors directive.
<ngx-control-text name="password" label="Password" ngxInlineErrors />
π DevTools β live form inspector
"Why is my form invalid?" answered in one panel. Mark a form as inspectable and press Ctrl+Shift+D:
<ngx-form [form]="form" ngxDevtools>β¦</ngx-form>
The hotkey (configurable via NGX_DEVTOOLS_HOTKEY) opens a draggable
overlay on the selected form β the registered form containing the
focused element, falling back to the last registered one. Drag it by the
title bar, close with β or Escape. The panel shows the form state signals
(valid/pending/submitting/submitCount/canSubmit), a per-field table (value,
valid/touched/dirty/pending, error messages β rows derived automatically
from the registered fields), the live JSON value and the last submit
errors.
Prefer it inline? <ngx-forms-devtools [form]="form" /> still embeds the
panel anywhere (optionally with explicit [fields] handles), and
NgxFormsDevtoolsService.toggle(form) opens the overlay programmatically.
No providers, tree-shakes away when unused.
π Interop β Reactive Forms (@ngx-signals/forms/interop)
Adopt incrementally in an existing codebase: the ngxCva directive makes any
renderer a ControlValueAccessor, so it plugs into formControlName /
formControl / ngModel without an <ngx-form>. @angular/forms is an
optional peer β zero weight if you skip it.
<form [formGroup]="group">
<ngx-control-text ngxCva name="email" formControlName="email" label="Email" />
</form>
Value, touched and disabled state sync both ways; validation stays in
Reactive Forms. (An official bridge to Angular's experimental
@angular/forms/signals lands once that API stabilizes.)
π§© Dynamic Forms β render from JSON config
readonly fields: NgxDynamicField[] = [
{ kind: "text", name: "fullName", label: "Full name", validators: { required: true, minLength: 2 } },
{ kind: "select", name: "topic", label: "Topic", options: [{ value: "sales", label: "Sales" }] },
{ kind: "slider", name: "score", label: "Score", min: 0, max: 10 },
];
<ngx-dynamic-form [fields]="fields" (submitted)="save($event)">
<button type="submit">Send</button>
</ngx-dynamic-form>
The config is a serializable discriminated union (store it in a CMS or
form-builder backend): 14 field kinds map to the renderer catalog and the
JSON-safe validator set (required, email, min/max, minLength/maxLength,
pattern) maps to the pure validator functions. Projected content (like the
submit button) lands inside the generated <ngx-form>; the inner form is
exposed via the form view child for getValue()/reset()/submit().
π Enterprise Select β server-side search & tagging
<ngx-control-select
name="city"
searchable
allowCreate
[ngxLoadOptions]="searchCities"
[ngxLoadOptionsDebounce]="300"
(optionCreated)="addCity($event)"
/>
[ngxLoadOptions] calls (query) => Promise<NgxSelectOption[]> on every
debounced query change (including the initial empty query) with the loading
spinner driven for the whole window and last-wins semantics on out-of-order
responses β works on select and multiselect. allowCreate adds a
"Create Β«queryΒ»" row when no option label matches: pick it and
optionCreated fires with the query so you can add the option and select it.
β Validation
You have four ways to validate your forms:
- Directives (Declarative):
ngxRequired,ngxEmail,ngxMinLength,ngxMaxLength,ngxPattern,ngxMin,ngxMax. Inputs are reactive: changing[ngxMin]at runtime re-registers the validator. - Pure Functions: compose
ngxRequired(),ngxMin(), β¦ withngxCompose/ngxComposeFirstand register them via the adapter (upsertValidators) or in anngxForm()schema. - Async Validators: in the schema (
field("", [], { asyncValidators: [checkUnique], asyncDebounceMs: 300 })) or viaadapter.upsertAsyncValidators(name, key, fns, { debounceMs })β the field'spendingsignal is true for the whole debounce+run window, results are last-wins, andcanSubmitwaits for them. - Cross-Field Validators:
crossField(paths, fn)receives the whole form value and attributes its error to every involved field (or to the form with an emptypathsarray). Declare them inngxForm(schema, { validators: [...] })or bind[formValidators]on<ngx-form>in declarative mode.
const form = ngxForm(
{
password: field("", [ngxRequired()]),
confirm: field("", [ngxRequired()]),
},
{
validators: [
crossField(["password", "confirm"], v =>
v.password === v.confirm ? null : "Passwords do not match"),
],
},
);
βΏοΈ Accessibility (a11y)
The library is built on top of Material Design 3 accessibility patterns:
- Keyboard Navigation: Full support for DatePickers, Selects, and TimePickers (Arrow keys, Space, Enter, Escape).
- Screen Reader Announcements:
NgxA11yAnnouncerservice integrated into all overlays. - Dynamic ARIA: Automatic management of
aria-invalid,aria-required,aria-expanded, andaria-activedescendant. - Focus Management: Robust "roving focus" and visual focus indicators.
π I18n β One-call locale setup
Built-in presets for en, it, de, fr, es cover every UI string
plus Intl-based date localisation (month/day names, first day of week):
bootstrapApplication(App, {
providers: [provideNgxSignalFormsLocale("it")],
});
// options: provideNgxSignalFormsLocale("it", { dateLocale: "it-CH", overrides: { noResults: "β¦" } })
For other languages provide NGX_I18N_MESSAGES directly β it is a plain
object of strings (NGX_I18N_MESSAGES_DEFAULT is the English reference).
The timepicker supports the 24-hour clock: <ngx-control-timepicker format="24h" />
stores "HH:mm" values ("14:30"), hides the AM/PM toggle and accepts 0-23
in the hour input.
The datepicker (in the default displayFormat="localized") also parses
typed dates in the locale's numeric format β 31/12/2026 in it-IT,
12/31/2026 in en-US, 31.12.2026 in de-DE β with the day/month order
derived from Intl (parseLocalizedDate is exported for reuse). ISO input
keeps working everywhere.
π I18n β Date Locale
The DatePicker automatically detects the browser locale. You can override it via DI:
providers: [
{ provide: NGX_DATE_LOCALE, useValue: buildDateLocale("it-IT", 1) }, // 1 = Monday
];
π Advanced Features
Conditional Options
Effortlessly link two selectors (e.g., Country -> Province).
<ngx-control-select name="country" [options]="countries" />
<ngx-control-select
name="province"
[ngxDependsOn]="'country'"
[ngxOptionsMap]="provincesByCountry"
/>
Form Serialization
Safely serialize form values, including File objects.
const data = ngxFormSerialize(adapter.getValue());
π¨ Token Customization
The library uses a 3-tier CSS custom property system. Override tokens at any scope β globally on :root or scoped to a specific container.
Tier 1 β System tokens (--ngx-signal-form-sys-*): semantic design values (color, shape, typography).
/* globals or :root */
:root {
--ngx-signal-form-sys-color-primary: #0071e3; /* primary action color */
--ngx-signal-form-sys-color-on-primary: #ffffff;
--ngx-signal-form-sys-shape-corner-medium: 10px; /* input border radius */
}
Tier 2 β Component tokens (--ngx-signal-form-comp-*): fine-grained per-component overrides.
:root {
--ngx-signal-form-comp-text-input-height: 52px;
--ngx-signal-form-comp-select-option-padding: 12px 16px;
}
Tier 3 β Bridge aliases (--ngx-primary, --ngx-on-surface, etc.): short variables used internally by all component CSS. Setting a bridge alias affects every component that references it and is the quickest path for a global brand colour change:
:root {
--ngx-primary: #0071e3;
--ngx-on-surface: #1d1d1f;
}
Tip: bridge aliases are back-compatible with 2.0.x β existing overrides continue to work unchanged.
π Compatibility
- Requirements: Angular 21+. No dependency on
@angular/formsor RxJS β the state engine uses native Angular Signals only.
π License
MIT Β© Lorenzo MuscherΓ