Reusable Angular CDK stepper with dynamic steps


Often times we want to guide users through a certain flow, and the Angular CDK provides us with a stepper component for us to use. While a simple wizard with fixed/pre-defined steps is a good fit for most cases, there are scenarios where you’d want to mix-and-match steps to accommodate different needs.
That’s the exact situation we ran into on one of the projects I’ve worked on in the past: the “add user” flow had to show different steps based on your role in the application. A local manager could add a user by specifying who they want to add, and what they want to add them as (Bob - Cashier), while a regional manager had an extra step to specify at which location they want to add someone (Bill - Anytown, USA - Clerk). So how can we achieve this?
What are we building?
We’ll create a stepper with the help of the CDK that will render a two- or three-step form based on a condition, by only changing the form and steps we pass into our stepper.
The Setup
A good starting point for our code is the stepper example that’s available in the docs. Here we’ll see that we have two components - CdkCustomStepperWithoutFormExample
and CustomStepper
. As we do want a form, we’ll have to set it up it ourselves. Here, we have two options: one form per stepper, or one form per step. For this implementation, I’ll go with the former (pun intended).
Creating the CDK stepper and adding forms
To add forms to our example, we’ll have to import ReactiveFormsModule
into the CdkCustomStepperWithoutFormExample
component, and define our form. As we have two steps, I’ll create a main form, with two subgroups (one for each step) and one control per group. Let’s call them foo
and bar
.
@Component({
// ...
imports: [
// ...
ReactiveFormsModule
],
})
export class CdkCustomStepperWithoutFormExample {
form = new FormGroup({
fooStep: new FormGroup({
foo: new FormControl('asd'),
}),
barStep: new FormGroup({
bar: new FormControl('123'),
}),
});
}
This change needs to be reflected in our template as well:
<form [formGroup]="form">
<example-custom-stepper>
<cdk-step>
<div [formGroup]="form.controls['fooStep']">
<input formControlName="foo" />
</div>
</cdk-step>
<cdk-step>
<div [formGroup]="form.controls['barStep']">
<input formControlName="bar" />
</div>
</cdk-step>
</example-custom-stepper>
</form>
This should result in the following:
As it can be seen here, for every step, we have to create a cdk-step
and define its contents and configurations. What if there’d be no need to do that, and instead we could just dynamically specify an array of steps to display? We’ll find out shortly.
After all the changes, your app should match this StackBlitz.
Cleaning up cluttered files and code
Now that we got our base setup done, let’s organize our code a bit.
First and foremost, we’ll probably want to use components as our steps, so let’s go ahead and create those. Three components should suffice to achieve our goal, so we’ll have a foo-step
, bar-step
and baz-step
respectively. All three components will be mostly the same - only the form-related stuff will change, so they each refer to their own groups/controls. We’ll have to pass the form that they’ll be working with, an input containing the FormGroup
will be necessary.
Since we’ll probably want to have a nicer aspect to our stepper by adding a header/footer, we don’t really want to duplicate that code every time we use it in a different page, so let’s put all of that into a StepperComponent
.
Another improvement would be streamlining the usage of the stepper. Looking at the CdkCustomStepperWithoutFormExample
HTML above, we can see that it’s mostly boilerplate (wrapping the stepper in a form
, creating the steps, setting up fields, etc). This could be tucked away in it’s own wrapper - so let’s do that and put this into the StepperWrapperComponent
. This component should have only two inputs: the steps to display, and the form to work with. This way, using the stepper will be an one liner.
We won’t be using the CustomStepper
component - it can be deleted. As CdkCustomStepperWithoutFormExample
is our bootstrap component, we need to update the HTML to use our new StepperWrapperComponent
, to which we can pass the form, and an empty array for the steps (as we don’t have them defined yet). Let’s not forget to update the imports - the CdkStepperModule
can be removed, and the StepperWrapperComponent
needs to be added.
By this point, the app seems “broken”, as only a left/right arrow shows up. No worries, as we don’t have the step rendering in place (and also we provided an empty array as the steps, so nothing to render either way).
Quite a few changes have been made in this section, so instead of adding a massive amount of code snippets, you can check out the StackBlitz that corresponds to the current state of the application.
Adding the dynamic part
All that’s left now is to render our steps. But what is a step? Well, it’s more or less the component we want to render, and the label to show in the “progress bar”. Great, so this means we want to render a component dynamically. Achieving this is quite straight forward with a directive to which we can pass the component we want to render (so foo-step
and the others). As our step components require an input, we’ll have to set those as well. Let’s wrap these two things into a class and call it DynamicStep
.
The directive will receive this DynamicStep
, clear its view container, then render the step’s component.
“Just show me the code!”:
import {
ComponentRef,
DestroyRef,
Directive,
effect,
inject,
input,
untracked,
ViewContainerRef,
} from '@angular/core';
export class DynamicStep {
constructor(public component: any, public data: any) { }
}
@Directive({
selector: '[dynamicStep]',
standalone: true,
})
export class DynamicDirective {
vcr = inject(ViewContainerRef);
dynamicStep = input.required<DynamicStep>();
componentRef: ComponentRef<{ data: any }> | undefined;
constructor() {
effect(() => untracked(() => this.createDynamicComponent()));
inject(DestroyRef).onDestroy(() => this.componentRef?.destroy());
}
private createDynamicComponent(): void {
this.vcr.clear();
this.componentRef = this.vcr.createComponent<{ data: any }>(
this.dynamicStep().component
);
this.componentRef.setInput('data', this.dynamicStep().data);
}
}
Now that we have a way of rendering a component, let’s make sure we it in the StepperWrapperComponent
by replacing the comment with <ng-container [dynamicStep]="step.component" />
(don’t forget to update imports).
StackBlitz with the current state: here.
Tying it all together
Only one step left - creating the steps themselves, and the forms they use. What does this entail? Let’s think back to the initial requirement: based on a condition, we want to have different steps in the stepper. Having different steps is basically populating the form with specific steps.
We’ll create a dynamic.helpers.ts
under the dynamic
folder where we’ll put all form- and step-related setup code. First, the form. Knowing that in our case, we’ll have two different flows, we can create two methods that return the appropriate forms, and a third method that decides which one to return based on our condition.
export const getForm = (condition: boolean): FormGroup => {
return condition ? getTwoStepForm() : getThreeStepForm();
}
const getTwoStepForm = (): FormGroup => new FormGroup({
fooStep: new FormGroup({
foo: new FormControl('foo-two'),
}),
barStep: new FormGroup({
bar: new FormControl('bar-two'),
})
});
const getThreeStepForm = (): FormGroup => new FormGroup({
fooStep: new FormGroup({
foo: new FormControl('foo-123'),
}),
barStep: new FormGroup({
bar: new FormControl('bar-asd'),
}),
bazStep: new FormGroup({
baz: new FormControl('123-baz'),
}),
});
Now, to the steps. As described above, a step is a label with a DynamicStep
containing the step’s component with the form control as its input. We’ll provide the overall form, and the formGroupName
that the current step works with, so we can pass only the FormGroup
related to the step. The label
and component
are rendered in the step’s label and content, respectively. This can look like the following:
const getStep = (
formGroup: FormGroup<any>,
formControlName: string,
label: string,
component: any
) => ({
label,
component: new DynamicStep(component, { stepControl: formGroup.controls[formControlName] }),
});
The only thing left to do is to pick out the steps we want, based on our generated form (or the condition itself).
export const getFormSteps = (
form: FormGroup
): StepConfig[] => {
const fooStep = getStep(form, 'fooStep', 'Foo Label', FooStepComponent);
const barStep = getStep(form, 'barStep', 'Bar Label', BarStepComponent);
const bazStep = getStep(form, 'bazStep', 'Baz Label', BazStepComponent);
const formControlCount = Object.keys(form.controls).length;
if (formControlCount === 3) {
return [fooStep, barStep, bazStep];
}
return [fooStep, barStep];
};
The formControlName
set here needs to match the formControlName
used in the Component itself. After having all steps generated, we can decide which set of steps we want to use. In this case, it’s straight forward. As the number of steps differs, we can just check the length of the controls, and return the matching step count. Otherwise, we’d have to pass in the condition we used in getForm
.
Let’s use these newly created functions in the CdkCustomStepperWithoutFormExample
:
export class CdkCustomStepperWithoutFormExample {
form = getForm(Math.random() > 0.5);
steps = getFormSteps(this.form);
}
For lack of a better condition, I’ll just use a random number, thus getting a two or a three step form 50% of the time.
With that, we should have everything implemented, and the application should have the behavior of the initial GIF!
Closing thoughts
As always, the full code for the application is available in this StackBlitz. The state of the application after each section can be found at the end of the end of the said section.
While this post focused mostly on the stepper-part of the story, I’ll be working on some articles that go more into depth on the dynamic aspect. Make sure to keep an eye out for those :)
Subscribe to my newsletter
Read articles from Ákos (heu) directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
