---
title: "Sensors and the viewport: useIntersectionObserver, useElementSize and useMediaQuery · vuejs.school"
description: "Lazy-load images, size a component by its own box, respect reduced motion and add a back-to-top button with VueUse 14, with cleanup and server rendering handled."
canonical_url: "https://vuejs.school/community/blog/sensors-and-the-viewport-with-vueuse"
meta:
  "og:description": "Lazy-load images, size a component by its own box, respect reduced motion and add a back-to-top button with VueUse 14, with cleanup and server rendering handled."
  "og:image": "https://vuejs.school/_og/d/a_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnk%2Cdescription_~TGF6eS1sb2FkIGltYWdlcywgc2l6ZSBhIGNvbXBvbmVudCBieSBpdHMgb3duIGJveCwgcmVzcGVjdCByZWR1Y2VkIG1vdGlvbiBhbmQgYWRkIGEgYmFjay10by10b3AgYnV0dG9uIHdpdGggVnVlVXNlIDE0LCB3aXRoIGNsZWFudXAgYW5kIHNlcnZlciByZW5kZXJpbmcgaGFuZGxlZC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zZW5zb3JzLWFuZC10aGUtdmlld3BvcnQtd2l0aC12dWV1c2Ui%2Cs_PgZl09h2PLoG-UMf.png"
  "og:image:alt": "Sensors and the viewport: useIntersectionObserver, useElementSize and useMediaQuery · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Sensors and the viewport: useIntersectionObserver, useElementSize and useMediaQuery · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnk%2Cdescription_~TGF6eS1sb2FkIGltYWdlcywgc2l6ZSBhIGNvbXBvbmVudCBieSBpdHMgb3duIGJveCwgcmVzcGVjdCByZWR1Y2VkIG1vdGlvbiBhbmQgYWRkIGEgYmFjay10by10b3AgYnV0dG9uIHdpdGggVnVlVXNlIDE0LCB3aXRoIGNsZWFudXAgYW5kIHNlcnZlciByZW5kZXJpbmcgaGFuZGxlZC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zZW5zb3JzLWFuZC10aGUtdmlld3BvcnQtd2l0aC12dWV1c2Ui%2Cs_PgZl09h2PLoG-UMf.png"
  "twitter:image:alt": "Sensors and the viewport: useIntersectionObserver, useElementSize and useMediaQuery · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## How the sensor composables work

The ones that watch an element take a template ref. The composable creates an observer or adds a listener once the element exists, writes what it sees into refs, and disconnects when the component unmounts. That last part is the reason to use these composables over the raw API: the cleanup is tied to the component's lifetime, so you cannot forget it. Where a composable returns a `stop` function, calling it does the same cleanup early.

The template ref comes from `useTemplateRef`, added in Vue 3.5. It takes the string you put in `ref="..."` and returns a ref that is `null` until the element mounts.

## Load an image when it is about to scroll in

An intersection observer watches an element and reports whether it overlaps a root, by default the viewport. `useIntersectionObserver` takes the target, a callback that receives the entries, and the observer's options.

*src/components/LazyImage.vue*

```
<script setup lang="ts">
import { shallowRef, useTemplateRef } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'

defineProps<{ src: string; alt: string }>()

const img = useTemplateRef<HTMLImageElement>('img')
const shouldLoad = shallowRef(false)

const { stop } = useIntersectionObserver(
  img,
  ([entry]) => {
    if (entry?.isIntersecting) {
      shouldLoad.value = true
      stop()
    }
  },
  { rootMargin: '200px' },
)
</script>

<template>
  <img ref="img" :src="shouldLoad ? src : undefined" :alt="alt" width="800" height="600" />
</template>
```

`rootMargin: '200px'` grows the viewport by 200 pixels on every side, so the image starts loading just before it appears. Once it has, `stop()` disconnects the observer; there is nothing left to watch. The `width` and `height` attributes reserve the space so the page does not jump when the image arrives.

