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
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:
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 colors, aspect ratio, resolution, availability of JavaScript parser etc.
Mobile-first design
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.
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.
.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
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
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:
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:
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:
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
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.
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.
@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
Instead of using service to subscribe to media changes we can listen to them directly in the component.
@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:
@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.
@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:
From child: Desktop
From child: Mobile
From FooBar
Media directive
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
@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.
@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
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.