This walkthrough builds a fully typed useResizeObserver composable for Vue 3 from an empty file, adding the box option, animation-frame throttling, and scope-safe teardown one step at a time, so you end with a composable you can drop straight into a component library.

Problem / Scenario Context

A chart component, a responsive card grid, and a canvas-based data visualization all need the same primitive: know an element's current pixel dimensions and re-run layout logic whenever they change. window resize events only report viewport size, not a specific element's box, and they fire far more often than any single element actually changes shape. ResizeObserver — covered in depth in ResizeObserver Mechanics & Triggers — solves the accuracy problem, but every component that needs it ends up repeating the same onMounted/onUnmounted boilerplate unless it's extracted into a composable, as introduced in Vue Observer Composables.

This page assumes you're comfortable with the Composition API basics and focuses on the specific decisions that separate a toy composable from a production one: which box model to report, how to throttle updates during continuous resize, and how to guarantee teardown regardless of where the composable is called from.

Step-by-Step Build

Step 1 — Define the return shape and options interface

Start with the types. A well-typed composable makes every consuming component's autocomplete correct from day one.

TypeScript
import type { Ref } from 'vue';

export interface UseResizeObserverOptions {
  /** Which box model to report; defaults to 'content-box'. */
  box?: ResizeObserverBoxOptions;
  /** Throttle reactive updates to one per animation frame. Defaults to true. */
  throttleWithRaf?: boolean;
}

export interface UseResizeObserverResult {
  target: Ref<HTMLElement | null>;
  width: Ref<number>;
  height: Ref<number>;
  isSupported: Ref<boolean>;
}

isSupported lets a consuming component render a static fallback layout when ResizeObserver is unavailable, rather than silently reporting 0 forever.

Step 2 — Scaffold reactive state and the template ref

TypeScript
import { ref } from 'vue';

export function useResizeObserver(
  options: UseResizeObserverOptions = {}
): UseResizeObserverResult {
  const { box = 'content-box', throttleWithRaf = true } = options;

  const target = ref<HTMLElement | null>(null);
  const width = ref(0);
  const height = ref(0);
  const isSupported = ref(typeof ResizeObserver !== 'undefined');

  // ... observer wiring added in the next steps
  return { target, width, height, isSupported };
}

Compute isSupported eagerly at call time rather than inside onMounted — Vue components can read it synchronously during their own setup() to decide whether to render an observer-driven branch at all.

Step 3 — Create the observer inside onMounted with the box option

TypeScript
import { ref, onMounted, type Ref } from 'vue';

// continuing the function body from Step 2
let observer: ResizeObserver | null = null;
let rafId: number | null = null;

onMounted(() => {
  if (!isSupported.value || !target.value) return;

  const applySize = (entry: ResizeObserverEntry): void => {
    const size = box === 'border-box'
      ? entry.borderBoxSize?.[0]
      : box === 'device-pixel-content-box'
        ? entry.devicePixelContentBoxSize?.[0]
        : entry.contentBoxSize?.[0];

    if (size) {
      width.value = size.inlineSize;
      height.value = size.blockSize;
    } else {
      // Older Safari: no boxSize arrays, fall back to contentRect
      width.value = entry.contentRect.width;
      height.value = entry.contentRect.height;
    }
  };

  observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
    const [entry] = entries;
    if (!entry) return;

    if (throttleWithRaf) {
      if (rafId !== null) cancelAnimationFrame(rafId);
      rafId = requestAnimationFrame(() => applySize(entry));
    } else {
      applySize(entry);
    }
  });

  observer.observe(target.value, { box });
});

The box option is passed straight through to observe(), and the callback reads the matching *BoxSize array — contentBoxSize for content-box, borderBoxSize for border-box, devicePixelContentBoxSize for device-pixel-content-box — falling back to the always-present contentRect for engines that predate the boxSize arrays.

Step 4 — Throttle with requestAnimationFrame

