Responsive Angular Components

Post Editor

Ever since mobile devices became capable of accessing the web, it became obvious that not all web pages are usable on a small screen. Today most websites implement responsive design, but often at a cost of extra DOM. This post will show you how to solve that last step in Angular.

9 min read
post

Responsive Angular Components

Ever since mobile devices became capable of accessing the web, it became obvious that not all web pages are usable on a small screen. Today most websites implement responsive design, but often at a cost of extra DOM. This post will show you how to solve that last step in Angular.

post
post
9 min read

Mobile phones used to be simple devices used for phone calls, short messages and simple games. As network protocols and screens progressed, new devices were capable of showing webpages.

However, due to limited screen real estate and controls they could only open pages written in WML language specially made for the WAP protocol. The iPhone was the first phone truly capable of opening HTML pages.

Ever since mobile devices became capable of accessing the web and showing web pages, it became obvious that not all web pages could work on a small screen. The most obvious was the UI.

Responsive web design
Link to this section

Early websites were built with the computer screen in mind. The images were big, the buttons were small. It looked nice on a big screen and was easy to click on using a mouse. But it was horror on a small screen. As a solution to this responsive web design was born. Using Media queries in CSS developers could now define styles that would be applied for different devices.

Take a look at the following CSS snippet:

<>Copy
header nav { display: block; } @media screen and (max-width: 768px) { header nav { display: none; } } @media screen and (orientation: landscape) { header nav { position: fixed; left: 0; width: 20vw; } }

Header navigation would be rendered in a block. Unless the screen was smaller than 769px (typical mobile resolution), in which case it would hidden. Unless, the screen is in landscape mode (width greater than height), in which case it would be fixed on the left and take 20% of the viewport's width.

Media queries give us the possibility to target various parameters including width and height (both with min and max prefixes), orientation, type of presentation (screen, reader, print), available colours, aspect ratio, resolution, availability of JavaScript parser etc.

Mobile-first design
Link to this section

As computers and browsers became more capable, static websites began to be replaced by full-blown web applications. Those applications had more in common with desktop applications than websites. Changing bits and pieces to accommodate mobile devices was not enough anymore. The applications had to be redesigned from a scratch for smaller screens.

That's how the mobile-first approach was born. In contrast to desktop-first, where the web application simplified for the mobile view, we now had web applications that were developed with the mobile view in mind, and later enriched for the desktop view. This allowed developers to target special small screen limitations early on.

Due to different limitations of the screen estate and accessibility (minimal clickable area, higher contrast) it's not seldom that mobile view and desktop view look completely different.

Such large differences usually require big parts of DOM to be visually modified or hidden. The following snippet is from a popular angular course website. As you can see, on the desktop (wide screen) view the navigation is horizontal with items inline aligned to right.

On the mobile view, suddenly there are a bunch of styles that position navigation fixed, with fixed size and initially out of the screen. The inner list is vertically oriented. When the opened modifier class is applied, the navigation slides in from the left side. Additionally, you can see that some styles had to be overridden (float, display) for our magic to work.

