Get License
angularsignalszonelesschange-detectioncomponent-library

Zero legacy decorators: what a component library written for Angular 21 actually looks like

Most Angular UI libraries are carrying a decade of migration debt. Base UI was written after signals, after standalone, after zoneless — and it shows in the code you inherit.

8 min read Base UI team

Pick any mature Angular component library and open a component. You'll find @Input() decorators, a constructor with six injected services, NgModule exports kept around for compatibility, and a ChangeDetectorRef.markForCheck() somewhere in the middle doing load-bearing work nobody wants to touch.

None of that is incompetence. It's sediment. Those libraries started before signals, before standalone components, before zoneless change detection, and every migration since has had to preserve the apps already depending on the old shape. The result is a library that works, and that quietly hands you a decade of someone else's migration debt the moment you install it.

Base UI was written after all of that landed. Here's what that means concretely.

Signal inputs everywhere. Not "mostly."

Across the entire library there are zero @Input() and zero @Output() decorators. Every input is a signal input; every output uses the output() function.

@Component({
  selector: 'base-badge',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './badge.component.html',
})
export class BadgeComponent {
  /**
   * Visual color of the badge.
   *
   * @example
   * <base-badge color="success">Active</base-badge>
   */
  readonly color = input<BadgeColor>('default');

  /** Emitted when the badge's dismiss control is activated. */
  readonly dismissed = output<void>();

  protected readonly hostClass = computed(() =>
    cn('inline-flex items-center rounded-full', COLORS[this.color()])
  );
}

This matters beyond aesthetics.

A signal input is readable in a computed. That means derived state is declarative — hostClass above recomputes exactly when color changes, with no lifecycle hook, no ngOnChanges switch statement, and no possibility of a stale value. With @Input(), that same derivation is either recalculated on every change detection pass or manually cached in ngOnChanges, and both options have failure modes.

Signal inputs are also typed at the boundary in a way the compiler enforces. input.required<T>() is a compile error if the consumer forgets it, not a runtime undefined three screens later.

OnPush is not an optimization you have to remember

All 213 components in the library ship with ChangeDetectionStrategy.OnPush.

In a library built on @Input() and mutable fields, OnPush is a trap — it's correct until someone mutates a nested object and the view silently stops updating. Teams learn to avoid it, or sprinkle markForCheck() until the symptom goes away.

With signals, OnPush is just how it works. A signal read in a template registers a dependency; writing to that signal marks exactly the views that read it. There's no manual invalidation to forget.

Zoneless-compatible, and tested that way

Angular's zoneless change detection removes zone.js entirely. It's a substantial performance win and it's where the framework is going — but it is merciless about state that isn't a signal. Mutate a plain class field from a setTimeout, a ResizeObserver, or a debounceTime callback in a zoneless app and nothing schedules change detection. The view goes stale, and in dev mode you get an NG0100 on the next tick.

That's a hard bug to find in your own code. It's a much harder bug to find inside a third-party component.

Base UI's demo application bootstraps with provideZonelessChangeDetection() and no zone.js, so every component in the catalog is exercised under zoneless conditions continuously. State that's written outside a template event handler is a signal, because in this environment anything else fails visibly and immediately.

// Written in a debounced resize handler — must be a signal under zoneless.
private readonly viewportWidth = signal(0);

constructor() {
  fromEvent(window, 'resize')
    .pipe(debounceTime(150), takeUntilDestroyed())
    .subscribe(() => this.viewportWidth.set(window.innerWidth));
}

If you're planning a zoneless migration — and if you're on Angular 21, you probably are — a component layer that already works there removes an entire category of unknowns from the project.

Standalone, with no NgModule anywhere

Every component, directive, and pipe is standalone: true. There is no BaseUiModule, no forRoot(), no module you import to unlock a directive.

@Component({
  selector: 'app-settings',
  standalone: true,
  imports: [CardComponent, BaseButtonDirective, InputGroupComponent],
  templateUrl: './settings.component.html',
})
export class SettingsComponent {}

You import the three things you use. Not a barrel that pulls in a component graph you'll spend a build config trying to shake back out.

Directives over wrappers for native elements

Buttons, inputs, and textareas are attribute directives, not wrapper components:

<button base-button color="primary" size="lg">Save changes</button>
<input base-input type="email" formControlName="email" />

The reason is not stylistic. A wrapper component has to re-implement everything the native element already does: disabled, type, focus behavior, form participation, the entire ARIA surface, and every native attribute a consumer might reasonably pass through. Libraries that take the wrapper route ship a long tail of "you can't set X" issues forever.

A directive adds styling to the real element and gets all of that for free — permanently, including behavior the platform adds later.

Reactive forms integration that actually is integration

Twenty-one form controls implement ControlValueAccessor — select, multi-select, datepicker, date range picker, OTP input, phone input, color picker, rich text editor, range and dual-range sliders, tri-state checkbox, toggle, knob, and more.

form = inject(FormBuilder).group({
  country: ['', Validators.required],
  launchWindow: [null as DateRange | null],
  notify: [true],
});
<base-custom-select formControlName="country" [options]="countries" />
<base-date-range-picker formControlName="launchWindow" />
<base-toggle formControlName="notify" />

valueChanges, setValue, patchValue, validators, touched/dirty, and disabled state all behave the way they do for a native input, because the controls participate in the forms API properly rather than emitting a change event and hoping.

Typed inputs, documented at the hover

Inputs use string-literal union types rather than loose strings:

type ButtonColor =
  | 'primary' | 'success' | 'danger' | 'warning'
  | 'accent' | 'white' | 'black' | 'default' | 'transparent';

type ButtonSize = 'sm' | 'default' | 'lg' | 'xl' | 'xxl';

Typo a color and the build fails. Autocomplete lists the valid values. And every public input, output, and class carries JSDoc with an example, which means your editor's hover tooltip answers most questions without a trip to the docs site.

Why this is worth caring about

When you adopt a component library you're not just adopting its components. You're adopting its architectural assumptions, its migration schedule, and its technical debt — and with a copy-in library like Base UI, that code lands directly in your repository.

So it's worth asking what you're inheriting. Here, it's signal-based inputs, OnPush throughout, standalone everything, zoneless-compatible state handling, and proper forms integration. Not because those are checkboxes, but because they're the difference between a component layer that ages with the framework and one you'll be migrating off of in three years.


See the components at base-ui.net →

Base UI is an Angular 21 + Tailwind CSS 4 component library. 187 components and blocks, 390 icons, 102 components free forever. Pro is a one-time $99 license with lifetime updates.

Start with the free tier

All primitives, all 19 form blocks, and 390 icons — no account, no licence key.

Get started

Theme Customizer

Customize your UI instantly.

Primary Color

Border Radius

Background

Mode

Copy these styles to paste into your project's global CSS and Tailwind config.