npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ngx-signal-forms-renderer

v0.0.1-beta.0

Published

Render Angular Signal Forms from a declarative layout.

Readme

ngx-signal-forms-renderer

Render Angular Signal Forms from a declarative layout.

ngx-signal-forms-renderer is a UI-agnostic library that renders a form from a layout description, powered by Angular Signal Forms (@angular/forms/signals). It separates form concerns into two layers:

  1. Signal Forms — data model + validation + metadata (your domain, untouched)
  2. Layout — structure + which component renders each field (controls, groups, arrays)

You bring your own control components; the renderer wires them to the form.

Live Demo

Installation

npm install ngx-signal-forms-renderer

Requires Angular 22+.

Quick Start

1. Register your control components

// app.config.ts
import { provideSignalFormsRenderer } from 'ngx-signal-forms-renderer';

export const appConfig: ApplicationConfig = {
  providers: [
    provideSignalFormsRenderer({
      types: {
        text: TextInputComponent,
        select: SelectInputComponent,
        textarea: TextareaInputComponent,
      },
    }),
  ],
};

2. Define your form (signal forms)

import { signal } from '@angular/core';
import { form, required, email } from '@angular/forms/signals';

interface UserProfile {
  name: string;
  email: string;
  role: string;
}

const model = signal<UserProfile>({ name: '', email: '', role: 'user' });

const userForm = form(model, (f) => {
  required(f.name);
  email(f.email);
});

3. Describe the layout

Inputs to your components are passed as bindings using inputBinding from @angular/core (reactive — they also support outputBinding and twoWayBinding).

import { inputBinding } from '@angular/core';
import { layout, control, group } from 'ngx-signal-forms-renderer';

const userLayout = layout(userForm, (f) => [
  group(
    {
      name: control(f.name, { type: 'text', bindings: [inputBinding('label', () => 'Full name')] }),
      email: control(f.email, {
        type: 'text',
        bindings: [inputBinding('label', () => 'Email'), inputBinding('inputType', () => 'email')],
      }),
      role: control(f.role, {
        type: 'select',
        bindings: [inputBinding('label', () => 'Role'), inputBinding('options', () => ['user', 'editor', 'admin'])],
      }),
    },
    { component: CardGroupComponent, bindings: [inputBinding('title', () => 'Personal info')] },
  ),
]);

4. Render it

<sfr-form [form]="userForm" [layout]="userLayout" />

Core Concepts

Two-layer architecture

Signal Form (data + validation)          Layout (structure + components)
form(model, schema)                       layout(form, f => [...])
         \                                        /
          \                                      /
           +------  <sfr-form>  ----------------+
                    walks the layout tree
                    resolves each node's component
                    renders it dynamically with reactive bindings

The layout never touches your data rules; signal forms never knows about rendering. A control component you write gets the real FieldTree, so it keeps full access to signal forms features (validation, hidden(), disabled(), metadata, …) — the renderer stays out of the way.

Layout node kinds

  • control — a form control. Its component receives the field (and optionally state), or implements FormValueControl<T>.
  • group — a keyed collection of nodes (Record<string, LayoutNode>), optionally wrapped in a component. Can be data-driven from a sub-object field.
  • array — a list of nodes: static, renderer-owned, or library-iterated from an array field.

Component resolution (two-tier)

Every node resolves its component the same way:

  1. Direct component reference (component) — highest priority
  2. String type resolved via the registry from provideSignalFormsRenderer({ types })
control(f.bio, { component: RichTextEditor });        // direct
control(f.name, { type: 'text' });                    // from the registry

Bindings & directives

Layout nodes carry bindings and directives instead of a plain props object — the same vocabulary the renderer uses internally. inputBinding(name, value) takes a value accessor (() => value): a signal already is one, so pass it directly; wrap plain/static values in () => value.

import { inputBinding, outputBinding } from '@angular/core';

control(f.email, {
  type: 'text',
  bindings: [
    inputBinding('label', () => 'Email'),       // static input value — wrap in an accessor
    inputBinding('hint', hintSignal),           // a signal is already () => value — pass it directly
    outputBinding('focused', onFocus),          // output listener — pass the handler directly
  ],
  directives: [TooltipDirective],               // host directives, conditionally if you like
});

Consumer bindings/directives are merged after the renderer's own (the field/state inputs, or the FormField directive for FormValueControl components) — don't re-bind those.

Visibility

  • A node that maps to a field (any control, or a data-driven group/array) defers to that field's native signal-forms hidden().
  • A layout-level hidden signal (on any node) takes precedence — use it for elements that don't map to a field, or to override.
// In the signal forms schema (semantic — also excluded from validation)
const userForm = form(model, (f) => {
  hidden(f.bio, { when: ({ valueOf }) => valueOf(f.role) !== 'admin' });
});

// Layout-level override
group({ /* ... */ }, { hidden: computed(() => userForm.role().value() !== 'admin') });

API Reference

You build layouts with the functions below — you don't construct node objects by hand. Every builder accepts the same options object:

| Option | Type | Purpose | | ------------ | --------------------------------------------------- | ---------------------------------------------------- | | component | Type<unknown> | Render with this component (takes precedence). | | type | string | Component registered via provideSignalFormsRenderer. | | bindings | Binding[] | Inputs/outputs (inputBinding/outputBinding/twoWayBinding). | | directives | (Type<unknown> \| DirectiveWithBindings<unknown>)[] | Host directives to attach. | | hidden | Signal<boolean> | Layout-level visibility override. |

