npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@joycostudio/metri

v3.1.1

Published

A library that measures for you efficiently

Readme

Metri

npm core gzip

Metri helps you build interfaces that respond to element size, visibility, and scroll position. Measure elements, track viewport dimensions, and drive scroll animations in JavaScript or React.

It caches element bounds and batches fresh measurements so your components can reuse geometry without repeatedly reading layout.

What you can do with Metri

| Need | Use Metri to… | | --- | --- | | Element size and position | Read width, height, and coordinates, and receive updates when elements resize. | | Visibility | Know when an element enters or leaves the viewport or a scroll container. | | Scroll animations | Track an element's progress through the viewport without rerendering on every scroll. | | Viewport dimensions | Read window size and CSS viewport heights (svh, lvh, and dvh). | | Custom scrolling | Connect your scroll source, including Lenis. | | React | Use hooks for bounds, visibility, viewport size, and scroll progress, with server rendering support. |

Get started · Recipes · Core API · React API

Install

pnpm add @joycostudio/metri

Quick start

Create one Metri instance for your app, initialize it after mounting, and track the elements you need. This example keeps a CSS custom property in sync with an element’s height:

import { Metri } from '@joycostudio/metri'

const metri = new Metri()
metri.init()

const el = document.querySelector<HTMLElement>('#hero')!

// Receive the initial bounds and later size changes.
const tracking = metri.track(el, (bounds) => {
  el.style.setProperty('--measured-height', `${bounds.height}px`)
})

tracking.bounds // Latest bounds; undefined until the first measurement.

// When the element is no longer needed:
tracking.dispose()
metri.dispose() // On application teardown.

Quick start (React)

Wrap your app in MetriProvider, then use hooks in your components. The provider handles initialization and cleanup for you.

import { MetriProvider, useBounds, useViewport } from '@joycostudio/metri/react'
import { useRef } from 'react'

function App() {
  return (
    <MetriProvider>
      <Hero />
    </MetriProvider>
  )
}

function Hero() {
  const ref = useRef<HTMLDivElement>(null)
  const { bounds } = useBounds(ref)
  const { viewport } = useViewport()

  return (
    <div ref={ref}>
      {bounds?.width.toFixed(0)} × {bounds?.height.toFixed(0)}
      {' / viewport '}
      {viewport?.width ?? 0} × {viewport?.height ?? 0}
    </div>
  )
}

Choosing an API

| You want to… | JavaScript | React | | --- | --- | --- | | Measure an element once | await metri.measure(element) | useMetri() to access metri.measure(element) | | Keep element bounds up to date | metri.track(element, onBounds) | useBounds(ref) | | React to visibility changes | metri.observe(element, config, onVisibility) | useObserve(ref, config) | | Follow scroll progress | metri.progress(element, options) | useProgress(ref, options) | | Track viewport dimensions | metri.trackViewport(onViewport) | useViewport() |

Tracked bounds and viewport dimensions start as undefined until their first measurement arrives. Keep the measured element mounted while waiting. When a layout change moves an element without resizing it, request fresh bounds with tracking.refresh() or the React hook's refresh().

Recipes

Scroll-driven reveal

function Reveal({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null)
  const [p, setP] = useState(0)

  useProgress(ref, { onProgress: setP })

  return (
    <div ref={ref} style={{ opacity: p, transform: `translateY(${(1 - p) * 40}px)` }}>
      {children}
    </div>
  )
}

CSS-only visibility animations

<DataVisibleSlot config={{ rootMargin: '-10%' }}>
  <h1 className="data-[visible=true]:animate-fade-in">Hello</h1>
</DataVisibleSlot>

Lenis as the scroll source

import Lenis from 'lenis'
import type { ScrollGetter, ScrollListener } from '@joycostudio/metri/react'

let lenis: Lenis | null = null

export const scrollGetter: ScrollGetter = () => ({
  scrollY: lenis?.actualScroll ?? window.scrollY,
  scrollX: window.scrollX,
})
export const scrollListener: ScrollListener = (callback) => {
  const scroller = new Lenis({ autoRaf: true })
  lenis = scroller
  const unsubscribe = scroller.on('scroll', () => callback(scrollGetter()))
  return () => {
    unsubscribe()
    scroller.destroy()
    lenis = null
  }
}

This adapter owns one vertical, window-scrolling Lenis instance. Both measurements and scroll notifications use native offsets. actualScroll reads the browser's scroll getter; it is not a cached Lenis value and may force style/layout if dirty. lenis.scroll is the maintained animation value and can lag native scrolling.

<MetriProvider scrollGetter={scrollGetter} scrollListener={scrollListener}>
  {children}
</MetriProvider>

