From 3af443e89be8b2e1fdb56d67d39ba7fb14f1666d Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 29 Jul 2026 13:56:48 +0200 Subject: [PATCH 01/11] feat: add Blume documentation site --- .github/workflows/pkg-pr.yml | 2 +- .github/workflows/test.yml | 14 +- .gitignore | 7 + CONTRIBUTING.md | 24 +- apps/docs/.gitignore | 1 + apps/docs/blume.config.ts | 45 + apps/docs/docs/api/configuration.mdx | 108 + apps/docs/docs/api/core-apis.mdx | 155 + apps/docs/docs/api/meta.ts | 8 + .../docs/guides/intersection-observer-v2.mdx | 53 + apps/docs/docs/guides/meta.ts | 8 + apps/docs/docs/guides/recipes.mdx | 220 + apps/docs/docs/guides/ssr.mdx | 57 + apps/docs/docs/index.mdx | 71 + apps/docs/docs/testing/browser-mode.mdx | 91 + apps/docs/docs/testing/index.mdx | 37 + apps/docs/docs/testing/meta.ts | 8 + .../docs/testing/mocking-intersections.mdx | 123 + apps/docs/evals.yaml | 103 + apps/docs/islands/ObserverDemo.tsx | 405 + apps/docs/islands/RecipeDemo.tsx | 524 + apps/docs/package.json | 20 + apps/docs/sources/github-releases.ts | 57 + .../storybook}/.storybook/main.ts | 0 .../storybook}/.storybook/manager.ts | 0 .../storybook}/.storybook/preview-head.html | 0 .../storybook}/.storybook/preview.ts | 0 {storybook => apps/storybook}/package.json | 4 +- .../storybook}/postcss.config.js | 0 .../storybook}/stories/InView.story.tsx | 0 .../storybook}/stories/Intro.mdx | 2 +- .../storybook}/stories/Recipes.mdx | 2 +- .../storybook}/stories/elements.tsx | 0 .../storybook}/stories/story-utils.ts | 0 .../storybook}/stories/useInView.story.tsx | 0 .../storybook}/stories/useOnInView.story.tsx | 0 {storybook => apps/storybook}/styles.css | 0 .../storybook}/tailwind.config.js | 0 {storybook => apps/storybook}/tsconfig.json | 3 +- package.json | 170 +- .../compat/packed.mts | 11 +- .../fixtures}/react-compat/entry.jsx | 0 .../fixtures}/react-compat/rsbuild.config.mjs | 0 .../react-intersection-observer/package.json | 158 + .../scripts/package-metadata.mjs | 28 + .../src}/InView.tsx | 0 .../src}/__tests__/InView.test.tsx | 0 .../src}/__tests__/browser.test.tsx | 0 .../src}/__tests__/observe.test.ts | 0 .../src}/__tests__/setup.test.ts | 0 .../src}/__tests__/useInView.ssr.test.ts | 0 .../src}/__tests__/useInView.test.tsx | 0 .../src}/__tests__/useOnInView.test.tsx | 0 .../src}/index.tsx | 0 .../src}/observe.ts | 0 .../src}/test-utils.ts | 0 .../src}/useInView.tsx | 0 .../src}/useIntersectionObserverRef.ts | 0 .../src}/useOnInView.tsx | 0 .../react-intersection-observer/tsconfig.json | 2 +- .../tsup.config.ts | 0 .../vitest.config.ts | 0 pnpm-lock.yaml | 16323 +++++++++++++--- pnpm-workspace.yaml | 4 +- storybook/readme.md | 130 - turbo.json | 25 + 66 files changed, 15748 insertions(+), 3255 deletions(-) create mode 100644 apps/docs/.gitignore create mode 100644 apps/docs/blume.config.ts create mode 100644 apps/docs/docs/api/configuration.mdx create mode 100644 apps/docs/docs/api/core-apis.mdx create mode 100644 apps/docs/docs/api/meta.ts create mode 100644 apps/docs/docs/guides/intersection-observer-v2.mdx create mode 100644 apps/docs/docs/guides/meta.ts create mode 100644 apps/docs/docs/guides/recipes.mdx create mode 100644 apps/docs/docs/guides/ssr.mdx create mode 100644 apps/docs/docs/index.mdx create mode 100644 apps/docs/docs/testing/browser-mode.mdx create mode 100644 apps/docs/docs/testing/index.mdx create mode 100644 apps/docs/docs/testing/meta.ts create mode 100644 apps/docs/docs/testing/mocking-intersections.mdx create mode 100644 apps/docs/evals.yaml create mode 100644 apps/docs/islands/ObserverDemo.tsx create mode 100644 apps/docs/islands/RecipeDemo.tsx create mode 100644 apps/docs/package.json create mode 100644 apps/docs/sources/github-releases.ts rename {storybook => apps/storybook}/.storybook/main.ts (100%) rename {storybook => apps/storybook}/.storybook/manager.ts (100%) rename {storybook => apps/storybook}/.storybook/preview-head.html (100%) rename {storybook => apps/storybook}/.storybook/preview.ts (100%) rename {storybook => apps/storybook}/package.json (88%) rename {storybook => apps/storybook}/postcss.config.js (100%) rename {storybook => apps/storybook}/stories/InView.story.tsx (100%) rename {storybook => apps/storybook}/stories/Intro.mdx (66%) rename {storybook => apps/storybook}/stories/Recipes.mdx (63%) rename {storybook => apps/storybook}/stories/elements.tsx (100%) rename {storybook => apps/storybook}/stories/story-utils.ts (100%) rename {storybook => apps/storybook}/stories/useInView.story.tsx (100%) rename {storybook => apps/storybook}/stories/useOnInView.story.tsx (100%) rename {storybook => apps/storybook}/styles.css (100%) rename {storybook => apps/storybook}/tailwind.config.js (100%) rename {storybook => apps/storybook}/tsconfig.json (86%) rename {scripts => packages/react-intersection-observer}/compat/packed.mts (90%) rename {fixtures => packages/react-intersection-observer/fixtures}/react-compat/entry.jsx (100%) rename {fixtures => packages/react-intersection-observer/fixtures}/react-compat/rsbuild.config.mjs (100%) create mode 100644 packages/react-intersection-observer/package.json create mode 100644 packages/react-intersection-observer/scripts/package-metadata.mjs rename {src => packages/react-intersection-observer/src}/InView.tsx (100%) rename {src => packages/react-intersection-observer/src}/__tests__/InView.test.tsx (100%) rename {src => packages/react-intersection-observer/src}/__tests__/browser.test.tsx (100%) rename {src => packages/react-intersection-observer/src}/__tests__/observe.test.ts (100%) rename {src => packages/react-intersection-observer/src}/__tests__/setup.test.ts (100%) rename {src => packages/react-intersection-observer/src}/__tests__/useInView.ssr.test.ts (100%) rename {src => packages/react-intersection-observer/src}/__tests__/useInView.test.tsx (100%) rename {src => packages/react-intersection-observer/src}/__tests__/useOnInView.test.tsx (100%) rename {src => packages/react-intersection-observer/src}/index.tsx (100%) rename {src => packages/react-intersection-observer/src}/observe.ts (100%) rename {src => packages/react-intersection-observer/src}/test-utils.ts (100%) rename {src => packages/react-intersection-observer/src}/useInView.tsx (100%) rename {src => packages/react-intersection-observer/src}/useIntersectionObserverRef.ts (100%) rename {src => packages/react-intersection-observer/src}/useOnInView.tsx (100%) rename tsconfig.json => packages/react-intersection-observer/tsconfig.json (92%) rename tsup.config.ts => packages/react-intersection-observer/tsup.config.ts (100%) rename vitest.config.ts => packages/react-intersection-observer/vitest.config.ts (100%) delete mode 100644 storybook/readme.md create mode 100644 turbo.json diff --git a/.github/workflows/pkg-pr.yml b/.github/workflows/pkg-pr.yml index cfc7f04c..27af6f06 100644 --- a/.github/workflows/pkg-pr.yml +++ b/.github/workflows/pkg-pr.yml @@ -18,4 +18,4 @@ jobs: - name: Build run: pnpm build - name: Publish preview package - run: pnpx pkg-pr-new publish --no-template + run: pnpm --filter react-intersection-observer preview diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 266f657a..70cee4a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,14 +27,14 @@ jobs: - name: Lint run: pnpm biome ci . - name: Test - run: pnpm vitest --coverage + run: pnpm --filter react-intersection-observer vitest --coverage env: CI: true - name: Fallow audit if: github.event_name == 'pull_request' run: pnpm fallow audit --base "origin/${{ github.base_ref }}" - name: Build - run: pnpm build + run: pnpm build:all test_matrix: runs-on: ubuntu-latest @@ -58,18 +58,18 @@ jobs: run: pnpm install --frozen-lockfile - name: Install legacy testing-library if: ${{ startsWith(matrix.react, '17') }} - run: pnpm add -D @testing-library/react@12.1.4 + run: pnpm --filter react-intersection-observer add -D @testing-library/react@12.1.4 - name: Install React types - run: pnpm add -D @types/react@${{ matrix.react }} @types/react-dom@${{ matrix.react }} + run: pnpm --filter react-intersection-observer add -D @types/react@${{ matrix.react }} @types/react-dom@${{ matrix.react }} - name: Install ${{ matrix.react }} - run: pnpm add -D react@${{ matrix.react }} react-dom@${{ matrix.react }} + run: pnpm --filter react-intersection-observer add -D react@${{ matrix.react }} react-dom@${{ matrix.react }} - name: Validate types - run: pnpm tsc + run: pnpm --filter react-intersection-observer typecheck - name: Build package run: pnpm build - name: Test packed package if: ${{ matrix.react != 'latest' }} - run: pnpm run compat:packed -- ${{ matrix.react }} + run: pnpm --filter react-intersection-observer run compat:packed -- ${{ matrix.react }} - name: Run test run: | pnpm exec playwright install diff --git a/.gitignore b/.gitignore index 086701b0..610acff0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .vscode .cache +.turbo +.blume +.npm-cache node_modules reports example @@ -21,3 +24,7 @@ test-utils.js test-utils.d.ts __screenshots__ .fallow/ +.turbo +.blume +.npm-cache +.blume-verify/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 166ae69f..8c590d32 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,13 +3,12 @@ Welcome to `react-intersection-observer`! I'm thrilled that you're interested in contributing. Here are some guidelines to help you get started. -The codebase is written in TypeScript, and split into two packages using PNPM -workspaces: +The codebase is written in TypeScript and uses PNPM workspaces: -- `react-intersection-observer` - The main package, which contains the - `useInView` hook and the `InView` component. -- `storybook` - A Storybook project that is used to develop and test the - `react-intersection-observer` package. +- `packages/react-intersection-observer` - The published package, which contains + the `useInView` hook and the `InView` component. +- `apps/storybook` - The Storybook project used to develop and test the package. +- `apps/docs` - The Blume documentation site. ## Development @@ -20,12 +19,14 @@ the dependencies using [PNPM](https://pnpm.io/): pnpm install ``` -Then you can start the Storybook development server with the `dev` task: +Then you can start the development surfaces with the `dev` task: ```shell pnpm dev ``` +Use `pnpm dev:storybook` or `pnpm dev:docs` to start one surface at a time. + ## Semantic Versioning `react-intersection-observer` follows Semantic Versioning 2.0 as defined at @@ -79,8 +80,15 @@ submitting your pull request. Please ensure that your changes are covered by tests, and that all tests pass before submitting your pull request. -You can run the tests with the `test` task: +You can run the package tests with the `test` task. Component tests run in +Vitest Browser Mode with Playwright; SSR tests run in a separate Node project. ```shell pnpm test ``` + +Build every published and documentation surface with: + +```shell +pnpm build:all +``` diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore new file mode 100644 index 00000000..2c0d364a --- /dev/null +++ b/apps/docs/.gitignore @@ -0,0 +1 @@ +.blume-verify/ diff --git a/apps/docs/blume.config.ts b/apps/docs/blume.config.ts new file mode 100644 index 00000000..bf4c5316 --- /dev/null +++ b/apps/docs/blume.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from "blume"; + +import { githubReleaseChangelogSource } from "./sources/github-releases"; + +export default defineConfig({ + title: "React Intersection Observer", + description: + "A lightweight React implementation of the Intersection Observer API.", + github: { + owner: "thebuilder", + repo: "react-intersection-observer", + dir: "apps/docs", + }, + content: { + sources: [ + { type: "filesystem", root: "docs" }, + { + type: "custom", + source: githubReleaseChangelogSource({ + owner: "thebuilder", + repo: "react-intersection-observer", + }), + }, + ], + }, + navigation: { + tabs: [ + { label: "Docs", path: "/" }, + { label: "Changelog", path: "/changelog", href: "/changelog" }, + ], + }, + theme: { + accent: { + light: "oklch(0.6 0.15 290)", + dark: "oklch(0.76 0.12 290)", + }, + mode: "system", + }, + deployment: { + output: "static", + }, + ai: { + llmsTxt: true, + }, +}); diff --git a/apps/docs/docs/api/configuration.mdx b/apps/docs/docs/api/configuration.mdx new file mode 100644 index 00000000..be82270e --- /dev/null +++ b/apps/docs/docs/api/configuration.mdx @@ -0,0 +1,108 @@ +--- +title: Observer options +description: Decide when observation should trigger, where it should happen, and how long it should run. +--- + +Most components need one of three decisions: **when** an element counts as visible, **where** to observe it, and **what happens after the first notification**. Start there; the full option reference is at the end. + +## Choose when it counts as visible + +Use `threshold` when the amount of visible content matters. A threshold of `0.5` means the target must be at least half visible before `inView` becomes true: + +```tsx +useInView({ threshold: 0.5 }); +``` + +Use `rootMargin` to start earlier or later than the target's actual edge. This is useful for preloading: + +```tsx +useInView({ + rootMargin: "200px 0px", + triggerOnce: true, +}); +``` + +`scrollMargin` changes the clipping rectangles of nested scroll containers. Use it when the target is clipped by scrollers inside the root, rather than to adjust the viewport itself. + +Test real layout, scrolling, and geometry in [Vitest Browser Mode](/testing/browser-mode). Use a deterministic mock to cross a configured threshold without relying on layout; see [Mock intersections](/testing/mocking-intersections). + +## Choose where to observe + +For a custom scroll container, start with these rules: the viewport is the default root; pass the ancestor scroll container as `root`; and ensure that root is an ancestor of the observed target. Keep the root in state so the hook receives it after React assigns the element. Omit `root` (or leave it as `null`) to observe relative to the browser viewport, then give a custom root a bounded scrolling area: + +```tsx +import { useState } from "react"; +import { useInView } from "react-intersection-observer"; + +export function ScrollArea() { + const [root, setRoot] = useState(null); + const { ref, inView } = useInView({ root }); + + return ( +
+
+
+ {inView ? "Visible in this container" : "Waiting"} +
+
+
+ ); +} +``` + +The root must be an ancestor of the target. `scrollMargin` is not a substitute for `rootMargin`: it expands or contracts the clipping rectangles of **nested** scroll containers inside the root. Use it when an inner scroller clips the target, for example `useInView({ root, scrollMargin: "80px 0px" })`. Invalid margin syntax, invalid thresholds, iframes, and custom roots can all change what the browser considers visible. + +## Stop, pause, or choose a callback API + +For lifecycle configuration, decide both when to stop and what result you need: use `triggerOnce` to stop observing after the first accepted `true` transition, or `skip` to temporarily disable observation while preserving the current state. onChange is available on useInView and InView when state and a callback are both needed. Use [`useOnInView`](/api/core-apis#useoninview) when a callback is the only result needed and no hook-owned state update is wanted. + +Use `triggerOnce` for work that only needs the first accepted enter event, such as loading an image or recording a one-time impression. Use `skip` to temporarily stop observing while preserving the current state. + +```tsx +const { ref, inView } = useInView({ + triggerOnce: true, + skip: isSaving, + onChange(nextInView, entry) { + console.log(nextInView, entry.target); + }, +}); +``` + +`onChange` runs alongside the `useInView` or `InView` state update. The callback-only alternative is [`useOnInView`](/api/core-apis#useoninview). + +## Initial, server, and unsupported-client state + +`initialInView` sets the state before an observer reports. `fallbackInView` sets a value only when the client lacks `IntersectionObserver`; a global policy is available through `defaultFallbackInView`. + +Those choices affect server rendering and unsupported browsers, so keep the policy in [SSR and fallbacks](/guides/ssr) rather than scattering it through routine component configuration. + +## Experimental: distinguish visibility from intersection + +Most applications only need `inView`: the target intersects the root. Set `trackVisibility: true` when you instead need Observer v2's `entry.isVisible` field, which can account for an intersecting target being visually compromised. Pair it with a `delay` of at least `100` milliseconds: + +```tsx +useInView({ trackVisibility: true, delay: 100 }); +``` + +This is experimental and has narrower browser support than the original API. Read [Observer v2](/guides/intersection-observer-v2) before using `isVisible` for viewability or occlusion decisions. + +## Option reference + +Observation options work with all three APIs. `onChange`, `initialInView`, and `fallbackInView` are only available with `useInView` and ``. + +| Option | Default | Purpose | +| --- | --- | --- | +| `root` | `null` | The viewport or an ancestor scroll container. | +| `rootMargin` | `"0px"` | Expand or contract the root bounds. | +| `scrollMargin` | `"0px"` | Adjust clipping across nested scroll containers. | +| `threshold` | `0` | A number or array of ratios from `0` to `1`. | +| `triggerOnce` | `false` | Stop observing after the first accepted enter transition. | +| `skip` | `false` | Disable observation while preserving the current state. | +| `onChange` | `undefined` | Run `(inView, entry)` after an accepted transition. | +| `initialInView` | `false` | Set initial state before observer delivery. | +| `fallbackInView` | `undefined` | Set state when the API is unavailable. | +| `trackVisibility` | `false` | **Experimental.** Request `entry.isVisible`, beyond geometric intersection, in browsers supporting Observer v2. | +| `delay` | `undefined` | Minimum delay between v2 visibility notifications. Only use with `trackVisibility`; it must be at least `100`ms. | diff --git a/apps/docs/docs/api/core-apis.mdx b/apps/docs/docs/api/core-apis.mdx new file mode 100644 index 00000000..56e78437 --- /dev/null +++ b/apps/docs/docs/api/core-apis.mdx @@ -0,0 +1,155 @@ +--- +title: Core APIs +description: Choose and use the three React APIs for observing elements. +--- + +`react-intersection-observer` exposes two hooks and one component. They all use the same observer options, but they express visibility in different ways. + +| API | Use it when | What it returns | +| --- | --- | --- | +| `useInView` | Visibility affects rendered output. | A callback `ref`, `inView`, and the latest `entry`. | +| `useOnInView` | You need an effect without a hook-owned state update. | A callback `ref` and your `(inView, entry)` callback. | +| `` | Render props or a wrapper component fit the composition. | Render-prop fields, or a generated wrapper for plain children. | + +When `initialInView` is omitted, all three APIs ignore the observer's initial `false` notification. Supplying `initialInView`—including `false`—defines the initial state and allows the first matching notification through. Later enter and leave transitions report both `true` and `false`. + +## Common configuration choices + +- [Load or reveal once](/api/configuration#stop-pause-or-choose-a-callback-api) with `triggerOnce`. +- [Start work before the target reaches the viewport](/api/configuration#choose-when-it-counts-as-visible) with `rootMargin`. +- [Require a meaningful amount of content to be visible](/api/configuration#choose-when-it-counts-as-visible) with `threshold`. +- [Observe inside a scrolling container](/api/configuration#choose-where-to-observe) with `root`. +- Use [`useOnInView`](#useoninview) for analytics or prefetching that should not create a hook-owned re-render. + +## `useInView` + +Use `useInView` when visibility belongs in React state: + +```tsx +import { useInView } from "react-intersection-observer"; + +export function ArticleSection() { + const { ref, inView, entry } = useInView({ + threshold: 0.5, + triggerOnce: true, + }); + + return ( +
+

{inView ? "Reading now" : "Not visible yet"}

+ + {entry ? `Ratio: ${entry.intersectionRatio}` : "Waiting for the observer"} + +
+ ); +} +``` + +The hook supports both object and tuple destructuring: + +```tsx +const { ref, inView, entry } = useInView(); +const [ref, inView, entry] = useInView(); +``` + +`entry` is `undefined` until an observer notification has been accepted. It is the latest [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry), so use it for details such as `intersectionRatio`, `boundingClientRect`, or `target`. + +### `onChange` + +`useInView` can call `onChange` alongside its state update: + +```tsx +const { ref, inView } = useInView({ + onChange(nextInView, entry) { + console.log(entry.target, nextInView); + }, +}); +``` + +Use `useOnInView` when the callback is the primary result and you do not need the state update. + +## `useOnInView` + +`useOnInView` returns a ref callback and does not update component state when visibility changes. It is a good fit for analytics, logging, prefetching, or other effects that should not cause a render: + +```tsx +import { useOnInView } from "react-intersection-observer"; + +export function TrackedCard({ id }: { id: string }) { + const ref = useOnInView( + (inView, entry) => { + if (inView) { + analytics.track("card_visible", { id, target: entry.target }); + } + }, + { threshold: 0.5, triggerOnce: true }, + ); + + return
Card {id}
; +} +``` + +The callback receives `(inView, entry)`. `useOnInView` accepts observer options that affect observation, including `root`, `rootMargin`, `scrollMargin`, `threshold`, `triggerOnce`, `skip`, `trackVisibility`, and `delay`. It does not accept `onChange`, `initialInView`, or `fallbackInView`. + +## `` + +Use render props when the component should receive the ref and visibility state directly: + +```tsx +import { InView } from "react-intersection-observer"; + +export function RevealCard() { + return ( + + {({ ref, inView, entry }) => ( +
+

Card

+

{entry ? "The observer has reported" : "Waiting"}

+
+ )} +
+ ); +} +``` + +Render-prop fields are `{ ref, inView, entry }`. The `ref` must be attached to the element you want to observe. + +### Plain children + +Plain children are always rendered. In this form `` creates a wrapper element, forwards additional HTML props to that wrapper, and observes it: + +```tsx + +

