Accurate carousel impressions need two conditions at once — the carousel is in the viewport, and the card is visible within the carousel's scrolling track — so use one observer on the carousel with the viewport as root, one on the cards with the track as root, and count an impression only when both hold for a minimum dwell time.

Problem / Scenario Context

A retailer's merchandising team pays attention to "impressions" for products shown in homepage carousels, and rewards categories whose products get more of them. The current tracker observes every card against the viewport. Because the carousel's track clips off-screen cards, those are correctly excluded — but the tracker counts all cards visible in the track the moment the page loads, even when the carousel is two screens down and nobody has scrolled to it (the tracker was only checking the track). A second version fixed that and then counted every card a user swiped past in a fast fling, inflating numbers for products in the middle of each carousel.

A defensible impression needs visibility in both frames and a little time. The Dynamic Visibility Tracking topic describes impression rules in general; tracking ad visibility for analytics compliance covers the ad-industry version.

Mechanics Explanation

A card inside a horizontally scrolling carousel is visible to the user only if:

  1. The carousel is on screen — its bounding box intersects the viewport.
  2. The card is on screen within the carousel — it intersects the track's visible area.

With the implicit root, the observer already clips the card by the track before intersecting with the viewport, so (1) and (2) are combined — correctly — into one ratio. The first version's bug came from observing with the track as root, which answers only (2). The combined approach works, but has a practical drawback: to pre-load images one slide ahead you want a track-rooted observer with a margin, while for impressions you want no margin at all, so most implementations end up with two concerns in one callback.

The cleaner design separates them: a gate observer (viewport root) knows whether the carousel is visible; a card observer (track root, no margin) knows which cards are visible within the track. An impression timer runs for a card only while both are true, and fires after the dwell threshold — commonly 50% of the card for at least one continuous second.

Two Observers, One ImpressionFour boxes. A gate observer with the viewport as root reports whether the carousel is on screen. A card observer with the track as root reports which cards are at least half visible within the track. A dwell timer runs for each card only while both conditions are true. After one continuous second the impression is recorded once and the card is unobserved.Gate observercarousel in viewport?Card observercard ≥ 50% in thetrack?Dwell timerboth true for 1 scontinuousRecord onceunobserve the card

Comparison Table: Impression Definitions

Definition Over-counts Under-counts Suitable for
Card rendered in DOM every card, always nothing
Card intersects track (track root) carousels off-screen pre-loading, not impressions
Card intersects viewport (implicit root) fast swipes, flings rough reach
Both, ≥ 50% visible fast swipes better
Both, ≥ 50% for ≥ 1 s continuous little very fast readers merchandising impressions

Minimal Reproducible Example

TypeScript
const track = document.querySelector<HTMLElement>('.carousel-track')!;
new IntersectionObserver((entries) => {
  for (const e of entries) if (e.intersectionRatio >= 0.5) recordImpression(e.target);
}, { root: track, threshold: 0.5 }).observe(...track.querySelectorAll('.card'));

declare function recordImpression(el: Element): void;

Load the page and do not scroll: the first four cards of a carousel far below the fold are recorded as impressions immediately.

Production-Safe Solution

TypeScript
interface CarouselImpressionOptions {
  carousel: HTMLElement;
  track: HTMLElement;
  minRatio?: number;
  dwellMs?: number;
  onImpression: (productId: string) => void;
}

export function trackCarouselImpressions(o: CarouselImpressionOptions): () => void {
  const { carousel, track, minRatio = 0.5, dwellMs = 1000, onImpression } = o;
  let carouselVisible = false;
  const cardVisible = new Set<Element>();
  const timers = new Map<Element, number>();
  const recorded = new Set<string>();

  const sync = (card: Element): void => {
    const should = carouselVisible && cardVisible.has(card) && document.visibilityState === 'visible';
    if (should && !timers.has(card)) {
      timers.set(card, window.setTimeout(() => {
        timers.delete(card);
        const id = (card as HTMLElement).dataset.productId!;
        if (recorded.has(id)) return;
        recorded.add(id);
        onImpression(id);
        cards.unobserve(card);
        cardVisible.delete(card);
      }, dwellMs));
    } else if (!should && timers.has(card)) {
      clearTimeout(timers.get(card));                 // continuity broken: start over next time
      timers.delete(card);
    }
  };
  const syncAll = () => cardVisible.forEach(sync);

  const gate = new IntersectionObserver(([e]) => {
    carouselVisible = e.intersectionRatio >= 0.3;       // most of the carousel on screen
    syncAll();
  }, { threshold: [0, 0.3] });

  const cards = new IntersectionObserver((entries) => {
    for (const e of entries) {
      e.intersectionRatio >= minRatio ? cardVisible.add(e.target) : cardVisible.delete(e.target);
      sync(e.target);
    }
  }, { root: track, threshold: [0, minRatio] });

  const onVis = () => syncAll();
  document.addEventListener('visibilitychange', onVis);

  gate.observe(carousel);
  track.querySelectorAll('.card').forEach((c) => cards.observe(c));

  return () => {
    gate.disconnect(); cards.disconnect();
    timers.forEach((t) => clearTimeout(t));
    document.removeEventListener('visibilitychange', onVis);
  };
}