Deferred scroll source (Lenis mounted later)

When enabling Lenis after Metri has initialized, reuse the getter and listener from the adapter above:

metri.setScrollHandlers(scrollGetter, scrollListener)

Metri unsubscribes the previous source before attaching the new one. If other libraries also use Metri, check the library integration constraints before replacing the scroll source. This adapter creates Lenis on attachment and destroys it when the source is replaced or Metri is disposed. An owned MetriProvider handles disposal on unmount; a bridge provider leaves it to the instance's owner.

Bridge across portals / multiple trees

const metri = useMemo(() => new Metri(), [])
useLayoutEffect(() => {
  metri.init()
  return () => metri.dispose()
}, [metri])

return (
  <>
    <MetriProvider metri={metri}>{appTree}</MetriProvider>
    {createPortal(<MetriProvider metri={metri}>{overlayTree}</MetriProvider>, document.body)}
  </>
)

Both trees share the cache, the ResizeObserver, and the observer pool.

Coordinate spaces

Bounds live in document-space internally — getBoundingClientRect output plus the current scroll offset.

documentBounds.top    = rect.top    + scrollY
viewportBounds.top    = documentBounds.top - scrollY

| Method | Space | Triggers re-render on scroll? | | ------------------------------------------------- | ---------------- | --------------------------------------------------- | | tracking.bounds | configured space | Cache-only; viewport conversion uses current scroll | | await metri.measure(elm, { space: 'viewport' }) | viewport | Fresh scheduled measurement | | useBounds(ref) | document | No | | useBounds(ref, { space: 'viewport' }) | viewport | Yes — every scroll event |

Prefer document-space plus useScrollCallback for scroll-driven work — you avoid the React reconciliation cost on every scroll tick.

For elements whose document position stays fixed, scrolling only requires subtracting the cached scroll offset. Sticky/fixed positioning, transforms, nested scrolling, and other position changes can require an explicit refresh.

Core API

Metri

import { Metri } from '@joycostudio/metri'

const metri = new Metri({ autoUpdate: true }) // Options are optional.
metri.init()

init() attaches the window resize listener, ResizeObserver, and scroll listener. Call it once on mount; pair it with dispose() on unmount.

Options

type MetriOptions = {
  shared?: boolean // Advanced library integration option; default: true.
  autoUpdate?: boolean // On-demand requestAnimationFrame driver (default: true)
  scrollGetter?: ScrollGetter // Read the current scroll state
  scrollListener?: ScrollListener // Subscribe to scroll updates
}

type ScrollState = { scrollX: number; scrollY: number }
type ScrollGetter = () => ScrollState
type ScrollListener = (cb: (state: ScrollState) => void) => () => void

Defaults use window.scrollX/Y and a passive scroll listener on window. For the advanced shared option, see Integrating Metri into a library.

Measurements and tracking

| API | Returns | Behavior | | --------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- | | measure(el, options?) | Promise<Bounds> | Queue a fresh measurement. Never returns an old cache entry. | | measure(elements, options?) | Promise<Bounds[]> | Queue a batch; preserve input order and duplicates. | | measureSync(el, options?) | Bounds | Immediately reads geometry and may force layout. Populates cache without notifying. | | measureSync(elements, options?) | Bounds[] | Immediate, deduplicated reads; preserves input order. May force layout. | | track(el, listener?, options?) | Tracking | Receive initial bounds and size updates without measuring synchronously. | | trackViewport(listener?) | ViewportTracking | Subscribe to the initial viewport snapshot and subsequent changes without measuring synchronously. | | progress(el, options?) | Progress | Track cached scroll progress; defaults to the lvh viewport. | | update() | void | Synchronously flush queued work; idle calls do nothing. May force layout. | | windowSize | Bounds \| undefined | Cache-only viewport dimensions; ready after the initial queued update. | | viewport | Viewport \| undefined | Cache-only window dimensions and CSS viewport heights; ready after the initial measurement. |

All measurements and tracking default to document space. Pass { space: 'viewport' } to subtract page scroll. Returned snapshots are shared: treat them as immutable.

The requested space is independent of how Metri stores a measurement. Each element is cached in its stable space, the one where page scroll does not move it, and every read converts from there using the current scroll:

| Element | Stored in | Read in document space | Read in viewport space | | --- | --- | --- | --- | | In-flow (static, relative, absolute, sticky) | document | as stored | minus current scroll | | Laid out against the viewport by position: fixed (itself or an ancestor) | viewport | plus current scroll | as stored |

Either way, cached snapshots stay correct while the page scrolls, without a new measurement. Document-space bounds of a fixed element are its position in the document now, so they change with scroll even though no listener fires. A transform, filter, perspective, contain, container-type, or matching will-change ancestor makes position: fixed scroll with the page, so Metri stores it in document space. isViewportAnchored(element) exposes the same check.

