Early mobile phones were limited to calls, texts, and simple games. As network technology and display quality advanced, these devices gained the ability to render web content.

Yet, given their small screens and limited input methods, they could only handle pages written in WML, a markup language tailored for the WAP protocol. It wasn't until the iPhone that a phone could genuinely display standard HTML pages.

Once mobile devices started browsing the open web, it quickly became apparent that many existing pages were unusable on compact screens. The primary culprit was the user interface.

Responsive web design

Early websites were designed with desktop monitors in mind. Generous imagery and small click targets worked well for mouse-driven navigation on large displays. On a small screen, however, they were a usability nightmare. Responsive web design emerged as a remedy. With CSS media queries, developers could define different styles for different device characteristics.

Consider this CSS example:

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;
  }
}

In this scenario, header navigation is displayed as a block by default. If the viewport drops below 769px in width—typical of mobile devices—that navigation gets hidden. However, when the device switches to landscape orientation (where width exceeds height), the navigation becomes fixed on the left, occupying 20% of the viewport's width.

Media queries open the door to targeting a wide range of parameters: dimensions (with both min and max prefixes), orientation, presentation type (screen, print, reader), color capabilities, aspect ratio, resolution, and more.

Mobile-first design

As desktop browsers grew more powerful, static sites evolved into complex web applications that behaved more like native desktop software. Patching existing designs to fit smaller screens was no longer enough; these apps needed a fundamental redesign for mobile.

This shift gave birth to the mobile-first approach. Rather than starting with a desktop layout and stripping it down for mobile, developers now built with the mobile experience as the baseline, then progressively enhanced for larger screens. This strategy allowed teams to address the constraints of small devices from the outset.

Because of differences in screen real estate and interaction patterns—like minimum touch target sizes and contrast requirements—it's common for mobile and desktop layouts to diverge significantly.

Such dramatic differences usually demand substantial DOM manipulation, either by visually altering elements or hiding them entirely. The snippet below, taken from a well-known Angular training site, illustrates this. On wide screens, the navigation is a horizontal bar with items aligned to the right.

On mobile, however, a completely different set of styles kicks in. The navigation is fixed, sized, and positioned off-screen. The list inside flips to a vertical orientation. When an opened modifier class is added, the entire nav slides in from the left. Notice that some properties, like float and display, had to be explicitly overridden to make this 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;
  }
}

Here's another illustration from a widely used web tutorial site. The page includes two navigation elements: main-nav and secondary-nav. Both contain the same set of 11 links. The layout relies on CSS grid for positioning. This is a clear demonstration of mobile-first thinking. In the default view, all items stay hidden in the main menu, while the secondary nav—activated via a hamburger button—shows every link.

But once the viewport crosses 800px, the behavior shifts. The first five items in the main menu become visible, while the same 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;
  }
}

This approach of splitting menu items across two containers lets the site experiment with different shapes and positions, achieving a richer design than the earlier example, which merely toggled visibility. The trade-off is duplicating menu items in the DOM.

Performance first

As applications grow, keeping invisible DOM elements around just for specific screen sizes can become a liability. Desktop pages typically load full-resolution images, but small screens don't need all those pixels. Similarly, background videos are a popular design choice, but you'd rarely want them on mobile for several reasons:

  • They tend to look poor on small displays.
  • Frequent scrolling on mobile clashes with video playback.
  • They drain the battery and consume bandwidth unnecessarily.

Beyond just resizing and repositioning UI elements, we often want to strip out heavier components entirely. Desktop visitors usually have reliable connections, but mobile users might be on flaky or slow networks. The goal is to deliver a similarly fast experience across all devices.

To accomplish that, we need a way to detect the device or viewport and conditionally inject or remove DOM nodes.

Meet MediaQueryList and matchMedia

Media queries aren't limited to CSS—JavaScript has access to them too. The global Window object offers a matchMedia function that returns a MediaQueryList. This object extends EventTarget, so it can register event listeners. It also introduces two extra 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 basic usage looks 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 real power of MediaQueryList emerges when you attach listeners. Let's expand the previous example:

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);

Listeners fire only upon state changes, so an initial synchronous call is necessary to capture the current match state.

Media Service

Any event listener produces a stream of events. We can expose that as an Observable through a service.
Consumers subscribe to the stream and respond to changes in media state accordingly.

At the heart of the service is a ReplaySubject that receives values from matchMedia. The listener wiring mirrors the vanilla TypeScript example shown 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);
    }
  }
}

This service can now be injected into components to control template visibility. When the media query match flips, the isDesktop property updates and the template re-renders accordingly.

@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);
  }
}

The MediaService finds many uses: fetching different backend resources, adjusting calculations based on layout, or enabling complex business rules. Still, when the goal is purely template manipulation, a dedicated component or directive is often a cleaner fit.

Media component

Instead of subscribing through a service, we can listen for media changes directly inside a 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 most notable distinction from the service is the handling of removeListener. While the service keeps the query static, the component allows runtime changes to the query, prompting the creation of a new match media listener. To prevent multiple concurrent listeners from racing, we clean up previous ones before adding new.

This component controls the template in a fashion similar to the service, but all the logic lives right in the view:

@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 { }

For clarity and reuse, we can extract the queries (min-width: 768px) and (max-width: 767px) into named constants and share them across the app. The example above is explicit, but it introduces two extra use-media DOM elements that exist solely for visibility control. Also, because of content projection, the projected content gets processed before ngIf gets a chance to discard it.

@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`);
  }
}

Regardless of final visibility, the console output on mobile and desktop is identical:

From child: Desktop
From child: Mobile
From FooBar

Media directive

A structural directive built on the same underlying logic addresses both concerns:

  • No additional DOM element is necessary
  • Content is rendered only when the condition is satisfied
@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 core difference between the directive and the component lies in the listener callback. The component sets a public isMatch flag, while the directive conditionally creates or clears a view based on the event 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 article explored why adaptive DOM structures matter and how to implement them in Angular using matchMedia, whether through services, components, or directives. For brevity, the examples omitted listener cleanup details. Any listener you create—whether in a service, component, or directive—must be removed when the instance is destroyed, ideally via the OnDestroy lifecycle hook. Additionally, some browsers still lack support for the modern MediaQueryList API, so polyfills may be necessary.

If you'd like the complete implementation with all safeguards and polyfills, or prefer using an npm package instead of building from scratch, the ng-helpers library has a working solution.

For those already using the Angular Material library, the BreakpointObserver utility offers functionality akin to MediaService.