Each card's timer runs only while all three conditions hold — carousel on screen, card half-visible in the track, tab visible — and is cancelled the moment any fails, so a fling past a card never completes its second. Recorded products are unobserved, and a set of product ids prevents double counting when the same product appears in two carousels or twice in one (looping carousels often clone slides).

Impressions should be batched rather than sent one by one; hand onImpression a queue drained at idle time, as in deferring non-urgent observer work with requestIdleCallback.

A Fling Versus a PauseA timeline for two cards. Card three passes through the track during a fling and is at least half visible for about three hundred milliseconds; its timer starts and is cancelled, so no impression. Card six is where the user stops swiping; it stays visible, its timer completes after one second, and one impression is recorded.Swipe through a carousel, then stopcard 3 visiblefling pastcard 3 timercancelledcard 6 visibleuser stopped herecard 6 timer1 s dwellrecorded0ms250ms500ms750ms1000ms1250ms1500ms1750ms2000ms

Looping and Auto-Advancing Carousels

Two common carousel features need specific handling:

Looping carousels clone slides at both ends so swiping wraps seamlessly. Clones have the same data-product-id as the originals; the recorded set prevents double counting, but clones should also be observed so an impression can be earned on either copy.

Which Carousel Views Earn an ImpressionA grid of carousel situations and whether each earns an impression under the dwell rule. A carousel below the fold earns none. A card flung past in a fast swipe earns none. A card the user stops on for a second earns one. A cloned slide in a looping carousel earns one only if the product has not been counted. An auto-advanced card earns one only if the user interacted recently.Impression?WhyCarousel below the foldnogate observer falseCard flung pastnodwell timer cancelledCard the user stops onyes, onceboth true for 1 sCloned slide in a looponly if not yet countedproduct id setAuto-advanced cardpolicy decisionrequire recent interaction

Auto-advancing carousels move without user action. Whether an automatically displayed card should count is a business decision; many teams count it only if the user has interacted with the carousel or the page recently, since an auto-advancing carousel on an unattended page would otherwise generate impressions indefinitely. Pausing auto-advance when the carousel leaves the viewport — which the gate observer already knows — is good for users and battery too; see pausing background video to save battery for the same principle applied to media.

Verification Steps

  • Load the page without scrolling and confirm no impressions for carousels below the fold.
  • Fling through a carousel and confirm no impressions for cards passed in under a second.
  • Stop on a card for over a second and confirm exactly one impression.
  • Switch tabs mid-dwell and confirm the timer is cancelled.
  • Swipe through a looping carousel twice and confirm each product is counted once.

Common Mistakes to Avoid

  • Track root alone. Counts cards in carousels nobody has scrolled to.
  • No dwell time. Counts every card flicked past.
  • Resetting dwell timers without continuity. Pausing and resuming a timer accumulates non-continuous time; restart it instead.
  • Ignoring clones. Looping carousels double count or never count cloned positions.

FAQ

Can one observer with the implicit root do all of this?

It can compute combined visibility, because the track clips the cards before the viewport check. Two observers are clearer when you also need track-relative behaviour, such as pre-loading with a margin, and they make the carousel-visible condition explicit.

Why require the carousel to be 30% visible?

A carousel barely peeking over the bottom edge makes its cards technically visible but not really noticeable. A modest threshold on the carousel as a whole filters those edge cases. Tune it to your layout.

What is a standard impression definition?

For display advertising, industry guidelines commonly use 50% of pixels visible for one continuous second. For merchandising impressions there is no universal standard, but adopting the same rule makes numbers comparable with ad metrics.

How do I avoid sending a request per impression?

Queue impressions and send them in batches at idle time, plus a final flush when the page is hidden. That also reduces the chance of losing the last impressions when the user leaves.

Should impressions count when the tab is hidden?

No. Timers should be cancelled on visibilitychange to hidden, because nobody can see the carousel.

Does the dwell timer need to survive a brief overlap drop?

Under a strict continuous-time definition, any drop below the threshold resets it. If your definition allows brief interruptions, accumulate time instead of restarting, but document the choice so the numbers are interpreted correctly.


↑ Back to Dynamic Visibility Tracking