const viewport = await metri.measure(el, { space: 'viewport' })
const [aBounds, bBounds] = await metri.measure([a, b])
const tracking = metri.track(el, onBounds, { space: 'viewport' })

measure() does not register an observer or acquire tracking ownership. Five concurrent requests for three unique elements cause three geometry reads in one batch. Caches update before any listeners or promises are delivered. Work requested during delivery waits for another batch, even in the same frame.

measureSync() updates those same caches, but never creates a subscription, emits a refresh event, or consumes a pending async request. Silent cache writes cannot suppress initial or explicitly requested notifications. Use it only when immediate geometry is necessary, such as a useLayoutEffect placement correction; it can force synchronous style/layout and block paint.

useLayoutEffect(() => {
  const bounds = metri.measureSync(ref.current!) // WARNING: may force layout.
  setPlacement(choosePlacement(bounds))
}, [metri])

Tracking handles

const tracking = metri.track(el, (bounds) => {
  el.style.setProperty('--measured-height', `${bounds.height}px`)
})

tracking.bounds // Latest cache-only snapshot; undefined until ready.
await tracking.refresh() // Fresh measurement, then listener delivery, even if unchanged.
tracking.dispose() // Idempotent; release this registration and cancel its refreshes.

Initial reads happen directly inside ResizeObserver, before subscribers can write. No requestAnimationFrame → setTimeout mount workaround is needed. Rendered nonzero elements receive initial delivery without a later resize. Initially hidden, detached, zero-by-zero, or non-replaced inline elements may remain pending until the browser reports them. If zero bounds are delivered, they are accepted. Explicit measure() or tracking.refresh() can measure such elements without waiting for the observer.

Call init() to enable observer delivery. Starting a new tracking lifetime discards any untracked cached snapshot, which may predate the mount. Already-tracked elements share pending work and observer ownership. A new listener on a ready element receives its cached snapshot in a microtask; registration never invokes it synchronously. The last handle's disposal cancels pending observer work and evicts its cache. Standalone requests remain independent and are still fulfilled.

tracking.refresh() broadcasts to every active listener for that element. Ordinary updates skip unchanged bounds. Comparison uses the last delivered snapshot, so a prior measureSync() cache update cannot swallow a change notification.

Viewport tracking handles

const tracking = metri.trackViewport((viewport) => {
  console.log(viewport.width, viewport.height, viewport.svh, viewport.lvh, viewport.dvh)
})

tracking.viewport // Latest cached snapshot; undefined before readiness or after disposal.
await tracking.refresh() // Queue a fresh read and notify all viewport tracking listeners.
tracking.dispose() // Cancel pending delivery and this handle's refresh requests.

Registration never reads layout or invokes the listener synchronously. If the viewport is ready, the new listener receives the latest cached snapshot in a microtask; otherwise it waits for the initial measurement from init(). Later measurements notify when any viewport field changes. useViewport() uses this same subscription API.

refresh() shares the existing window measurement queue and CSS probes. It broadcasts to all active viewport tracking listeners even when unchanged, then resolves with the snapshot measured in that batch. With autoUpdate: false, call update() to flush queued work; initial probe observer delivery can also fulfill a pending refresh. Disposing the handle or calling dispose() cancels its pending refreshes with AbortError. Disposing this handle stops its subscription; metri.dispose() invalidates all handles belonging to the instance.

The viewportResize event remains available for low-level change notifications. It does not replay the cache for late subscribers or emit for an unchanged forced refresh. Use trackViewport() when each consumer needs an initial callback.

Scheduling and cancellation

const metri = new Metri({ autoUpdate: false })
metri.init()

// In your existing ticker's read phase:
metri.update()

With autoUpdate: true, work schedules one on-demand animation frame that calls the same update(). Manual update() cancels a redundant scheduled frame. No perpetual idle loop or throttle is used. In manual mode, scheduled requests wait until your ticker calls update().

Initial ResizeObserver reads run in both modes, independently of update(). This retains the browser's post-layout opportunity. Automatic scheduling and external tickers have identical queue, cache, and delivery behavior. Every batch finishes all reads before delivering listeners. Promise continuations run later as microtasks, so do not rely on await to run before the next callback in an external ticker.

Neither rAF scheduling nor observer timing guarantees zero forced layout. React commits and other observers can dirty layout. The read-before-notify guarantee covers each batch, not the entire frame. See caching and scheduling.

const controller = new AbortController()
const request = metri.measure(el, { signal: controller.signal })
controller.abort() // Rejects this request; other consumers are unaffected.
await request.catch((error) => {
  if (error.name !== 'AbortError') throw error
})

