Detect when elements are visible

Find out when an element scrolls onto or off the screen, as you would with IntersectionObserver on the web. Use it to render heavy content only when needed, or to track which items the user saw. Lynx calls this exposure (see Glossary).

Choose an API

You want toUse
Change what one element shows when it becomes visibleThe lynxExposure directive: a visible() signal per element
Record impressions across the whole page, for example for analyticsThe LynxGlobalExposure service: batched events for tagged elements

Watch one element

Add lynxExposure to an element and read its visible() signal:

src/app/feed-item.ts
import { Component } from '@angular/core';
import { LYNX_ELEMENTS, LynxExposure } from '@blotch/angular-lynx'; 

@Component({
  selector: 'app-feed-item',
  imports: [LYNX_ELEMENTS, LynxExposure],
  template: `
    <view lynxExposure #exp="lynxExposure" style="height: 200px">
      @if (exp.visible()) {
        <image
          src="https://example.com/photo.jpg"
          style="width: 100%; height: 200px"
        />
      } @else {
        <view style="height: 200px; background-color: #e4e4e7" />
      }
    </view>
  `,
})
export class FeedItem {}
  • #exp="lynxExposure" gives the template a reference to the directive.
  • exp.visible() is true while the element is on screen and false otherwise. It starts as false.
  • The element must have a size and not be fully transparent. Inside a <scroll-view> or <list>, it counts as visible when it overlaps the visible part of the scroll area.
  • The directive cleans up after itself when the element is destroyed.

The directive switches content in and out as the user scrolls. To create a component the first time it appears and keep it, combine lynxExposure with @defer.

Track impressions across the page

Give each element you want to track an exposure-id, then read the batches from LynxGlobalExposure:

src/app/home.ts
import { Component, effect, inject } from '@angular/core';
import { LYNX_ELEMENTS, LynxGlobalExposure } from '@blotch/angular-lynx'; 

@Component({
  selector: 'app-home',
  imports: [LYNX_ELEMENTS],
  template: `
    <scroll-view scroll-orientation="vertical" style="height: 100%">
      <view
        exposure-id="promo-banner"
        exposure-scene="home"
        style="height: 300px"
      >
        <text>Promotion</text>
      </view>
      @for (product of products; track product) {
        <view
          [attr.exposure-id]="product"
          exposure-scene="home"
          style="height: 120px"
        >
          <text>{{ product }}</text>
        </view>
      }
    </scroll-view>
  `,
})
export class Home {
  readonly products = ['shoes', 'hats', 'bags', 'scarves'];
  readonly #exposure = inject(LynxGlobalExposure); 

  constructor() {
    effect(() => {
      for (const seen of this.#exposure.exposures()) {
        console.log('Seen:', seen.exposureID, 'in', seen.exposureScene);
      }
    });
  }
}
  • exposure-id marks an element for tracking. Use [attr.exposure-id] to set it from data. exposure-scene is an optional label for grouping, such as the screen name.
  • Lynx checks visibility about 20 times a second and reports changes in batches.
  • exposures() holds the latest batch of elements that became visible. disexposures() holds the latest batch that left the screen.
  • Each signal holds only the latest batch. To count impressions, or to track everything that's on screen, add each batch to your own state in an effect().

Pause and resume tracking

src/app/tracking-toggle.ts
import { Component, inject } from '@angular/core';
import { LYNX_ELEMENTS, LynxGlobalExposure } from '@blotch/angular-lynx';

@Component({
  selector: 'app-tracking-toggle',
  imports: [LYNX_ELEMENTS],
  template: `
    <view (bindtap)="toggle()">
      <text>{{
        exposure.active() ? 'Pause tracking' : 'Resume tracking'
      }}</text>
    </view>
  `,
})
export class TrackingToggle {
  readonly exposure = inject(LynxGlobalExposure);

  toggle(): void {
    if (this.exposure.active()) {
      this.exposure.stopExposure(); 
    } else {
      this.exposure.resumeExposure(); 
    }
  }
}
  • stopExposure() stops Lynx's visibility checks. By default, Lynx first reports every visible element as having left. Pass { sendEvent: false } to skip that.
  • active() is a signal that's false while paused.