For a plain content image the native `loading="lazy"` attribute does this with no JavaScript, and you should prefer it. The observer earns its place when you need to know that something became visible: to start a video, record an impression, or load the next page of a list.

## The mistake: passing the element instead of the ref

Every one of these composables wants the ref, not its value.

*src/components/LazyImage.vue**Breaks***

```
const img = useTemplateRef<HTMLImageElement>('img')

useIntersectionObserver(img.value, ([entry]) => {
  shouldLoad.value = entry?.isIntersecting ?? false
})
```

During setup the element does not exist yet, so `img.value` is `null`. The composable receives `null`, watches nothing, and never calls back. No error, no warning, just an image that never loads.

*src/components/LazyImage.vue**Works***

```
const img = useTemplateRef<HTMLImageElement>('img')

useIntersectionObserver(img, ([entry]) => {
  shouldLoad.value = entry?.isIntersecting ?? false
})
```

Passing `img` lets VueUse watch the ref and attach the observer the moment the element mounts. The same applies to `useElementSize`, `useMouse` with a target, and anything else that takes an element.

## Reveal a section on scroll, and respect reduced motion

The same observer drives a reveal animation. Two things change. A `threshold` sets the fraction of the element that must be visible before the callback fires, and a check against the user's motion preference skips the animation. `useMediaQuery` takes any CSS media query string and returns a boolean ref that updates when the query result changes.

*src/components/RevealOnScroll.vue*

```
<script setup lang="ts">
import { shallowRef, useTemplateRef } from 'vue'
import { useIntersectionObserver, useMediaQuery } from '@vueuse/core'

const box = useTemplateRef<HTMLElement>('box')
const revealed = shallowRef(false)
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')

const { stop } = useIntersectionObserver(
  box,
  ([entry]) => {
    if (entry?.isIntersecting) {
      revealed.value = true
      stop()
    }
  },
  { threshold: 0.5 },
)
</script>

<template>
  <section ref="box" :class="{ revealed: revealed || reducedMotion }">
    <slot />
  </section>
</template>
```

With `threshold: 0.5` the callback fires when half the section is on screen. The styles are yours to write: give `section` a starting opacity and transform, and a transition to the `.revealed` state. When the user has asked for reduced motion the class is applied immediately, so the content is simply there, as that setting asks.

Dark mode works the same way: `useMediaQuery('(prefers-color-scheme: dark)')`. VueUse also ships `usePreferredDark()` and `usePreferredReducedMotion()`, which wrap these two queries so you do not have to remember the strings. Only the first returns a boolean: `usePreferredReducedMotion()` returns the string `'reduce'` or `'no-preference'`, and both count as true in a condition, so compare it with `'reduce'`.

## Size a card by its own width, or by the window

Media queries answer "how wide is the window". A reusable component usually needs "how wide am I", because it may sit in a sidebar on one page and span the main column on another. `useElementSize` wraps a resize observer and returns `width` and `height` refs for one element.

*src/components/ChartCard.vue*

```
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { breakpointsTailwind, useBreakpoints, useElementSize } from '@vueuse/core'

const card = useTemplateRef<HTMLElement>('card')
const { width } = useElementSize(card)
const bars = computed(() => Math.max(4, Math.floor(width.value / 48)))

const breakpoints = useBreakpoints(breakpointsTailwind)
const isWide = breakpoints.greaterOrEqual('md')
</script>

<template>
  <article ref="card" :class="isWide ? 'two-columns' : 'stacked'">
    <p>{{ bars }} bars fit in {{ Math.round(width) }}px</p>
  </article>
</template>
```

`bars` is recomputed whenever the card changes size, whether because the window resized or because a sidebar opened next to it. That is the kind of decision CSS container queries also solve, and when the answer is purely visual, prefer CSS. Use the ref when JavaScript needs the number, as a chart library does.