A disposed handle rejects outstanding refresh() promises with AbortError. metri.dispose() rejects the instance's outstanding requests and invalidates its tracking handles. Handle expected cancellation when awaiting work that can outlive a component.

Scroll

| Method / Property | Description | | ------------------------------------- | ----------------------------------------------------------------------------------------- | | scroll | Current scroll state ({ scrollX, scrollY }). | | setScrollHandlers(getter, listener) | Swap the scroll source at runtime. Useful when Lenis or similar mounts after first paint. |

Each measurement batch calls scrollGetter once before reading geometry, so a delayed scroll event cannot mix old offsets with fresh rects. Custom getters must return offsets matching the rendered DOM, and listeners must publish offsets from that same coordinate source. scroll is the cached snapshot from the most recent measurement or scroll notification; reading it never samples the DOM. Cache-only bounds reads and idle update() calls do not sample scroll. Native scroll reads may force style/layout, including when an adapter reads them in a scroll listener.

Scroll progress

metri.progress() owns the element's bounds subscription, listens to the configured scroll source and viewport measurements, and calculates scroll progress. It shares geometry reads with other consumers of the same element.

const progress = metri.progress(element, {
  mode: 'lvh', // Default; also accepts 'svh' and 'dvh'
  onProgress: (value) => {
    element.style.opacity = String(value)
  },
})

progress.current // Cached 0–1 value; zero until both measurements are available
progress.dispose() // Release progress listeners and bounds ownership

Registration does not read layout or invoke the callback synchronously. If tracked bounds and the selected viewport height are already cached, current is calculated immediately; otherwise it starts at zero until both measurements arrive. The initial callback remains asynchronous even when the value is ready at registration. Once ready, callbacks run on scroll events and changes to bounds or the selected height; an element and viewport update in the same measurement batch notify once for the same inputs. Scroll calculations only read cached geometry. current is updated before the callback runs and retains its last value after disposal.

Progress is clamp((height - elementViewportY) / (height + elementHeight), 0, 1) using the selected CSS height. Unsupported units leave progress at zero without invoking the callback.

mode is fixed for each vanilla handle; dispose and create another handle to switch it. dispose() is idempotent, and metri.dispose() disposes all progress handles. Other consumers retain their own bounds subscriptions. Visibility observation is separate: use metri.observe() when needed. Position-only changes such as transforms, sticky positioning, and nested scrolling have the same explicit-refresh requirements as bounds tracking.

Intersection

observe() now returns an owned handle, not the native IntersectionObserver. Replace observer.unobserve(element) or observer.disconnect() with observation.dispose(). Releasing a handle cannot stop another consumer's observation. A late consumer receives cached visibility asynchronously when available; an optional listener is scoped to that exact registration and configuration.

const observation = metri.observe(element, { threshold: 0.5 }, ({ visible }) => {
  console.log(visible)
})
observation.dispose()

| Method | Returns | Description | | ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ | | observe(elm, config?, listener?) | Observation | Own one visibility registration. Native observers are pooled by root identity and configuration. | | unobserve(elm) | void | Release this instance's registrations for the element. | | disposeIntersectionObserver(config) | void | Release this instance's registrations for this configuration. |

type IntersectionConfig = {
  root?: Element | Document | null
  rootMargin?: string
  threshold?: number | number[]
}

Events

type MetriEventMap = {
  refresh: { target: Element | Window; rect: Bounds; metri: Metri }
  observe: { target: Element; visible: boolean; entry: IntersectionObserverEntry; metri: Metri }
  scroll: { scrollX: number; scrollY: number }
  viewportResize: Viewport
}

metri.on('refresh', ({ target, rect }) => {
  /* ... */
})
metri.on('observe', ({ target, visible }) => {
  /* ... */
})
metri.on('scroll', ({ scrollY }) => {
  /* ... */
})
metri.off('refresh', listener)

Window resize and subsequent ResizeObserver notifications share the scheduled queue. Window resize also remeasures ready tracked elements that moved without changing size; pending initial elements remain with the observer. Global refresh event rects are in viewport space; per-element listeners use their configured space. measureSync() never emits events.

Standalone cache entries are invalidated on window resize. Position-only changes from other causes still need an explicit tracking.refresh() or fresh measure() request.

Cleanup

metri.dispose() // Stop this instance's subscriptions and cancel its pending requests

React API

import {
  MetriProvider,
  useMetri,
  useBounds,
  useQueryBounds,
  useScreen,
  useViewport,
  useProgress,
  useObserve,
  useTrack,
  useScrollCallback,
  DataVisibleSlot,
} from '@joycostudio/metri/react'

