The browser can tell you when an element scrolls into view, how wide it is, whether the user prefers reduced motion, and how far the page has scrolled. Each of those is a different API with its own setup and teardown. VueUse turns each one into a composable that returns refs and cleans up after itself, so a lazy image or a back-to-top button takes a few lines. The part that needs thought is server rendering, where none of these APIs exist.
The examples are plain Vue 3.5 with explicit imports. VueUse 14 needs Vue 3.5, and one install covers everything here.
npm install @vueuse/core
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.
<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.
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.
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.
<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.
<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.
<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.
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>.