<>Copy
nav { display: block; } nav ul { float: right; } nav ul li { display: inline-block; } @media screen and (max-width: 768px) { nav { display: flex; position: fixed; z-index: 999; width: 73vw; height: 100%; background: #fff; top: 0; left: -100vw; float: none; transition: .25s ease-in-out; } nav ul { float: none; width: 100%; } nav ul li { display: block; } nav.opened { left: 0; } }

The following example is from a popular web tutorials website. On the page we have two menus: main-nav and secondary-nav. Both of them contain the same 11 navigation items. The page uses the grid to position the items and menus. This is an obvious example of the mobile-first approach. While all the menu items are hidden in the main menu, in secondary navigation (which is toggled with the burger-like button), all navigation items are visible.

However, once we pass the 800px limit suddenly the first five items in the main menu are shown, while the first five items in the secondary menu are always hidden.

<>Copy
.main-header { display: grid; } .nav-item-1, .nav-item-2, .nav-item-3, .nav-item-4, .nav-item-5, .nav-item-6, .nav-item-7, .nav-item-8, .nav-item-9, .nav-item-10, .nav-item-11 { display: none; } .main-nav { /* some grid relevant styles */ } .secondary-nav { display: none; } .show-secondary .secondary-nav { display: flex; } .secondary-nav .nav-item-1, .secondary-nav .nav-item-2, .secondary-nav .nav-item-3, .secondary-nav .nav-item-4, .secondary-nav .nav-item-5, .secondary-nav .nav-item-6, .secondary-nav .nav-item-7, .secondary-nav .nav-item-8, .secondary-nav .nav-item-9, .secondary-nav .nav-item-10, .secondary-nav .nav-item-11 { display: flex; } @media (min-width: 800px) { .nav-item-1, .nav-item-2, .nav-item-3, .nav-item-4, .nav-item-5 { display: flex; } .main-nav { /* some grid relevant styles */ } .show-secondary .secondary-nav { display: block; } .secondary-nav .nav-item-1, .secondary-nav .nav-item-2, .secondary-nav .nav-item-3, .secondary-nav .nav-item-4, .secondary-nav .nav-item-5 { display: none; } }

The splitting of the menus is allowing the website to play with shapes and positions and achieve more than the previous example which was only changing the rendering of the menu items. Unfortunately, the price was keeping duplicates in the DOM.

Performance first
Link to this section

As the application grows the price of keeping elements in the DOM that are only visible on certain devices/resolutions can become too expensive. While images on the desktop are usually in the full quality, small screens require less pixels. Playing a video in the background of your webpage is a popular thing to do. But you wouldn't want to do this on the mobile for several reasons:

  • Video would probably not be visible nicely
  • Page scrolling which happens often on small screens would interfere with video
  • It is an unnecessary impact on the battery and bandwidth

Not only do we want to change the looks of the UI to make it more accessible on the small devices, but we also want to remove some heavy elements. While desktop versions get opened mostly on stable networks, mobile versions get opened in situations where the internet connection might be weak or breaking. We want the user to have the same fast experience on mobile as they have on the desktop.

To achieve this, we need to be able to detect devices and remove/add DOM elements depending on the device.

Meet MediaQueryList and matchMedia
Link to this section

Media queries are not only supported in CSS but also in JavaScript. The Window object implements a function matchMedia that returns a response of type MediaQueryList. MediaQueryList extends EventTarget, meaning it can receive events and have listeners set up. It also adds two additional properties:

<>Copy
interface MediaQueryList extends EventTarget { matches: boolean; // => true if document matches the passed media query, false if not media: string; // => the media query used for the matching }

A very simple example could look like this:

<>Copy
const query = '(orientation: portrait)'; const mediaQueryList = window.matchMedia(query); // check the match if (mediaQueryList.matches) { /* we are in the portrait mode */ } else { /* viewport is in the landscape mode */ }

The MediaQueryList becomes even more usable once we attach listeners to it. Let's take the previous example and enhance it a bit:

<>Copy
const query = '(orientation: portrait)'; const mediaQueryList = window.matchMedia(query); // define the callback function for our event listener function listener(mql: MediaQueryList) { if (mql.matches) { /* we are in the portrait mode */ } else { /* viewport is in the landscape mode */ } } // run check once listener(mediaQueryList); // run check on every subsequent change mediaQueryList.addEventListener('change', listener);

Attaching the listener will only trigger our callback upon change, so we have to run it synchronously the first time.

Media Service
Link to this section

Each event listener produces a stream of events. This allows us to wrap the information in an Observable using the service.
The consumer of the service can then subscribe to the stream of media changes and react upon them.

The core of the service is a ReplaySubject to which we will pass all the values from the matchMedia function. The listener part is equivalent to the plain Vanilla TypeScript example above.

<>Copy
class MediaService { private matches = new ReplaySubject<boolean>(1); public match$ = this.matches.asObservable(); constructor(public readonly query: string) { // we need to make sure we are in browser if (window) { const mediaQueryList = window.matchMedia(this.query); // here we pass value to our ReplaySubject const listener = event => this.matches.next(event.matches); // run once and then add listener listener(mediaQueryList); mediaQueryList.addEventListener('change', listener); } } }

We can now use this service in our components to control the visibility of parts of the template. Each time the media query match changes, our property isDesktop will be changed and influence the rendering of the template.

<>Copy
@Component({ selector: 'foo-bar', template: ` <div *ngIf='isDesktop; else isMobile'>I am visible only on desktop</div> <ng-template #isMobile> <div>I am visible only on mobile</div> </ng-template> ` }) class FooBarComponent implements OnInit { isDesktop: boolean; private mediaService = new MediaService('(min-width: 768px)'); ngOnInit() { this.mediaService.match$.subscribe(value => this.isDesktop = value); } }

There are many use cases for MediaService, such as fetching different resources from the backend, calculations based on the media or complex business logic. However, if we only care about manipulating the template we are better off with a dedicated component or directive implementation.

Media component
Link to this section

Instead of using service to subscribe to media changes we can listen to them directly in the component.

<>Copy
@Component({ selector: 'use-media', template: '<ng-content *ngIf="isMatch"></ng-content>' }) class MediaComponent { @Input() set query(value: string) { // cleanup old listener if (this.removeListener) { this.removeListener(); } this.setListener(value); } isMatch = false; private removeListener: () => void; private setListener(query: string) { const mediaQueryList = window.matchMedia(query); const listener = event => this.isMatch = event.matches; // run once and then add listener listener(mediaQueryList); mediaQueryList.addEventListener('change', listener); // add cleanup listener this.removeListener = () => this.removeEventListener('change', listener); } }

The first obvious difference between the component and service is the removeListener. While our service had the query set as read-only, the component can change the value of the query in runtime causing the creation of the new match media listener. We want to avoid having two or more listeners running in a race condition, so we are making sure all the previous listeners have been cleaned up.

Our component would be used to control the template in a similar way to how service does, but now all the magic happens in the template:

<>Copy
@Component({ selector: 'foo-bar', template: ` <use-media query="(min-width: 768px)"> I am visible only on desktop </use-media> <use-media query="(max-width: 767px)"> I am visible only on mobile </use-media> ` }) class FooBarComponent { }

Of course, for better readability and reusability we can extract two media queries (min-width: 768px) and (max-width: 767px) to constants and use them across our application. Although, this example exposes clear intent, we still have two extra use-media DOM elements, whose sole purpose is to control the visibility. Additionally, since we use content projection, the inner content will always be processed before ngIf takes over.

<>Copy
@Component({ selector: 'child-component' }) class ChildComponent implements OnInit { @Input() value: string; ngOnInit() { console.log(`From child: ${value}`); } } @Component({ selector: 'foo-bar', template: ` <use-media query="(min-width: 768px)"> <child-component value="Desktop"></child-component> </use-media> <use-media query="(max-width: 767px)"> <child-component value="Mobile"></child-component> </use-media> ` }) class FooBarComponent implements OnInit { ngOnInit() { console.log(`From FooBar`); } }

Despite expectation and final visibility, in both mobile and desktop the console log would be the same:

<>Copy
From child: Desktop From child: Mobile From FooBar

Media directive
Link to this section

A structural directive built on top of the same logic solves both of those issues:

  • No extra DOM element required
  • The content is only rendered if the positive value is received
<>Copy
@Directive({ selector: '[media]' }) class MediaDirective { @Input() set media(query: string) { // cleanup old listener if (this.removeListener) { this.removeListener(); } this.setListener(value); } private hasView = false; private removeListener: () => void; constructor( private readonly viewContainer: ViewContainerRef, private readonly template: TemplateRef<any> ) { } private setListener(query: string) { const mediaQueryList = window.matchMedia(query); const listener = event => { // create view if true and not created already if (event.matches && !this.hasView) { this.hasView = true; this.viewContainer.createEmbeddedView(this.template); } // destroy view if false and created if (!event.matches && this.hasView) { this.hasView = false; this.viewContainer.clear(); } }; // run once and then add listener listener(mediaQueryList); mediaQueryList.addEventListener('change', listener); // add cleanup listener this.removeListener = () => this.removeEventListener('change', listener); } }

The only major difference between the media directive and component is in the listener callback. While the component was setting public property isMatch, the directive is creating or clearing the view based on the value.

<>Copy
@Component({ selector: 'foo-bar', template: ` <div *media="'(min-width: 768px)'">I am visible only on desktop</div> <div *media="'(max-width: 767px)'">I am visible only on mobile</div> ` }) class FooBarComponent { }

Final words
Link to this section

This post showed you why responsive DOM is important and how to achieve it in Angular using matchMedia and services, components and directives. To avoid being too cluttered, the examples are missing details on listener cleanup. Each time you create a listener in service, component or directive you need to make sure to also remove that listener once the instance has been destroyed (best done using OnDestroy lifecycle hook). Additionally, some browsers still support only old MediaQueryList methods so certain polyfills should be set in place.

If you want to see the full code with all checks and polyfills in place or you would rather just use the npm package instead of reimplementing it yourself, you can find a working solution in my ng-helpers library.

If you are using the Angular Material Design component library, you can use BreakpointObserver which does similar thing to MediaService.

Share

About the author

author_image

Miro is a software developer interested in the frontend of things, speaker, co-founder of Angular Austria, co-organizer of Angular Vienna and ViennaJS meetups, and open source enthusiast.

author_image

About the author

Miroslav Jonas

Miro is a software developer interested in the frontend of things, speaker, co-founder of Angular Austria, co-organizer of Angular Vienna and ViennaJS meetups, and open source enthusiast.

About the author

author_image

Miro is a software developer interested in the frontend of things, speaker, co-founder of Angular Austria, co-organizer of Angular Vienna and ViennaJS meetups, and open source enthusiast.

Looking for a JS job?
Job logo
Full Stack AngularJS / Laravel Developer

The Kotter Group

Worldwide
Remote
$85k - $90k
Job logo
Full Stack Developer (Laravel/Angular)

1st Phorm

Worldwide
Remote
$85k - $120k
More jobs
NxAngularCli
NxAngularCli
NxAngularCli

Featured articles

JavaScriptpost
27 September 202130 min read
An in-depth perspective on webpack's bundling process

Webpack is a very powerful and interesting tool that can be considered a fundamental component in many of today's technologies that web developers use to build their applications. However, many people would argue it is quite a challenge to work with it, mostly due to its complexity.

JavaScriptpost
27 September 202130 min read
An in-depth perspective on webpack's bundling process

Webpack is a very powerful and interesting tool that can be considered a fundamental component in many of today's technologies that web developers use to build their applications. However, many people would argue it is quite a challenge to work with it, mostly due to its complexity.

Read more
JavaScriptpostAn in-depth perspective on webpack's bundling process

27 September 2021

30 min read

Webpack is a very powerful and interesting tool that can be considered a fundamental component in many of today's technologies that web developers use to build their applications. However, many people would argue it is quite a challenge to work with it, mostly due to its complexity.

Read more