`useBreakpoints` is for layout decisions that follow the window. `breakpointsTailwind` is a preset with the Tailwind names (`sm` 640, `md` 768, `lg` 1024, `xl` 1280, `2xl` 1536), `greaterOrEqual('md')` returns a boolean ref, and `breakpoints.active()` returns the name of the current one. In Nuxt, VueUse's module (`@vueuse/nuxt`) auto-imports `useBreakpoints` but not the presets, so import the one you use.

## Add a back-to-top button and track the pointer

`useWindowScroll` returns `x` and `y` for the page scroll position. They are writable: assigning to `y` scrolls the window, and the `behavior` option makes that scroll smooth.

*src/components/BackToTop.vue*

```
<script setup lang="ts">
import { computed } from 'vue'
import { useWindowScroll } from '@vueuse/core'

const { y } = useWindowScroll({ behavior: 'smooth' })
const visible = computed(() => y.value > 400)
</script>

<template>
  <button v-show="visible" type="button" @click="y = 0">Back to top</button>
</template>
```

VueUse updates `y` on every scroll event unless you pass a `throttle` option, and comparing a number with 400 in a `computed` is cheap enough to run that often. `v-show` keeps the button in the DOM and toggles its CSS `display`, which costs less than `v-if` for something that flips often. While it is hidden, screen readers skip it too.

The last sensor is the simplest: `const { x, y, sourceType } = useMouse()` gives the pointer position in page coordinates and whether it came from a mouse or a touch. It is the right tool for a demo or a custom cursor and the wrong one for anything that could be a CSS `:hover`.

## Choose what the server renders

None of these APIs exist on a server, and VueUse does not pretend they do. During server rendering `useMediaQuery` returns `false`, `useWindowScroll` returns zeros, `useElementSize` returns its initial size of `0` by `0`, and an intersection observer cannot fire before the component mounts in a browser. The code does not crash.

The risk is a hydration mismatch: the server's HTML says one thing and the browser's first render another. Vue warns about it in development but repairs only part of it. Text is corrected; a `class` keeps the value the server wrote until the bound value changes again. Rendered on a server, `ChartCard` stays `stacked` on a wide screen. For a visitor who asked for reduced motion, `RevealOnScroll` keeps its hidden starting state, because Vue believes the `revealed` class is already there and never adds it.

So decide the server value on purpose. For a width-based decision, tell VueUse which width to assume: `useMediaQuery('(min-width: 1024px)', { ssrWidth: 1024 })`, or `useBreakpoints(breakpointsTailwind, { ssrWidth: 1024 })`. In a Nuxt app you can set it once for every call with a plugin.

*app/plugins/ssr-width.ts*

```
import { provideSSRWidth } from '@vueuse/core'

export default defineNuxtPlugin((nuxtApp) => {
  provideSSRWidth(1024, nuxtApp.vueApp)
})
```

Let the plugin run on both the server and the browser (no `.server` in the file name): the browser's first render needs the width too. With a width set, `useMediaQuery` answers from it on the server and in the browser's first render, so the two agree, and switches to the real result once the component mounts. A query that is not about width, such as reduced motion, answers `false` until then, so this one plugin fixes both components above. Pick the width most of your visitors have, and the server renders the layout most of them will keep.

A width does not help the lazy image. The server renders `shouldLoad` as `false`, so the `src` is missing from the HTML it sends, and a crawler that reads only that HTML never sees the image. That is one more reason to prefer native `loading="lazy"` for content images. For anything that only makes sense in a browser, such as the back-to-top button, render it only after mount, or in Nuxt wrap it in `<ClientOnly>`.

**Check it stuck**3 questions from the Sensors and the viewport quiz.

