Metri
Metri library by JOYCO Studio.
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
| Feature | Description |
|---|---|
| Cached bounds | getBoundingClientRect fires once per element; results stay fresh via a shared ResizeObserver. |
| Document-space coords | Bounds are stored with scroll offset baked in. Read in viewport-space on demand without re-rendering every frame. |
| Shared ResizeObserver | One throttled observer for the whole app; per-element reference counting so multiple subscribers share a single registration. |
| Pluggable scroll source | scrollGetter + scrollListener options — wire Lenis, Locomotive, or any virtual scroller without forking the library. |
| Observer pool | IntersectionObserver instances are keyed by { root, rootMargin, threshold } and reused across observe() calls. |
| React hooks | useBounds, useScreen, useObserve, useTrack, useQueryBounds, useScrollCallback — subscribe via a single emitter. |
| Debug instrumentation | Opt-in counters for getBoundingClientRect calls, cache hit/miss ratio, and observer activity. Zero overhead when disabled. |
| SSR-safe | Browser APIs are lazily accessed; the React provider mounts behind useLayoutEffect and never touches window on the server. |
Install
pnpm add @joycostudio/metriQuick 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
| Layer | Responsibility |
|---|---|
| Metri | Central registry. Owns the cache, the shared ResizeObserver, the observer pool, and the scroll listener. |
| Bounds cache | Map<Element | Window, Bounds> keyed by element reference. Bounds stored in document-space. |
| ResizeObserver | One shared, throttled observer. Elements registered via track() with reference counting. |
| 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. |
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) => () => voidDefaults use window.scrollX/Y and a passive scroll listener on window.
Bounds
| Method | Returns | Description |
|---|---|---|
get(elm) | Bounds | Cached bounds in document-space. Computes lazily on cache miss. |
getBounds(elm) | Bounds | Cached bounds in viewport-space (document-space minus current scroll). |
windowSize | Bounds | Viewport dimensions ({ width, height, ... }). |
track(elm) | Bounds | Register with the shared ResizeObserver and cache bounds. Reference-counted. |
dispose(elm) | void | Decrement refcount; stop observing and evict cache entry when it hits zero. |
refresh(elm?) | void | Recompute 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 / 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. |
Intersection
| Method | Returns | Description |
|---|---|---|
observe(elm, config) | IntersectionObserver | Observe an element. Observers are pooled by config — same config returns the same instance. |
unobserve(elm) | void | Unobserve from every pooled observer. |
disposeIntersectionObserver(config) | void | Disconnect 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 } })| Method | Returns | Description |
|---|---|---|
debugSnapshot() | DebugSnapshot | null | Current counter snapshot. null when debug is disabled. |
debugReset() | void | Zero 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 cacheReact 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 instanceuseBounds
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' })| Return | Type | Description |
|---|---|---|
bounds | Bounds | undefined | Current bounds snapshot. undefined before first measure. |
getBounds() | () => Bounds | undefined | Imperative read — skips React state. |
refresh() | () => void | Force 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| Method | Space | Triggers re-render on scroll? |
|---|---|---|
metri.get(elm) | document | No |
metri.getBounds(elm) | viewport | No (it's a pure read; you decide when to call) |
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.
Package exports
| Import path | Contents |
|---|---|
@joycostudio/metri | Core: Metri, EVENTS, VERSION, and all types (Bounds, ScrollState, ScrollGetter, ScrollListener, MetriOptions, DebugOption, DebugSnapshot, IntersectionConfig). |
@joycostudio/metri/react | React: MetriProvider, useMetri, useBounds, useQueryBounds, useScreen, useObserve, useTrack, useScrollCallback, DataVisibleSlot. |