Atomic Decompose
The first question a serious team asks before adopting a component library is the exit question:
"If we ship our design system on top of this, can we take the visuals back later without rewriting our consumer code?"
CNGX is built so the answer is yes.
Every organism is authored as two seams the consumer can separate:
- A brain (behaviour, state, ARIA, focus, async lifecycle) that stays linked from the library and keeps receiving updates.
- A skin (template, structural CSS, themable tokens) that the consumer can eject into their own source tree and own outright.
The exit is mechanical, not a rewrite.
This page is about the contract that makes that split work.
The Instrumentation Pattern doc covers the same idea applied to Angular Material; here we describe the CNGX-internal authoring rules every organism follows so the decompose split lands cleanly.
The contract token
The split is held together by InjectionToken<TContract> declarations.
- The interface is the public seam.
- The class that satisfies the interface is private to the lib.
- Consumers, sibling components, and the ejected skin all talk to the token, never to the class.
Canonical example
The canonical example is the form-field control contract in projects/core/tokens/form-field-control.token.ts:22-53:
export interface CngxFormFieldControl {
readonly id: Signal<string>;
readonly focused: Signal<boolean>;
readonly empty: Signal<boolean>;
readonly disabled: Signal<boolean>;
readonly errorState: Signal<boolean>;
focus?(options?: FocusOptions): void;
}
export const CNGX_FORM_FIELD_CONTROL = new InjectionToken<CngxFormFieldControl>(
'CngxFormFieldControl',
);Five signals plus an optional focus() method. That is the entire surface the host needs to render ARIA, run focus management, and react to state.
Anything that fulfils this shape is a valid control:
CngxSelectCngxMultiSelectCngxButtonToggleGroup- A custom organism the consumer writes after decompose
They all participate in the same form-field machinery without sharing a class.
Where the token lives
The token lives in @cngx/core/tokens (Level 1), not in @cngx/forms/field (Level 3). This is deliberate.
Level-2 atoms in @cngx/common/* can provide the token and integrate with form-fields without importing Level-3 code. The contract is a Level-1 interface; only the implementations are higher up.
Three decompose levels
Decompose targets are scoped by atomic level. The operational consequence is short: only organisms can be ejected. See The CNGX Way for the full atomic taxonomy.
The taxonomy
| Level | Example | Decompose? |
|---|---|---|
| Atom | CngxRovingTabindex, CngxFocusTrap, CngxActiveDescendant, CngxLiveRegion |
No (terminal unit) |
| Molecule | CngxListboxTrigger, CngxPopoverTrigger, CngxOption |
No (terminal unit) |
| Organism | CngxSelect, CngxTreeSelect, CngxFormField, CngxStepper, CngxButtonToggleGroup |
Yes |
Why atoms and molecules are terminal
Atoms and molecules have nothing to eject. They are headless directives or single-responsibility units; their entire surface is behaviour.
If you want to swap how an atom looks, you do not eject it. You compose a different organism that uses the same atom under a different template.
Only organisms carry a template and a stylesheet, which is exactly what decompose ejects.
The brain underneath stays in the library:
- The host directives
- The contract tokens
- The controller factories
The brain stays, the skin ejects
CngxSelect in projects/forms/select/single-select/select.component.ts:151-176 is the reference implementation. Look at what the @Component block carries:
@Component({
selector: 'cngx-select',
imports: [CngxClickOutside, CngxListbox, CngxListboxTrigger, CngxPopover, CngxPopoverTrigger, CngxSelectPanel, NgTemplateOutlet],
providers: [
{ provide: CNGX_FORM_FIELD_CONTROL, useExisting: CngxSelect },
{ provide: CNGX_STATEFUL, useFactory: ... },
{ provide: CNGX_SELECT_PANEL_HOST, useExisting: CngxSelect },
{ provide: CNGX_SELECT_PANEL_VIEW_HOST, useExisting: CngxSelect },
],
template: `...`,
styleUrls: ['./select.component.css'],
})
export class CngxSelect<T = unknown> implements CngxFormFieldControl { ... }What ejects vs what stays linked
When a consumer runs decompose on CngxSelect, the seam runs through that @Component block:
What ejects into the consumer's source:
- The template (the
<div role="combobox">shell, the trigger markup, the panel outlet) select.component.css(the structural layout)- The minimal
@Componentmetadata around the ejected class
What stays linked from the library:
- The behaviour atoms in
imports:CngxClickOutside,CngxListbox,CngxListboxTrigger,CngxPopover,CngxPopoverTrigger,CngxSelectPanel - Every contract token in
providers(CNGX_FORM_FIELD_CONTROL,CNGX_STATEFUL,CNGX_SELECT_PANEL_HOST,CNGX_SELECT_PANEL_VIEW_HOST) - The shared
createSelectCore<T, TCommit>factory and the 18 override factories the select family declares - The form-field integration (every host that consumes
CNGX_FORM_FIELD_CONTROL)
The ejected class still implements CngxFormFieldControl. It still provides the same four tokens. It still imports the same six host directives.
The consumer owns the visuals; the library owns the brain.
The empty-shell variant
A second pattern shows up in CngxFormField itself.
The component class in projects/forms/field/form-field.component.ts:48-56 is empty:
@Component({
selector: 'cngx-form-field',
template: `<ng-content />`,
hostDirectives: [
CngxErrorScopeFieldBridge,
{ directive: CngxFormFieldPresenter, inputs: ['field'] },
],
})
export class CngxFormField {}The brain is CngxFormFieldPresenter (a Level-2 host directive in projects/forms/field/form-field-presenter.ts:48-64). The skin is <ng-content /> plus a few CSS class hooks.
After decompose the consumer can replace the template with whatever wrapper markup their design system wants. The presenter still owns inputId, describedById, ariaInvalid, error reveal, and every other ARIA derivation.
The slot registry
Not every consumer wants to eject.
Most of the time the visuals are 90% correct and they only need to swap one piece:
- The caret
- The empty state
- The option label
- The trigger placeholder
The select family handles this with seventeen named template slots and a registry that resolves them.
Slot directives
The slot directives live in projects/forms/select/shared/template-slots.ts. Three real examples:
*cngxSelectCheck(template-slots.ts:269) - overrides the selection indicator inside each option.*cngxSelectCaret(template-slots.ts:315) - overrides the trigger caret glyph.*cngxSelectEmpty(template-slots.ts:453) - overrides the "no options" panel.
Registry resolution
The component resolves them through CNGX_TEMPLATE_REGISTRY_FACTORY (projects/forms/select/single-select/select.component.ts:422):
protected readonly tpl = inject(CNGX_TEMPLATE_REGISTRY_FACTORY)<T>({
check: this.checkDirective,
caret: this.caretDirective,
optgroup: this.optgroupDirective,
placeholder: this.placeholderDirective,
empty: this.emptyDirective,
loading: this.loadingDirective,
// ...
});Consumer-side authoring
Authoring a slot consumer-side is a one-liner:
<cngx-select [field]="f.country" [options]="countries">
<ng-template cngxSelectEmpty>No countries match your filter.</ng-template>
</cngx-select>This is the middle path between theming and full decompose. The brain runs unchanged, the structural skin stays, and the consumer overrides a single visual fragment.
The Bridge-input rule
When you write a directive that injects a host contract (like CNGX_STATEFUL) as an optional fallback, the directive must accept its driver input as optional, not required.
The reason is decompose-symmetric. The same directive must run with the input bound or with the input absent (fallback via DI), and input.required() would fail at instantiation in the second mode.
The optional-string-transform shape
Use the optional-string-transform shape:
readonly state = input<CngxAsyncState<unknown> | undefined, CngxAsyncState<unknown> | '' | undefined>(
undefined,
{ alias: 'cngxToastOn', transform: (v) => (typeof v === 'string' ? undefined : v) },
);Real-world callers
Real example: [cngxToastOn], [cngxBannerOn], and [cngxAlertOn] follow this rule.
They accept an explicit [state] binding when the consumer wants to point them at a specific signal. They fall back to CNGX_STATEFUL injection when they sit inside an organism that provides it (CngxSelect, CngxStepper, CngxMatStepperBridge).
Both shapes survive decompose.
When to decompose
Three knobs, in increasing order of disruption:
The three knobs
Theme tokens. Need different colours, spacings, font sizes?
Override the
--cngx-*CSS custom properties on a parent selector. The library publishes fallback defaults; consumer values win. No code changes.Slot template. Need a different caret glyph, empty state, or option row layout?
Project a
*cngxSelect...slot template. The structural shell and the brain are untouched.Decompose. Need a fundamentally different visual identity - different DOM structure, a different shell component, a layout the slot registry cannot express?
Eject the skin. The brain stays linked from the library.
The escalation signal
If you find yourself reaching for decompose to change a colour, stop. Use a theme token.
If you find yourself nesting four slots to fight the default shell, that is the signal that decompose is the right move.
What you keep when you decompose
The exit budget is the point.
When the consumer ejects CngxSelect's skin, they inherit a flat HTML file plus a CSS file.
They do not inherit:
What stays in the library
- Keyboard navigation (lives in
CngxListbox+CngxListboxTrigger, still composed viaimports) - Active-descendant focus management (lives in
CngxActiveDescendant) - ARIA wiring for the combobox role (lives in the host bindings backed by
triggerAria()computed signals) - Selection semantics (lives in
createSelectionController<T>, injected viaCNGX_SELECTION_CONTROLLER_FACTORY) - The async-commit lifecycle (lives in
createCommitControllerand theCngxAsyncState<T>view) - The form-field integration (lives behind
CNGX_FORM_FIELD_CONTROL) - The 17 slot directives (still re-projectable from the new shell)
- The 18 override tokens that let the consumer further customise selection, commit, panel hosting, announcement, focus, and template registration
The trade
Those guarantees travel with the brain.
Every library update that fixes a keyboard regression, tightens a focus restore, or adjusts an ARIA attribute reaches the decomposed consumer without a re-eject.
That is the trade. The consumer gets total visual freedom and the maintenance contract on the parts they did not touch.