The rafId guard above cancels any pending animation frame before scheduling a new one, so a burst of native ResizeObserver notifications during a continuous drag-resize collapses into a single reactive update per frame:

TypeScript
// Already wired above — isolated here for clarity
function scheduleUpdate(entry: ResizeObserverEntry, apply: (e: ResizeObserverEntry) => void): void {
  if (rafId !== null) cancelAnimationFrame(rafId);
  rafId = requestAnimationFrame(() => apply(entry));
}

Without this guard, a component with an expensive watch(width, recomputeChartLayout) handler would re-run recomputeChartLayout on every native notification — potentially several times within a single 16.6ms frame during a fast window drag — rather than once per rendered frame.

Step 5 — Tear down with tryOnScopeDispose

TypeScript
import { tryOnScopeDispose } from '@vueuse/core';

tryOnScopeDispose(() => {
  if (rafId !== null) cancelAnimationFrame(rafId);
  observer?.disconnect();
  observer = null;
});

tryOnScopeDispose (from VueUse) checks for an active effect scope before registering the callback, so this composable works both when it's called directly inside a component's setup() and when it's called from a Pinia store's setup function or a nested composable running inside a plain effectScope(). If you know the composable will only ever be used directly inside components, onUnmounted from vue itself works identically and avoids the VueUse dependency:

TypeScript
import { onUnmounted } from 'vue';

onUnmounted(() => {
  if (rafId !== null) cancelAnimationFrame(rafId);
  observer?.disconnect();
});

Step 6 — Full assembled composable

TypeScript
import { ref, onMounted, onUnmounted, type Ref } from 'vue';

export interface UseResizeObserverOptions {
  box?: ResizeObserverBoxOptions;
  throttleWithRaf?: boolean;
}

export interface UseResizeObserverResult {
  target: Ref<HTMLElement | null>;
  width: Ref<number>;
  height: Ref<number>;
  isSupported: Ref<boolean>;
}

export function useResizeObserver(
  options: UseResizeObserverOptions = {}
): UseResizeObserverResult {
  const { box = 'content-box', throttleWithRaf = true } = options;

  const target = ref<HTMLElement | null>(null);
  const width = ref(0);
  const height = ref(0);
  const isSupported = ref(typeof ResizeObserver !== 'undefined');

  let observer: ResizeObserver | null = null;
  let rafId: number | null = null;

  const applySize = (entry: ResizeObserverEntry): void => {
    const size = box === 'border-box'
      ? entry.borderBoxSize?.[0]
      : box === 'device-pixel-content-box'
        ? entry.devicePixelContentBoxSize?.[0]
        : entry.contentBoxSize?.[0];

    if (size) {
      width.value = size.inlineSize;
      height.value = size.blockSize;
    } else {
      width.value = entry.contentRect.width;
      height.value = entry.contentRect.height;
    }
  };

  onMounted(() => {
    if (!isSupported.value || !target.value) return;

    observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
      const [entry] = entries;
      if (!entry) return;

      if (throttleWithRaf) {
        if (rafId !== null) cancelAnimationFrame(rafId);
        rafId = requestAnimationFrame(() => applySize(entry));
      } else {
        applySize(entry);
      }
    });

    observer.observe(target.value, { box });
  });

  onUnmounted(() => {
    if (rafId !== null) cancelAnimationFrame(rafId);
    observer?.disconnect();
  });

  return { target, width, height, isSupported };
}
JavaScript
// Plain JS equivalent (no types)
import { ref, onMounted, onUnmounted } from 'vue';

