Responsive Equal Height with Angular Directive

Jecelyn Yeen

Let's look at a very common use case. You has a list of items, you need to display all nicely on screen in card form. Malaysia states

It looks okay but you want all cards to always maintain same height. It should always match the height of the tallest object, and resize accordingly when screen size changed, like this:-

always same height

We can achieve this with by using creating a custom directive.

Interesting? Let's code.

Main Component

Let's look at our main component.

// page-same-height.component.ts

import { Component } from '@angular/core';

@Component({
    selector: 'page-same-height',
    template: `
        <main class="container">
            <h2>Malaysia States</h2>

            <div class="row">
                <div class="col-sm-4" *ngFor="let state of list">
                    <div class="card card-block">
                        <h4 class="card-title">{{ state.title }}</h4>
                        <p class="card-text">
                            {{ state.content }}
                        </p>
                    </div>
                </div>
            </div>
        </main>
   `
})
export class PageSameHeightComponent {
    list = [
        { 
            title: 'Selangor', 
            content: 'Selangor is a state ....' 
        },
        { 
            title: 'Kuala Lumpur', 
            content: 'Kuala Lumpur is the capital of Malaysia...' 
        },
        {
            title: 'Perak',
            content: 'Perak is a state in the northwest of Peninsular Malaysia...'
        }
    ]
}

The code is pretty expressive itself. We have a list of states. We loop the list with *ngFor in our template and display each item accordingly.

Please note that in this example, I am using Boostrap 4 to style the CSS, but it's not neccesary.

How to Match Height?

There are a few ways to match height. In this tutorial, we will match height by using CSS class name.

In our example, we want to match the height of all elements with class name card on the same row. Row is the parent and all Card are the children.

To align all the children Card of the Row, let's modify our HTML template and assign an attribute called myMatchHeight to the row and pass in card as the attribute value.

// page-same-height.component.ts
...

@Component({
    selector: 'page-same-height',
    template: `
        <main class="container">
            <h2>Malaysia States</h2>

            <!-- Assign myMatchHeight here -->
            <div class="row" myMatchHeight="card">
                <div class="col-sm-4" *ngFor="let state of list">
                    <div class="card card-block">

...

Now you might be wondering, where is the myMatchHeight coming from? That is the custom directive that we are going to build next!

Match Height Directive

Let's create our match height directive.

// match-height.directive.ts

import {
    Directive, ElementRef, AfterViewChecked, 
    Input, HostListener
} from '@angular/core';

@Directive({
    selector: '[myMatchHeight]'
})
export class MatchHeightDirective implements AfterViewChecked {
    // class name to match height
    @Input()
    myMatchHeight: string;

    constructor(private el: ElementRef) {
    }

    ngAfterViewChecked() {
        // call our matchHeight function here later
    }

    matchHeight(parent: HTMLElement, className: string) {
        // match height logic here
    }
}

Notes:

  • We create directive by using @Directive decorator. We specify [myMatchHeight] in the selector, this mean that we use it as an attribute in any HTML tag. E.g. We use that in our main component.
  • We have an input myMatchHeight with the same name as our selector. By doing this, we can then use it like this myMatchHeight="some_value", some_value will then be assigned to myMatchHeight variable. E.g. We use that in our main component, we pass in card as the value.
  • Directive has a few lifecycle hooks that we can utilize. To understand more about lifecycle hooks, please refer to this Angular official documentation. In our case, we will do apply our match height magic during AfterViewChecked.
  • We will write all our logic in matchHeight function.

How Should We Start?

Let's breakdown what should we do step by step:-

  1. We need to find all the child elements with the selected class name from the parent.
  2. We need to get all the child elements heights and find out the tallest.
  3. We need to update all the child elements to the tallest height.

Let's code it.

// match-height.directive.ts

...
    matchHeight(parent: HTMLElement, className: string) {
        // match height logic here

        if (!parent) return;

        // step 1: find all the child elements with the selected class name
        const children = parent.getElementsByClassName(className);

        if (!children) return;

        // step 2a: get all the child elements heights
        const itemHeights = Array.from(children)
            .map(x => x.getBoundingClientRect().height);

        // step 2b: find out the tallest
        const maxHeight = itemHeights.reduce((prev, curr) => {
            return curr > prev ? curr : prev;
        }, 0);

        // step 3: update all the child elements to the tallest height
        Array.from(children)
            .forEach((x: HTMLElement) => x.style.height = `${maxHeight}px`);
    }
...

I guess the code is very expressive itself.

Apply the Magic

Now that we have completed our match height logic, let's use it.

// match-height.directive.ts

...
    ngAfterViewChecked() {
        // call our matchHeight function here
        this.matchHeight(this.el.nativeElement, this.myMatchHeight);
    }
...

Remember to import the directive in your app module and add it in declarations.

Refresh your browser and you should see all the card are with same height!

Wait... Are We Done Yet?

Now, let's resize your browser. The card height is not adjusted automatically until you refresh the broswer again. Let's update our code.

Listen to Window Resize event

We need to listen to the window resize event and update the elements' height.

// match-height.directive.ts

...
    @HostListener('window:resize') 
    onResize() {
        // call our matchHeight function here
        this.matchHeight(this.el.nativeElement, this.myMatchHeight);
    }
...

Always Reset all child elements' height first

Another problem you'll see is that when you scale down your browser, the height will be updated accordingly (grow taller). However, when you scale up your browser, the height is not updated (not shrink down).

Why is this happening? It is because once the card size grow taller, all content can fit in and no height adjustment needed.

To solve this, we need to reset the height of all elements before we recalculate the tallest height. We do this after step 1.

// match-height.directive.ts

...
    matchHeight(parent: HTMLElement, className: string) {
        // step 1: find all the child elements with the selected class name
        const children = parent.getElementsByClassName(className);

        if (!children) return;

        // step 1b: reset all children height
        Array.from(children).forEach((x: HTMLElement) => {
            x.style.height = 'initial';
        });

        ...
    }
...

Summary

That's it. Remember that whenever we need to manipulate DOM element, it's recommend that we do it in directive. Creating a custom directive and listening to custom event is easy with Angular Directive.

Our directive work well with any elements including nested component too. Check out the source code for another 2 more examples.

Live Demo: https://ng-musing.firebaseapp.com/same-height Github: https://github.com/chybie/ng-musing/tree/master/src/app/same-height

Happy coding!

Jecelyn Yeen

20 posts

Coder. Diver. Board Game Lover.

Speak English, Mandarin, JavaScript, Typescript, C# and more.

GDE | Angular | Web Technologies.