[**Practice now**](https://vuejs.school/learn/vueuse/sensors-and-the-viewport?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Deep dive · 6 min read**VueUse 15's new composables: useWebMCP, useLiveAnnouncer and useTemporalNow**](https://vuejs.school/community/blog/vueuse-15s-new-composables-usewebmcp-useliveannouncer-usetemporalnow) [Release notes · 6 min read**VueUse 15: the breaking changes in plain words**](https://vuejs.school/community/blog/vueuse-15-the-breaking-changes-in-plain-words)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/sensors-and-the-viewport-with-vueuse#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-11-25","description":"Lazy-load images, size a component by its own box, respect reduced motion and add a back-to-top button with VueUse 14, with cleanup and server rendering handled.","name":"Sensors and the viewport: useIntersectionObserver, useElementSize and useMediaQuery","url":"https://vuejs.school/community/blog/sensors-and-the-viewport-with-vueuse","breadcrumb":{"@id":"https://vuejs.school/community/blog/sensors-and-the-viewport-with-vueuse#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/sensors-and-the-viewport-with-vueuse"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/sensors-and-the-viewport-with-vueuse#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-11-25","description":"Lazy-load images, size a component by its own box, respect reduced motion and add a back-to-top button with VueUse 14, with cleanup and server rendering handled.","headline":"Sensors and the viewport: useIntersectionObserver, useElementSize and useMediaQuery","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnk%2Cdescription_~TGF6eS1sb2FkIGltYWdlcywgc2l6ZSBhIGNvbXBvbmVudCBieSBpdHMgb3duIGJveCwgcmVzcGVjdCByZWR1Y2VkIG1vdGlvbiBhbmQgYWRkIGEgYmFjay10by10b3AgYnV0dG9uIHdpdGggVnVlVXNlIDE0LCB3aXRoIGNsZWFudXAgYW5kIHNlcnZlciByZW5kZXJpbmcgaGFuZGxlZC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zZW5zb3JzLWFuZC10aGUtdmlld3BvcnQtd2l0aC12dWV1c2Ui%2Cs_PgZl09h2PLoG-UMf.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/sensors-and-the-viewport-with-vueuse#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/sensors-and-the-viewport-with-vueuse#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/sensors-and-the-viewport-with-vueuse#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Sensors and the viewport: useIntersectionObserver, useElementSize and useMediaQuery","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnk%2Cdescription_~TGF6eS1sb2FkIGltYWdlcywgc2l6ZSBhIGNvbXBvbmVudCBieSBpdHMgb3duIGJveCwgcmVzcGVjdCByZWR1Y2VkIG1vdGlvbiBhbmQgYWRkIGEgYmFjay10by10b3AgYnV0dG9uIHdpdGggVnVlVXNlIDE0LCB3aXRoIGNsZWFudXAgYW5kIHNlcnZlciByZW5kZXJpbmcgaGFuZGxlZC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zZW5zb3JzLWFuZC10aGUtdmlld3BvcnQtd2l0aC12dWV1c2Ui%2Cs_PgZl09h2PLoG-UMf.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~U2Vuc29ycyBhbmQgdGhlIHZpZXdwb3J0OiB1c2VJbnRlcnNlY3Rpb25PYnNlcnZlciwgdXNlRWxlbWVudFNpemUgYW5kIHVzZU1lZGlhUXVlcnk%2Cdescription_~TGF6eS1sb2FkIGltYWdlcywgc2l6ZSBhIGNvbXBvbmVudCBieSBpdHMgb3duIGJveCwgcmVzcGVjdCByZWR1Y2VkIG1vdGlvbiBhbmQgYWRkIGEgYmFjay10by10b3AgYnV0dG9uIHdpdGggVnVlVXNlIDE0LCB3aXRoIGNsZWFudXAgYW5kIHNlcnZlciByZW5kZXJpbmcgaGFuZGxlZC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zZW5zb3JzLWFuZC10aGUtdmlld3BvcnQtd2l0aC12dWV1c2Ui%2Cs_PgZl09h2PLoG-UMf.png"}]}
```