Skip to content
Corsair UI
Browse the docs

HooksNo. 106 of 109

useInView

Whether an element is in the viewport, from IntersectionObserver, with once, margin and amount options.

Installation

pnpm dlx shadcn@latest add @corsair-ui/use-in-view

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Examples

Live

Scroll the box: the buoy reports when most of it is visible.

Out of view

API

PropTypeDefault
once

Stop after the first entry.

booleanfalse
rootMargin

Grows or shrinks the viewport used for the check.

string"0px"
amount

Fraction of the element that has to be visible.

number0
onChange

Called on every change; the first call says whether it started in view.

(inView, entry) => void—
returns

A callback ref for the element and whether it is in view.

[ref, inView]—

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Works in server components

Source

1 file, installed as source you own
use-in-view.tsView on GitHub
import { useCallback, useEffect, useRef, useState } from "react";

export interface UseInViewOptions {
  /** Stop watching after the first time the element comes into view. */
  once?: boolean;
  /**
   * Grows or shrinks the viewport used for the check, like a CSS margin.
   * `"0px 0px -15% 0px"` waits until the element is 15% above the bottom edge.
   */
  rootMargin?: string;
  /** Fraction of the element that has to be visible, from 0 to 1. */
  amount?: number;
  /** The value before the browser has answered: on the server and on the first render. */
  initial?: boolean;
  /**
   * Called every time the answer changes, with the observer entry. The first
   * call tells you whether the element started out in view.
   */
  onChange?: (inView: boolean, entry?: IntersectionObserverEntry) => void;
}

/**
 * Whether an element is in the viewport, from IntersectionObserver: no scroll
 * listeners, and no work at all while nothing crosses the edge. Put the
 * returned ref on the element.
 *
 * @example
 * const [ref, inView] = useInView({ once: true, amount: 0.3 });
 * return <section ref={ref} data-visible={inView}>…</section>;
 */
export function useInView<T extends Element = Element>({
  once = false,
  rootMargin = "0px",
  amount = 0,
  initial = false,
  onChange,
}: UseInViewOptions = {}) {
  const [node, setNode] = useState<T | null>(null);
  const [inView, setInView] = useState(initial);
  const onChangeRef = useRef(onChange);

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

  useEffect(() => {
    if (!node) return;

    // Browsers without IntersectionObserver get the content, not a blank page.
    if (typeof IntersectionObserver === "undefined") {
      const frame = requestAnimationFrame(() => {
        setInView(true);
        onChangeRef.current?.(true);
      });
      return () => cancelAnimationFrame(frame);
    }

    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[entries.length - 1];
        if (!entry) return;
        setInView(entry.isIntersecting);
        onChangeRef.current?.(entry.isIntersecting, entry);
        if (once && entry.isIntersecting) observer.disconnect();
      },
      { rootMargin, threshold: amount }
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, [node, once, rootMargin, amount]);

  const ref = useCallback((element: T | null) => setNode(element), []);
  return [ref, inView] as const;
}