@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 is ngx-signal-forms. They are the same project.

Angular TypeScript License GitHub Sponsors


⚑ Live Demo

Try the library immediately on StackBlitz: Open in 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 File objects.

⚑️ 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

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

SelectorComponentValue Type
ngx-control-textNgxTextComponentstring
ngx-control-numberNgxNumberComponentnumber | null
ngx-control-datepickerNgxDatePickerComponentstring (ISO)
ngx-control-daterangeNgxDateRangePickerComponentNgxDateRange
ngx-control-timepickerNgxTimepickerComponentstring (HH:mm AM/PM)
ngx-control-selectNgxSelectComponentTValue | null
ngx-control-multiselectNgxMultiselectComponentTValue[]
ngx-control-colorsNgxColorsComponentstring (Hex)
ngx-control-checkboxNgxCheckboxComponentboolean
ngx-control-toggleNgxToggleComponentboolean
ngx-control-radioNgxRadioGroupComponentTValue | null
ngx-control-segmentedNgxSegmentedButtonComponentTValue | null
ngx-control-sliderNgxSliderComponentnumber
ngx-control-textareaNgxTextareaComponentstring
ngx-control-fileNgxFileComponentFile | 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:

  1. Directives (Declarative): ngxRequired, ngxEmail, ngxMinLength, ngxMaxLength, ngxPattern, ngxMin, ngxMax. Inputs are reactive: changing [ngxMin] at runtime re-registers the validator.
  2. Pure Functions: compose ngxRequired(), ngxMin(), … with ngxCompose/ngxComposeFirst and register them via the adapter (upsertValidators) or in an ngxForm() schema.
  3. Async Validators: in the schema (field("", [], { asyncValidators: [checkUnique], asyncDebounceMs: 300 })) or via adapter.upsertAsyncValidators(name, key, fns, { debounceMs }) β€” the field's pending signal is true for the whole debounce+run window, results are last-wins, and canSubmit waits for them.
  4. 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 empty paths array). Declare them in ngxForm(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: NgxA11yAnnouncer service integrated into all overlays.
  • Dynamic ARIA: Automatic management of aria-invalid, aria-required, aria-expanded, and aria-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/forms or RxJS β€” the state engine uses native Angular Signals only.

πŸ“„ License

MIT Β© Lorenzo MuscherΓ