export function useResizeObserver(options = {}) {
  const { box = 'content-box', throttleWithRaf = true } = options;
  const target = ref(null);
  const width = ref(0);
  const height = ref(0);
  const isSupported = ref(typeof ResizeObserver !== 'undefined');
  let observer = null;
  let rafId = null;

  const applySize = (entry) => {
    const size = box === 'border-box'
      ? entry.borderBoxSize?.[0]
      : box === 'device-pixel-content-box'
        ? entry.devicePixelContentBoxSize?.[0]
        : entry.contentBoxSize?.[0];
    if (size) {
      width.value = size.inlineSize;
      height.value = size.blockSize;
    } else {
      width.value = entry.contentRect.width;
      height.value = entry.contentRect.height;
    }
  };

  onMounted(() => {
    if (!isSupported.value || !target.value) return;
    observer = new ResizeObserver((entries) => {
      const [entry] = entries;
      if (!entry) return;
      if (throttleWithRaf) {
        if (rafId !== null) cancelAnimationFrame(rafId);
        rafId = requestAnimationFrame(() => applySize(entry));
      } else {
        applySize(entry);
      }
    });
    observer.observe(target.value, { box });
  });

  onUnmounted(() => {
    if (rafId !== null) cancelAnimationFrame(rafId);
    observer?.disconnect();
  });

  return { target, width, height, isSupported };
}

Step 7 — Consuming it for a container-query fallback

HTML
<script setup lang="ts">
import { computed } from 'vue';
import { useResizeObserver } from './composables/useResizeObserver';

const { target, width } = useResizeObserver({ box: 'content-box' });

const layoutClass = computed(() => {
  if (width.value >= 640) return 'card--wide';
  if (width.value >= 360) return 'card--medium';
  return 'card--compact';
});
</script>

<template>
  <div ref="target" :class="['card', layoutClass]">
    <slot />
  </div>
</template>

Reading width.value inside a computed gives the component a JavaScript-driven equivalent of a CSS container query, useful as a fallback for browsers without container query support or for layout logic too complex to express in CSS alone.

Configuration Notes

Decision Recommendation Why
Default box value content-box Matches the native ResizeObserver default and most layout math
throttleWithRaf default true Prevents redundant reactive updates during continuous drag-resize
Teardown hook onUnmounted for component-only use, tryOnScopeDispose for shared/store use Avoids the "no active component instance" warning outside setup()
Shared vs. per-instance observer Per-instance here; switch to a shared registry for lists of 50+ elements One ResizeObserver per row is wasteful at scale — see the parent composables guide's shared-observer pattern

Verification Steps

  • Render the component in a resizable container (a <textarea>-style resize handle or a DevTools device toolbar drag) and confirm width.value/height.value update in the Vue DevTools reactive state panel.
  • Drag continuously for several seconds and confirm — via a console.count() inside applySize — that update frequency stays roughly at one call per animation frame rather than firing dozens of times per frame.
  • Unmount the component (toggle a v-if) and confirm via Chrome DevTools' Performance Monitor that the ResizeObserver count returns to its pre-mount value, indicating disconnect() ran.
  • Stub ResizeObserver as undefined in a Vitest test and confirm isSupported.value is false and the component renders its fallback branch without throwing.

FAQ

Which box option should useResizeObserver default to?

Default to content-box, since it matches what CSS layout code usually expects (dimensions excluding padding and border) and it is the ResizeObserver default when no box option is passed at all. Only switch to border-box or device-pixel-content-box when a specific consumer — a canvas renderer or a component with box-sizing: border-box — explicitly needs those numbers.

Do I need requestAnimationFrame throttling if ResizeObserver already batches callbacks?

ResizeObserver batches all pending entries into one callback per observer per frame, but that does not prevent your own callback logic from doing expensive work every frame during a continuous drag-resize. rAF throttling inside the composable coalesces reactive ref updates so Vue's renderer only processes one update per animation frame instead of firing on every native ResizeObserver notification, which matters when many rows share one observer.

How is this composable useful for CSS container queries?

Native CSS container queries only key off an ancestor's size, and support still varies for older browsers. Exposing width as a reactive ref lets a component apply layout classes conditionally in JavaScript (v-bind:class) as a polyfill-free fallback, or drive imperative canvas/chart re-layout logic that CSS container queries cannot express.


↑ Back to Vue Observer Composables