The builders return LayoutNodes — the only type you'll reference directly, as the children input on custom group/array wrapper components.

Builder functions

control(field, options?)

control(f.name);
control(f.name, { type: 'text', bindings: [inputBinding('label', () => 'Name')] });
control(f.name, { component: CustomInput });

group(...)

A keyed collection of nodes, optionally wrapped in a component. Two forms:

Explicit children — a fixed set of keyed nodes. Use to group unrelated top-level fields under one wrapper (e.g. a card).

group({ name: control(f.name), email: control(f.email) });
group({ name: control(f.name) }, { component: CardComponent });

Data-driven — bound to a sub-object field; the callback receives that object's sub-fields. Use for a nested object (an address, etc.). The group also defers to the field's native hidden().

group(f.address, (g) => ({ street: control(g.street), city: control(g.city) }));
group(f.address, { component: AddressCard }, (g) => ({ street: control(g.street) }));

array(...)

A list of nodes. Four forms, differing in who owns iteration:

Static — a fixed, ordered set of nodes (not driven by an array field). Use for things like wizard steps that just need ordering.

array([control(f.step1), control(f.step2)]);

Library-iterated — the renderer iterates the array field and builds one node per item from your itemLayout. Use for a plain repeating list. Item identity is tracked, so editing/adding/removing/ reordering doesn't recreate (or lose focus in) the other rows.

array(f.items, (item) => group(item, (g) => ({ name: control(g.name) })));

Library-iterated + wrapper — same per-item iteration, but the per-item nodes are passed to a wrapper component as its children. Use when the list needs UI around each item (reorder, remove, "add"): the library builds each item, your component arranges them and adds the controls.

array(f.items, { component: SortableList }, (item) => group(item, (g) => ({ name: control(g.name) })));

Renderer-owned — the component receives the array field itself and does its own iteration and mutation. Use when you want full control over how the array renders and changes.

array(f.items, { component: ItemsListComponent });

layout(form, fn)

Returns a Signal<LayoutNode[]>. Reading signals inside fn makes the layout reactive.

const myLayout = layout(userForm, (f) => [
  group({ name: control(f.name, { type: 'text' }) }),
  array(f.items, (item) => group(item, (g) => ({ name: control(g.name) }))),
]);

Provider

provideSignalFormsRenderer({
  types: { text: TextInputComponent, select: SelectInputComponent, card: CardGroupComponent },
});

Components

<sfr-form>

| Input | Type | Description | | -------- | ----------------------- | ------------------------------------------ | | form | FieldTree<T> | Required. The signal form. | | layout | Signal<LayoutNode[]> | Required. The signal from layout(). |

<sfr-form [form]="userForm" [layout]="userLayout" />

<sfr-children>

Helper for custom group/array components to render their children. Renders no element of its own (display: contents), so your component's element is the layout container.

@Component({
  imports: [SfrChildrenComponent],
  template: `
    <mat-card>
      <mat-card-header><mat-card-title>{{ title() }}</mat-card-title></mat-card-header>
      <mat-card-content><sfr-children [children]="children()" /></mat-card-content>
    </mat-card>
  `,
})
export class CardGroupComponent {
  readonly title = input('');
  readonly children = input.required<Record<string, LayoutNode>>();
}

Component contracts

Control components

The renderer binds field (the FieldTree) and state (the FieldState) only if the component declares those inputs — so a control opts into exactly what it needs.

Pattern A — wrap an existing UI control (Material, PrimeNG, …) via [formField]:

@Component({
  imports: [MatFormField, MatLabel, MatInput, MatError, FormField],
  template: `
    <mat-form-field>
      <mat-label>{{ label() }}</mat-label>
      <input matInput [formField]="field()" />
      <mat-error>{{ state().errors()[0]?.message }}</mat-error>
    </mat-form-field>
  `,
})
export class TextInputComponent {
  readonly field = input.required<FieldTree<string>>();
  readonly state = input.required<FieldState<string>>(); // declare only if you read it
  readonly label = input('');                            // from bindings
}

A control that only binds via [formField] and shows no errors can declare just field.

Pattern B — fully custom control implementing FormValueControl<T>. The renderer attaches the FormField directive (binding value/touched/etc.), so it receives no field/state:

export class RatingComponent implements FormValueControl<number> {
  readonly value = model(0);   // required — two-way bound by FormField
  readonly label = input('');  // from bindings
}

Group / array components

Receive children (and any custom bindings). Render them with <sfr-children>, or iterate for custom placement.

export class StepperComponent {
  readonly children = input.required<LayoutNode[]>();
}

Layout composability

Layouts are plain functions returning LayoutNode[], so composition is just TypeScript.

function addressFields(addr: FieldTree<Address>): Record<string, LayoutNode> {
  return {
    street: control(addr.street, { type: 'text', bindings: [inputBinding('label', () => 'Street')] }),
    city: control(addr.city, { type: 'text', bindings: [inputBinding('label', () => 'City')] }),
  };
}

const orderLayout = layout(orderForm, (f) => [
  group(f.shippingAddress, { component: CardGroupComponent }, addressFields),
  group(f.billingAddress, { component: CardGroupComponent }, addressFields),
]);

Because the layout is just data + functions, you can also build it from your own schema/config — the demo renders a whole checkout form from a plain-data schema mapped onto these builders.

License

MIT