Toolbox

Metri

See on GitHub

Metri library by JOYCO Studio.

npm core + react gzip

A DOM measurement engine for interactive apps. Caches element bounds, pools intersection observers, and tracks resize + scroll from a single source — with first-class React bindings.

Features

FeatureDescription
Cached boundsgetBoundingClientRect fires once per element; results stay fresh via a shared ResizeObserver.
Document-space coordsBounds are stored with scroll offset baked in. Read in viewport-space on demand without re-rendering every frame.
Shared ResizeObserverOne throttled observer for the whole app; per-element reference counting so multiple subscribers share a single registration.
Pluggable scroll sourcescrollGetter + scrollListener options — wire Lenis, Locomotive, or any virtual scroller without forking the library.
Observer poolIntersectionObserver instances are keyed by { root, rootMargin, threshold } and reused across observe() calls.
React hooksuseBounds, useScreen, useObserve, useTrack, useQueryBounds, useScrollCallback — subscribe via a single emitter.
Debug instrumentationOpt-in counters for getBoundingClientRect calls, cache hit/miss ratio, and observer activity. Zero overhead when disabled.
SSR-safeBrowser APIs are lazily accessed; the React provider mounts behind useLayoutEffect and never touches window on the server.

Install

pnpm add @joycostudio/metri

Quick start

import { Metri } from '@joycostudio/metri'
 
const metri = new Metri({ throttleMs: 100 })
metri.initialize()
 
// Register an element — its bounds are cached and updated on resize.
const el = document.querySelector('#hero')!
metri.track(el)
 
metri.on('refresh', ({ target, rect }) => {
  if (target === el) console.log('hero moved', rect)
})
 
// Read cached bounds — no getBoundingClientRect call.
metri.get(el)         // document-space
metri.getBounds(el)   // viewport-space
metri.windowSize      // { width, height, ... }

Quick start (React)

import { MetriProvider, useBounds, useScreen } 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 { screen } = useScreen()
 
  return (
    <div ref={ref}>
      {bounds?.width.toFixed(0)} × {bounds?.height.toFixed(0)}
      {' / viewport '}
      {screen.width} × {screen.height}
    </div>
  )
}

Architecture

LayerResponsibility
MetriCentral registry. Owns the cache, the shared ResizeObserver, the observer pool, and the scroll listener.
Bounds cacheMap<Element | Window, Bounds> keyed by element reference. Bounds stored in document-space.
ResizeObserverOne shared, throttled observer. Elements registered via track() with reference counting.
Observer poolIntersectionObserver instances keyed by config hash. Reused across every observe() call.
Scroll subsystemPluggable scrollGetter / scrollListener. Emits scroll events and feeds the viewport-space converter.

Bounds are stored in document-space (scroll offset already applied). Converting to viewport-space is a cheap subtraction at read time, so scrolling never invalidates the cache.


Core API

Metri

import { Metri } from '@joycostudio/metri'
 
const metri = new Metri(options?: MetriOptions)
metri.initialize()

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

Options

type MetriOptions = {
  throttleMs?: number       // ResizeObserver + resize throttle (default: 100)
  scrollGetter?: ScrollGetter   // Read the current scroll state
  scrollListener?: ScrollListener // Subscribe to scroll updates
  debug?: boolean | { interval: number } // Opt-in instrumentation
}
 
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.

Bounds

MethodReturnsDescription
get(elm)BoundsCached bounds in document-space. Computes lazily on cache miss.
getBounds(elm)BoundsCached bounds in viewport-space (document-space minus current scroll).
windowSizeBoundsViewport dimensions ({ width, height, ... }).
track(elm)BoundsRegister with the shared ResizeObserver and cache bounds. Reference-counted.
dispose(elm)voidDecrement refcount; stop observing and evict cache entry when it hits zero.
refresh(elm?)voidRecompute bounds for one element, or — with no argument — for the window + every tracked element.
type Bounds = {
  x: number
  y: number
  top: number
  right: number
  bottom: number
  left: number
  width: number
  height: number
}

Scroll

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

Intersection

MethodReturnsDescription
observe(elm, config)IntersectionObserverObserve an element. Observers are pooled by config — same config returns the same instance.
unobserve(elm)voidUnobserve from every pooled observer.
disposeIntersectionObserver(config)voidDisconnect and evict a pooled observer.
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 }
}
 
metri.on('refresh', ({ target, rect }) => { /* ... */ })
metri.on('observe', ({ target, visible }) => { /* ... */ })
metri.on('scroll', ({ scrollY }) => { /* ... */ })
metri.off('refresh', listener)

refresh fires on window resize, ResizeObserver entries, and manual refresh() calls. rect is always in viewport-space (the document-space version lives in the cache).

Debug