Hooks use the nearest provider when present. Without a provider, each hook creates a Metri instance and handles its initialization and cleanup automatically. Use a provider to configure Metri for your app, such as setting a custom scroll source. Bounds hooks render cached snapshots without synchronous geometry reads.

MetriProvider

Two modes — the provider either owns a fresh Metri instance (created + disposed for you) or bridges an existing instance across trees.

// Self-managed: provider creates and disposes on unmount
<MetriProvider autoUpdate={true}>
  {children}
</MetriProvider>

// Self-managed with custom scroll source (e.g. Lenis)
<MetriProvider scrollGetter={scrollGetter} scrollListener={scrollListener}>
  {children}
</MetriProvider>

// Bridge: reuse an instance (e.g. across portal roots)
const metri = new Metri()
metri.init()

<MetriProvider metri={metri}>
  {children}
</MetriProvider>

In bridge mode the provider never calls init() or dispose() — lifecycle is yours.

useMetri

Returns { metri: Metri } from the nearest provider, or an automatically managed instance when no provider is mounted. The fallback is created without browser work during render and initialized after mounting, including under React StrictMode.

const { metri } = useMetri()

useBounds

Reactive bounds for a single element. Registers with ResizeObserver on mount and unregisters on unmount. After each component commit, switches tracking if the ref points to a different element; unchanged targets retain their subscription. Ref mutations outside a React commit do not trigger an update by themselves.

const ref = useRef<HTMLDivElement>(null)
const { bounds, getBounds, refresh } = useBounds(ref, { space: 'document' })

Bounds hooks defer by default. bounds and the cache-only getBounds() stay undefined until initial delivery. refresh() queues a fresh measurement and returns a promise, rejecting on disposal. The same defaults apply to useTrack() and useQueryBounds().

The hook automatically measures the element when ResizeObserver delivers its initial notification, then updates the cache and React state. Calling getBounds() while waiting simply returns undefined; it does not trigger or schedule a measurement. Use await refresh() to explicitly request fresh bounds, including for hidden or zero-size elements that have not received an initial notification.

| Return | Type | Description | | ------------- | ------------------------------------ | --------------------------------------------------------------- | | bounds | Bounds \| undefined | Current bounds snapshot. undefined before first measure. | | getBounds() | () => Bounds \| undefined | Read the latest cache without measuring or scheduling work. | | refresh() | () => Promise<Bounds \| undefined> | Queue a fresh measurement; undefined when no target is tracked. |

space: 'viewport' subscribes to scroll events and updates React state with converted bounds. Default is 'document', which avoids scroll-driven bounds updates while the element's document position stays fixed. For position: fixed elements, prefer 'viewport': their viewport bounds are the stable ones.

useQueryBounds

Same as useBounds, but resolves the target with document.querySelector after mount and whenever the selector changes. Changing selectors disposes the previous subscription and defers the new target's initial bounds; an unmatched selector clears the bounds. DOM mutations alone do not rerun the selector query.

const { bounds } = useQueryBounds<HTMLElement>('[data-hero]')

useScreen (deprecated)

Use useViewport() instead: replace screen / getScreen() with viewport / getViewport(). The new hook returns undefined until measured; use viewport?.width ?? 0 and viewport?.height ?? 0 if you need the previous zero fallback.

Cached viewport dimensions. screen and getScreen() return { width: 0, height: 0 } until the viewport cache is ready; afterward they expose only { width, height }. getScreen() never reads layout. React state updates when Metri delivers viewport measurements, including its scheduled resize updates.

const { screen, getScreen } = useScreen()
// screen and getScreen(): { width, height }

useViewport

Reactive window dimensions and CSS viewport heights, shared with the vanilla JS API:

const { viewport, getViewport } = useViewport()

viewport?.width // window.innerWidth
viewport?.height // window.innerHeight
viewport?.svh // pixel height of 100svh: small viewport, with browser UI expanded
viewport?.lvh // pixel height of 100lvh: large viewport, with browser UI retracted
viewport?.dvh // pixel height of 100dvh: dynamic viewport

getViewport() // Latest cached snapshot, without reading layout
import { Metri } from '@joycostudio/metri'

const metri = new Metri()
metri.init()

metri.viewport // Same snapshot as useViewport(); initially undefined
const tracking = metri.trackViewport((viewport) => {
  console.log(viewport.width, viewport.lvh)
})

// On teardown:
tracking.dispose()
metri.dispose()

Viewport is a readonly snapshot of { width, height, svh, lvh, dvh }. All values are pixels, and each unit field represents 100 CSS units. The snapshot is undefined before its first measurement and during SSR. If the browser does not support a unit, that field remains undefined; Metri does not approximate it using innerHeight.

