# useIntersectionObserver

A hook that tracks whether an element is in the viewport.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/use-intersection-observer.json
```

[Registry JSON](https://mwui.vercel.app/r/use-intersection-observer.json)

## Preview

```tsx
import { Badge } from "@/components/ui/badge";

import { useIntersectionObserver } from "@/hooks/use-intersection-observer";

export function Preview() {
  const watched = useIntersectionObserver<HTMLDivElement>({ threshold: 0.9 });
  const revealed = useIntersectionObserver<HTMLDivElement>({ threshold: 0.5, once: true });

  return (
    <div className="flex w-full max-w-sm flex-col gap-3 text-sm">
      <div className="flex items-center justify-between">
        <span className="text-muted-foreground">Card in view</span>
        <Badge variant={watched.isIntersecting ? "default" : "outline"}>
          {watched.isIntersecting ? "visible" : "hidden"}
        </Badge>
      </div>

      <div className="h-48 overflow-y-auto rounded-lg border border-border p-3">
        <div className="flex h-32 items-center justify-center text-xs text-muted-foreground">
          Scroll down ↓
        </div>

        <div
          ref={watched.ref}
          className="flex h-24 items-center justify-center rounded-md bg-muted text-xs"
        >
          Watched card
        </div>

        <div className="flex h-32 items-center justify-center text-xs text-muted-foreground">
          Keep scrolling ↓
        </div>

        <div
          ref={revealed.ref}
          data-visible={revealed.isIntersecting}
          className="flex h-24 items-center justify-center rounded-md border border-dashed border-border text-xs opacity-0 transition-opacity duration-500 data-[visible=true]:opacity-100"
        >
          Revealed once
        </div>

        <div className="h-16" />
      </div>
    </div>
  );
}
```


## Source

### hooks/use-intersection-observer.ts

```ts
"use client";

import * as React from "react";

type UseIntersectionObserverOptions = {
  root?: Element | Document | null;
  rootMargin?: string;
  threshold?: number | number[];
  once?: boolean;
  disabled?: boolean;
  initialIsIntersecting?: boolean;
  onChange?: (entry: IntersectionObserverEntry) => void;
};

type UseIntersectionObserverResult<T extends Element> = {
  ref: (node: T | null) => void;
  entry: IntersectionObserverEntry | null;
  isIntersecting: boolean;
};

function useIntersectionObserver<T extends Element = HTMLElement>({
  root = null,
  rootMargin = "0px",
  threshold = 0,
  once = false,
  disabled = false,
  initialIsIntersecting = false,
  onChange,
}: UseIntersectionObserverOptions = {}): UseIntersectionObserverResult<T> {
  const [element, setElement] = React.useState<T | null>(null);
  const [entry, setEntry] = React.useState<IntersectionObserverEntry | null>(null);
  const [isIntersecting, setIsIntersecting] = React.useState(initialIsIntersecting);

  const onChangeRef = React.useRef(onChange);

  React.useEffect(() => {
    onChangeRef.current = onChange;
  });

  const thresholdKey = Array.isArray(threshold) ? threshold.join(",") : String(threshold);
  const frozen = once && isIntersecting;

  React.useEffect(() => {
    if (!element || disabled || frozen) return undefined;
    if (typeof IntersectionObserver === "undefined") return undefined;

    const observer = new IntersectionObserver(
      (entries) => {
        for (const nextEntry of entries) {
          setEntry(nextEntry);
          setIsIntersecting(nextEntry.isIntersecting);
          onChangeRef.current?.(nextEntry);
        }
      },
      {
        root,
        rootMargin,
        threshold: thresholdKey.includes(",")
          ? thresholdKey.split(",").map(Number)
          : Number(thresholdKey),
      },
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [element, root, rootMargin, thresholdKey, disabled, frozen]);

  return { ref: setElement, entry, isIntersecting };
}

export {
  useIntersectionObserver,
  type UseIntersectionObserverOptions,
  type UseIntersectionObserverResult,
};
```



## Usage

The primitive behind infinite scroll, lazy media, reveal-on-scroll animation, and scroll-spy
navigation — all of which are the same question asked with different options.

```tsx
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";

function LoadMore({ onReach }: { onReach: () => void }) {
  const { ref } = useIntersectionObserver({
    rootMargin: "400px",
    onChange: (entry) => entry.isIntersecting && onReach(),
  });

  return <div ref={ref} aria-hidden className="h-px" />;
}
```

`ref` is a callback ref, so the element can mount, unmount, and remount — inside a conditional, a
list, or a virtualized viewport — and the observer follows it. A `RefObject` cannot do that.

## Reveal once

`once` freezes the result after the first intersection and disconnects the observer, which is what
entrance animations want: they should not replay when the user scrolls back up.

```tsx
const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.3, once: true });

<section ref={ref} data-visible={isIntersecting} className="opacity-0 data-[visible=true]:animate-in">
```

## Options

| Option                  | Description                                                                |
| ----------------------- | -------------------------------------------------------------------------- |
| `root`                  | Scroll container to measure against. Defaults to the viewport.              |
| `rootMargin`            | Grows or shrinks the root box. Positive values fire early — use for prefetch. |
| `threshold`             | Fraction of the element that must be visible. Accepts an array.             |
| `once`                  | Stop observing after the first intersection.                                |
| `disabled`              | Pause observation without unmounting.                                       |
| `initialIsIntersecting` | Value returned before the observer reports. Set `true` to avoid a flash.     |
| `onChange`              | Called with each entry. Read from a ref, so it need not be memoized.        |

## Returns

`entry` is the raw `IntersectionObserverEntry` — reach for `intersectionRatio` or `boundingClientRect`
when a boolean is not enough, such as a scroll-spy that highlights the most visible heading.