const metri = new Metri({ debug: true })
// or log to console every 2s and reset counters per window:
const metri = new Metri({ debug: { interval: 2000 } })
MethodReturnsDescription
debugSnapshot()DebugSnapshot | nullCurrent counter snapshot. null when debug is disabled.
debugReset()voidZero the counters — enables reset-then-snapshot profiling windows.
type DebugSnapshot = {
  getBoundingClientRectCalls: number
  cacheHits: number
  cacheMisses: number
  trackedElementCount: number
  fullRefreshCount: number
  targetedRefreshCount: number
  resizeObserverFires: number
  intersectionObserverCount: number
  cacheSize: number
}

Zero overhead when debug is falsy — the instrumentation hooks short-circuit before any branches.

Cleanup

metri.disposeAll() // Disconnects observers, detaches listeners, clears the cache

React API

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

All hooks read from a single Metri instance held by the provider; subscriptions go through the shared emitter so components only re-render when their target changes.

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 throttleMs={100} debug={false}>
  {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({ throttleMs: 100 })
metri.initialize()
 
<MetriProvider metri={metri}>
  {children}
</MetriProvider>

In bridge mode the provider never calls initialize() or disposeAll() — lifecycle is yours.

useMetri

Returns the context value — typed { metri: Metri }. Throws if no provider is mounted.

const { metri } = useMetri()
metri.setPlaybackRate // ← nope, wrong lib; you get the Metri instance

useBounds

Reactive bounds for a single element. Registers with ResizeObserver on mount and unregisters on unmount.

const ref = useRef<HTMLDivElement>(null)
const { bounds, getBounds, refresh } = useBounds(ref, { space: 'document' })
ReturnTypeDescription
boundsBounds | undefinedCurrent bounds snapshot. undefined before first measure.
getBounds()() => Bounds | undefinedImperative read — skips React state.
refresh()() => voidForce a recompute for this element.

space: 'viewport' subscribes to the scroll event and re-renders on every scroll tick. Default is 'document' — scroll-independent and free.

useQueryBounds

Same as useBounds, but resolves the target with document.querySelector.

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

useScreen

Reactive viewport dimensions — updates on resize only.

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

useObserve

Reactive visibility via IntersectionObserver.

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

Config is passed straight to the pooled IntersectionObserver, so components sharing the same config share one native observer.

useTrack

Composition of useBounds + useObserve with a scroll-progress callback.

const { bounds, observe, scrollProgress } = useTrack(ref, {
  threshold: 0,
  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. onProgress fires on every scroll event the provider emits.

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 drop-in <Slot> wrapper that forwards data-visible={true|false} to its child based on IntersectionObserver. No state, no re-renders outside the attribute flip.

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

Recipes

Lenis as the scroll source

import Lenis from 'lenis'
import type { ScrollGetter, ScrollListener } from '@joycostudio/metri/react'
 
let lenis: Lenis | null = null
const getLenis = () => (lenis ??= new Lenis({ autoRaf: true }))
 
export const scrollGetter: ScrollGetter = () => ({ scrollY: getLenis().scroll, scrollX: 0 })
export const scrollListener: ScrollListener = (cb) =>
  getLenis().on('scroll', (l) => cb({ scrollX: 0, scrollY: l.scroll }))
<MetriProvider scrollGetter={scrollGetter} scrollListener={scrollListener}>
  {children}
</MetriProvider>

Deferred scroll source (Lenis mounted later)

const { metri } = useMetri()
 
useEffect(() => {
  const lenis = new Lenis({ autoRaf: true })
  metri.setScrollHandlers(
    () => ({ scrollX: 0, scrollY: lenis.scroll }),
    (cb) => lenis.on('scroll', (l) => cb({ scrollX: 0, scrollY: l.scroll })),
  )
  return () => lenis.destroy()
}, [metri])

Scroll-driven reveal

function Reveal({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null)
  const [p, setP] = useState(0)
 
  useTrack(ref, { onProgress: setP })
 
  return (
    <div ref={ref} style={{ opacity: p, transform: `translateY(${(1 - p) * 40}px)` }}>
      {children}
    </div>
  )
}

Bridge across portals / multiple trees

const metri = useMemo(() => new Metri(), [])
useLayoutEffect(() => {
  metri.initialize()
  return () => metri.disposeAll()
}, [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.

Profiling a render window

const metri = new Metri({ debug: true })
metri.initialize()
 
// ...some interaction...
metri.debugReset()
await userTypes()
console.table(metri.debugSnapshot())

CSS-only visibility animations

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

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
MethodSpaceTriggers re-render on scroll?
metri.get(elm)documentNo
metri.getBounds(elm)viewportNo (it's a pure read; you decide when to call)
useBounds(ref)documentNo
useBounds(ref, { space: 'viewport' })viewportYes — every scroll event

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


Package exports

Import pathContents
@joycostudio/metriCore: Metri, EVENTS, VERSION, and all types (Bounds, ScrollState, ScrollGetter, ScrollListener, MetriOptions, DebugOption, DebugSnapshot, IntersectionConfig).
@joycostudio/metri/reactReact: MetriProvider, useMetri, useBounds, useQueryBounds, useScreen, useObserve, useTrack, useScrollCallback, DataVisibleSlot.

Related Toolboxs