Metri measures CSS viewport heights using invisible, isolated elements. Their resize signals update the viewport snapshot, including changes that do not trigger a window resize. Initial observer delivery works with autoUpdate: false; subsequent queued updates follow the configured ticker. All reads finish before subscribers are notified. VIEWPORT_RESIZE delivers the initial snapshot and later changes to any of its five fields; unchanged measurements keep the same object and do not notify.

svh and lvh provide stable reference heights during ordinary toolbar transitions; they still update on actual viewport resizing, such as device rotation. dvh follows the browser's dynamic CSS height. Browser timing and viewport-unit behavior apply; these values are not measurements of visualViewport or guaranteed keyboard-visible space. Existing windowSize and useScreen() behavior is unchanged.

useObserve

Reactive visibility via IntersectionObserver.

const ref = useRef<HTMLDivElement>(null)
const { visible, observer } = useObserve(ref, { threshold: 0.5 })

Components sharing the same root and config share one native observer. The returned observer is an owned Observation handle with dispose(), not a raw IntersectionObserver. Configurations and committed ref-target changes retarget ownership; equivalent inline configs do not resubscribe.

useProgress

A React wrapper around metri.progress(). Core owns all measurement, subscription, and calculation logic. The hook connects a React ref, keeps onProgress current, and disposes its handle on unmount.

const progress = useProgress(ref, {
  mode: 'lvh', // Default; also accepts 'svh' and 'dvh'
  onProgress: (value) => {},
})

// Read in an animation callback or effect; does not trigger React renders.
progress.current

The returned ref-like object has a stable identity and a readonly current number, initially zero (also during SSR). Changing mode, the provider's Metri instance, or the ref target on a React commit replaces the core subscription. A mode change reuses ready shared bounds and sets current to the selected mode’s value during the hook’s effect, before subsequent consumer effects run, without remeasuring. Its callback remains asynchronous. Updating only the callback does not resubscribe. A null target releases the subscription and resets current to zero. Scrolling does not cause this hook to rerender; use onProgress with state if rendering should follow progress.

Use useBounds(ref) or useObserve(ref) alongside it when you also need reactive geometry or visibility. Bounds measurements are shared.

useTrack (deprecated)

Compatibility composition of useBounds + useObserve + useProgress. Its return shape is unchanged. Prefer useProgress for progress alone, adding the other hooks only when needed.

const { bounds, observe, scrollProgress } = useTrack(ref, {
  threshold: 0,
  mode: 'lvh', // Default; also accepts 'svh' and 'dvh'
  onProgress: (p) => setProgress(p), // 0 when entering from below, 1 when exited through top
})

scrollProgress is a ref — read scrollProgress.current when you need it outside the callback. It starts at zero. Once bounds and the selected viewport height are available, onProgress runs for provider scroll events and when bounds, the selected height, or mode change. If the selected CSS unit is unsupported, progress remains zero and no progress callback runs until a supported mode is selected.

ViewportMode is 'svh' | 'lvh' | 'dvh'. Progress is clamp((height - elementViewportY) / (height + elementHeight), 0, 1) using the selected height. A stable lvh/svh reference prevents toolbar-only height changes from shifting progress, but actual changes to the tracked element's size or position can still affect it. mode only affects progress; intersection observation continues to use root, rootMargin, and threshold.

useScrollCallback

Subscribe to scroll without re-rendering. Callback ref is always up to date.

useScrollCallback(({ scrollY }) => {
  // runs on every scroll event — not throttled
})

DataVisibleSlot

A <Slot> wrapper that forwards data-visible={true|false} to its child based on IntersectionObserver. It uses the visibility hook's React state to update the attribute.

<DataVisibleSlot config={{ threshold: 0.25 }}>
  <section className="opacity-0 data-[visible=true]:opacity-100 transition">{/* CSS reacts to data-visible */}</section>
</DataVisibleSlot>

Package exports

| Import path | Contents | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | @joycostudio/metri | Core: Metri, EVENTS, VERSION, and all types (Bounds, Viewport, ViewportMode, Progress, ProgressOptions, ScrollState, ScrollGetter, ScrollListener, MetriOptions, IntersectionConfig). | | @joycostudio/metri/react | React: MetriProvider, useMetri, useBounds, useQueryBounds, useScreen, useViewport, useObserve, useProgress, useTrack, useScrollCallback, DataVisibleSlot. Also exports Viewport, ViewportMode, Progress, ProgressOptions, and UseProgressConfig types. |