Always-rendered content

+
+``` + +Use `as` to keep the generated wrapper semantic. Plain-child mode does not support ref forwarding; use render props or `useInView` when you need direct control of the observed element or a custom component ref. + +## Low-level `observe` + +The three APIs above are the React surface. When you already own a DOM element outside React rendering, use the low-level `observe` function and always keep its cleanup function: + +```ts +import { observe } from "react-intersection-observer"; + +const stop = observe( + element, + (inView, entry) => { + console.log(inView, entry.intersectionRatio); + }, + { threshold: 0.5 }, +); + +// Call this when the element is no longer relevant. +stop(); +``` + +## Shared behavior + +- `triggerOnce` stops observing after the first accepted `true` transition. +- `skip` disables observation while preserving the current state. +- When an observed node is removed and later replaced, the hook resets to `initialInView` unless `triggerOnce` or `skip` prevents that reset. +- The latest `entry` may be `undefined` before the first accepted notification or after an observed node is reset. + +See [Configuration](/api/configuration) for every option and [Testing](/testing) for deterministic observer transitions. diff --git a/apps/docs/docs/api/meta.ts b/apps/docs/docs/api/meta.ts new file mode 100644 index 00000000..70e32ec6 --- /dev/null +++ b/apps/docs/docs/api/meta.ts @@ -0,0 +1,8 @@ +import { defineMeta } from "blume"; + +export default defineMeta({ + title: "API", + icon: "code-2", + order: 2, + pages: ["core-apis", "configuration"], +}); diff --git a/apps/docs/docs/guides/intersection-observer-v2.mdx b/apps/docs/docs/guides/intersection-observer-v2.mdx new file mode 100644 index 00000000..6cc5b445 --- /dev/null +++ b/apps/docs/docs/guides/intersection-observer-v2.mdx @@ -0,0 +1,53 @@ +--- +title: Observer v2 +description: Use trackVisibility only when intersection alone is insufficient and you accept narrower browser support. +sidebar: + label: Observer v2 + badge: Experimental +--- + +Most applications should use the standard `inView` signal. Intersection Observer v2 adds `trackVisibility` and `entry.isVisible` for specialised viewability cases where an intersecting element may be covered or visually compromised. + +## Enable visibility tracking + +Set `trackVisibility: true` and a `delay` of at least `100` milliseconds: + +```tsx +import { useOnInView } from "react-intersection-observer"; + +declare global { + interface IntersectionObserverEntry { + isVisible?: boolean; + } +} + +export function Viewability({ onSeen }: { onSeen: () => void }) { + const ref = useOnInView( + (inView, entry) => { + if (inView && entry.isVisible === true) onSeen(); + }, + { + trackVisibility: true, + delay: 100, + threshold: 0.5, + triggerOnce: true, + }, + ); + + return
Measured content
; +} +``` + +`isVisible` is not yet part of TypeScript's standard `IntersectionObserverEntry` declaration, so add the augmentation once in application types before reading it. The option is available on all three public APIs. With `useOnInView`, inspect `entry.isVisible` inside the callback; with ``, inspect it from the render-prop `entry` or `onChange` callback. + +## Support and fallback behavior + +Support for v2 is narrower than support for the original Intersection Observer API. When the browser exposes Intersection Observer but does not provide `isVisible`, this package falls back to v1 behavior and sets `entry.isVisible` to the calculated intersection state. Treat that value as an approximation in unsupported browsers. + +If the entire `IntersectionObserver` API is unavailable, use `fallbackInView` or `defaultFallbackInView` as described in [Configuration](/api/configuration). These are separate concerns: v2 fallback covers a missing visibility field, while `fallbackInView` covers a missing observer API. + +## Test it deliberately + +Use a real browser when verifying geometry, clipping, or actual occlusion. The package mock can exercise the v1 fallback branch, but it cannot simulate an intersecting element being covered. For deterministic callback and threshold tests, assert the application behavior you need rather than actual occlusion. + +Keep the minimum delay explicit in shared configuration so a future browser or package update does not silently make the observer invalid. The package mock can exercise your callback branch, but it cannot prove real occlusion. diff --git a/apps/docs/docs/guides/meta.ts b/apps/docs/docs/guides/meta.ts new file mode 100644 index 00000000..826e969c --- /dev/null +++ b/apps/docs/docs/guides/meta.ts @@ -0,0 +1,8 @@ +import { defineMeta } from "blume"; + +export default defineMeta({ + title: "Guides", + icon: "book-open", + order: 3, + pages: ["recipes", "ssr", "intersection-observer-v2"], +}); diff --git a/apps/docs/docs/guides/recipes.mdx b/apps/docs/docs/guides/recipes.mdx new file mode 100644 index 00000000..0dfb293c --- /dev/null +++ b/apps/docs/docs/guides/recipes.mdx @@ -0,0 +1,220 @@ +--- +title: Recipes +description: Complete visibility-aware React patterns built with react-intersection-observer. +--- + +Each recipe starts with the problem it solves, shows a complete component, and calls out the boundary that is easiest to miss. For the API details behind the examples, see [Core APIs](/api/core-apis) and [Configuration](/api/configuration). + +## Lazy image loading + +:::tip[Prefer native lazy loading] +Add the `loading="lazy"` attribute to ordinary image elements. It lets the browser decide when to defer image loading without any observer code. +::: + +Use the observer when you need an explicit preload margin, a custom placeholder, or a component-level loading transition. + +```tsx +import { useInView } from "react-intersection-observer"; + +type LazyImageProps = { + src: string; + alt: string; + width: number; + height: number; +}; + +export function LazyImage({ src, alt, width, height }: LazyImageProps) { + const { ref, inView } = useInView({ + rootMargin: "200px 0px", + triggerOnce: true, + }); + + return ( +
+ {inView ? ( + {alt} + ) : ( + + ); +} +``` + + + +Reserve the image's space with dimensions or an aspect ratio. Otherwise the image can shift the page after it loads, and the observer may repeatedly encounter a moving target. + +## Scroll-triggered animation + +Keep the animation in CSS and use `triggerOnce` when the reveal should happen only once: + +```tsx +import { useEffect, useState } from "react"; +import { useInView } from "react-intersection-observer"; + +export function Reveal({ children }: { children: React.ReactNode }) { + const [enhanced, setEnhanced] = useState(false); + const { ref, inView } = useInView({ + fallbackInView: true, + threshold: 0.2, + triggerOnce: true, + }); + + useEffect(() => { + setEnhanced("IntersectionObserver" in window); + }, []); + + return ( +
+ {children} +
+ ); +} +``` + +```css +.reveal { + opacity: 1; + transform: none; + transition: opacity 250ms ease, transform 250ms ease; +} + +.reveal-pending { + opacity: 0; + transform: translateY(1rem); +} + +@media (prefers-reduced-motion: reduce) { + .reveal { + opacity: 1; + transform: none; + transition: none; + } +} +``` + + + +The baseline remains visible until the browser can enhance it, and `fallbackInView: true` keeps it visible when observation is unavailable. Do not make the animation the only way to discover important content; reduced-motion users see it immediately. + +## Track an impression + +Use `useOnInView` when the result is an effect and a state update is unnecessary: + +```tsx +import { useOnInView } from "react-intersection-observer"; + +type ImpressionProps = { + id: string; + record: (id: string) => void; +}; + +export function Impression({ id, record }: ImpressionProps) { + const ref = useOnInView( + (inView) => { + if (inView) record(id); + }, + { threshold: 0.5, triggerOnce: true }, + ); + + return
Tracked content
; +} +``` + + + +The package ignores the initial `false` notification, so this callback records the first accepted enter transition. Keep the identifier stable and decide whether `triggerOnce` matches your analytics definition of an impression. + +## Infinite scrolling + +Observe a sentinel at the end of a list, guard requests while loading, and keep an explicit fallback action for keyboard and assistive-technology users: + +```tsx +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useInView } from "react-intersection-observer"; + +type Page = { items: T[]; hasNextPage: boolean }; + +export function InfiniteList({ + loadPage, + renderItem, +}: { + loadPage: (skip: number) => Promise>; + renderItem: (item: T) => ReactNode; +}) { + const [items, setItems] = useState([]); + const [hasNextPage, setHasNextPage] = useState(true); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadMore = useCallback(async () => { + if (loading || !hasNextPage) return; + + setLoading(true); + setError(null); + try { + const page = await loadPage(items.length); + setItems((current) => [...current, ...page.items]); + setHasNextPage(page.hasNextPage); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error("Unable to load")); + } finally { + setLoading(false); + } + }, [hasNextPage, items.length, loadPage, loading]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + void loadPage(0) + .then((page) => { + if (cancelled) return; + setItems(page.items); + setHasNextPage(page.hasNextPage); + }) + .catch((cause) => { + if (!cancelled) { + setError(cause instanceof Error ? cause : new Error("Unable to load")); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [loadPage]); + + const { ref } = useInView({ + rootMargin: "400px 0px", + skip: loading || !hasNextPage || error !== null, + onChange: (inView) => { + if (inView) void loadMore(); + }, + }); + + return ( + <> +
    {items.map((item, index) =>
  • {renderItem(item)}
  • )}
+ {error ? : null} + {hasNextPage ? ( + <> +