Migration from 2.x

  • Rename initialize() to init() and disposeAll() to dispose().
  • Replace core get(el) with await measure(el) for fresh document-space measurements, or tracking.bounds for cached snapshots. To preserve the old getBounds(el) viewport coordinates, pass { space: 'viewport' } to measure() or track().
  • Replace synchronous track(el) results with a listener or the handle's initially optional bounds.
  • Replace metri.refresh(el) with tracking.refresh(); replace full refresh with requests on the relevant handles or measure(elements).
  • Replace metri.dispose(el) with tracking.dispose().
  • Replace throttleMs with automatic frame scheduling or autoUpdate: false plus an external ticker.
  • React bounds hooks defer by default; no deferInitial or cachedOnly option is needed.
  • React's getBounds() still exists, but only reads the cache. Keep the measured element mounted while its bounds are undefined. Await refresh() when fresh bounds are needed, and handle AbortError if the component unmounts or retargets before delivery. Its resolved value does not imply React has committed the corresponding state update.
  • windowSize can be undefined until the initial viewport measurement. useScreen() falls back to zero dimensions, and getScreen() now returns only { width, height }, not a full Bounds object.
  • useScreen() is deprecated in favor of useViewport(), which also exposes CSS viewport heights. Handle its initial undefined snapshot instead of assuming zero dimensions.
  • useTrack() is deprecated: use useProgress() for scroll progress, alongside useBounds() / useObserve() when needed. Vanilla consumers can use metri.progress().
  • useTrack() now defaults to mode: 'lvh', using the large CSS viewport height instead of window.innerHeight for progress. Choose 'svh' or 'dvh' explicitly when needed. 'dvh' follows the CSS dynamic viewport; it is not an alias for innerHeight.
  • The debug option, debugSnapshot(), debugReset(), and their DebugOption / DebugSnapshot types have been removed. Use browser profiling tools to inspect measurement costs.
  • For before-paint placement, explicitly call measureSync() in a layout effect; it can force synchronous style/layout.

Integrating Metri into a library

Let the host application's bundler resolve Metri. This allows the library and the application to ship one copy of the core while keeping separate clients that share an engine.

Declare a dependency and preserve its imports

Add Metri to your library's regular dependencies, so installing the library also installs Metri even when the host does not declare it:

pnpm add @joycostudio/metri

Use a version range that includes shared-runtime support. Align it with the host's supported range where possible; overlapping ranges allow package-manager deduplication but do not guarantee it. Align prerelease versions explicitly when using beta releases. A peer dependency describes a host compatibility requirement; a regular dependency is the appropriate choice here for a library that must work independently. See npm's dependency documentation.

Keep Metri and its subpaths external in the library build. For example, with tsup:

import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm'],
  dts: true,
  external: [/^@joycostudio\/metri(?:\/|$)/],
})

The published JavaScript should retain import { Metri } from '@joycostudio/metri' instead of embedding Metri's implementation. The host bundler then resolves that import alongside the application's own import. Apply externalization to every published library entry that uses Metri; this is a library-build setting, not a request to exclude Metri from the final browser application. With esbuild directly, external: ['@joycostudio/metri'] also covers subpaths; see external packages.

Your library can still create its own new Metri(), call init() when mounted, and call dispose() when destroyed. No host-provided instance is required. To share an engine, clients must use compatible runtime protocols and matching settings as described under automatic sharing across libraries, including identical custom scroll function references.

Ensure the host resolves one package copy

Check the installed dependency graph with pnpm why @joycostudio/metri or npm ls @joycostudio/metri, align compatible versions, and inspect the final application bundle for duplicate copies. The same version installed at different paths can still resolve as separate modules.

The library's dependency provides Metri when the application has no explicit installation. Deduplication cannot make incompatible versions interchangeable or remove Metri code already embedded in a library bundle.

| What resolves in the application | Code shipped | Runtime behavior | | --- | --- | --- | | Library and host resolve the same Metri entry | One core copy | Compatible clients share one engine. | | Separate copies with compatible protocols and settings | Multiple core copies may ship | The registry still shares one engine. | | Separate copies with incompatible protocols or settings | Multiple core copies may ship | Separate engines; diagnostics report conflicts outside production. |

Runtime sharing saves duplicate listeners, caches, observers, and viewport probes; bundle deduplication saves downloaded code. Both matter. If all consumers import only @joycostudio/metri, Metri adds no React adapter, React, or Radix code to the browser bundle. If the React entry is also used from the same package build, it shares the core chunk.

Automatic sharing across libraries

Keep using new Metri() and the existing lifecycle. Compatible clients in the same browser window automatically share one measurement engine:

// Library A — the application does not need to supply an instance.
const a = new Metri()
a.init()

// Library B or the host application, even from another compatible package copy.
const b = new Metri()
b.init()

const trackingA = a.track(element)
const trackingB = b.track(element)
// One observation and shared cached bounds, with independent handles.

a.dispose() // B keeps working, including the shared viewport probes.
b.dispose() // Last client: release the engine's listeners, observers, probes, and cache.

Construction and cache getters do not acquire a runtime or access browser APIs. Browser operations acquire an engine lazily; init() attaches its resize/scroll listeners and probes. Calling init() again is idempotent. After disposal a client can initialize again, while old handles remain invalid. Server-side viewport registrations remain local until browser initialization; nothing is stored in a server-global registry.

The registry lives at window[Symbol.for('@joycostudio/metri/runtime')]. It finds or creates a compatible runtime; that runtime issues a lease for each client and owns its client count, scroll-source restrictions, and final teardown. Releasing a lease is idempotent, including across package copies. Sharing requires the same internal runtime protocol, autoUpdate setting, and scroll getter/listener function references (or the native defaults). The protocol is a versioned client/engine contract, independent of package semver; maintainers must increment it when required engine methods or semantics change. Different package versions can share only when this contract matches. Older Metri releases without the registry do not participate.

Conflicting configurations or protocols use separate engines and emit a development diagnostic once per conflict. A single build serves development and production; diagnostics are suppressed when process.env.NODE_ENV is production. In browsers without a process global or a bundler replacement, diagnostics remain enabled. Compatibility checks and runtime errors are always available. Set shared: false for intentional isolation; this creates an independent engine without a diagnostic. Custom-scroll clients must pass the same function references to share. setScrollHandlers() can replace a source only while its client has exclusive engine ownership; attempting to change other attached clients' source throws. Reapplying the same functions is a no-op. A host joining later adopts a compatible engine; live instances are not migrated or merged.

const isolated = new Metri({ shared: false })
// React also supports <MetriProvider shared={false}>.

Each client keeps its own event subscriptions. Scroll, viewport, and window-refresh events reach all subscribed clients. Element refreshes reach clients tracking that element or owning a pending measurement. Intersection events reach clients observing that element with the matching configuration. Event payloads containing metri identify the receiving client. off() and dispose() never remove another client's subscriptions; a throwing listener does not prevent other clients from receiving the update.

Architecture

| Layer | Responsibility | | -------------------- | ----------------------------------------------------------------------------------------------------------- | | Metri client | Owns its tracking handles, requests, and event callbacks. Compatible clients share a browser engine. | | Shared engine | Owns the cache, ResizeObserver, intersection observer pool, viewport probes, and scroll listener. | | Target records | One map stores document-space bounds, subscriptions, initial delivery, and last-notified bounds per target. | | ResizeObserver | One shared observer. Initial reads run directly in its callback; later updates join the frame queue. | | Observer pool | IntersectionObserver instances keyed by config hash. Reused across every observe() call. | | Scroll subsystem | Pluggable scrollGetter / scrollListener. Emits scroll events and feeds the viewport-space converter. |

Pending measurements stay in a separate work queue, so updates visit only queued targets. The engine cancels requests by client ownership and shares subscription setup and cleanup between bounds and viewport handles. Published JavaScript is unminified and preserves member names; the host application's bundler handles minification.

Contributing

Repository setup, tests, and release instructions are for working on Metri itself.

Demo

The interactive playground lives in demo/. From the repository root:

pnpm install
pnpm dev             # build Metri, then watch the library and run Next.js on :3000
pnpm build:demo      # production build of the library and demo
pnpm typecheck:demo  # check the demo's types

The demo requires Node.js 20.9+ and the pnpm version pinned in package.json. Open http://localhost:3000 to explore bounds, visibility, viewport dimensions, and scroll progress. See demo/README.md for examples and app-specific commands, or measurement comparison for profiling experiments.

Testing

Use Node.js 22 or newer:

pnpm test            # build and run the unit/React tests
pnpm exec playwright install chromium firefox webkit
pnpm test:browser    # build and check real CSS geometry in all three engines

The unit suite covers core measurement and mounted React lifecycles using Node's built-in test runner, jsdom, controlled browser signals and geometry, and mocked timers. The browser suite compares viewport probes with ordinary CSS-sized elements, checks progress after viewport resizing, and verifies isolation from page styles and scroll overflow. These checks run in CI; they do not emulate physical iOS browser bars expanding or collapsing.

Releasing

Beta and stable releases use Changesets and GitHub Actions: merge a PR containing changesets, then review and merge the generated Version Packages PR to publish. See the release guide for starting a beta cycle, shipping another beta, and graduating to stable. Agents must follow AGENTS.md; manual versioning, publishing, and release tags are not part of this process.