Sensors and the viewport
Measure elements, notice when they scroll into view, close menus on an outside click, and read media queries, all as refs.
What you'll learn
When you finish, you can do these.- 1Measure an element's size with useElementSize()
- 2Load a photo only when it scrolls into view
- 3Close a menu when someone clicks outside it
- 4Change behaviour on narrow screens with useBreakpoints()
Some parts of a page react to what happens around them: a note box that someone drags bigger, a photo that loads only when someone scrolls to it, a menu that closes on a click elsewhere, a page that behaves differently on a phone. The browser has a separate tool for each job, switched on and off by hand.
VueUse, a library of ready-made functions called composables, turns each tool into a sensor. A sensor hands you a ref: a box that holds a value and tells Vue when it changes. The Code tab holds a page with three parts: a note box that prints its size (NoteSize.vue), a mug's reviews that end in a photo (PhotoFeed.vue) and a Menu button (MenuButton.vue). By the end it also tells a narrow screen from a wide one. You need one idea from Debounce, throttle and timers: a VueUse function that starts something, such as the timer behind that lesson's clock, stops it by itself when its component leaves the page.
Observers that hand you refs
The note box prints its own width and height, measured by an observer: a browser object that runs a function of yours when something about an element changes. A ResizeObserver reports size changes, and an IntersectionObserver reports when an element enters or leaves the viewport, the visible area. VueUse creates the observer, points it at your element, copies each report into a ref, and disconnects it when the component unmounts, that is, leaves the page.
- Element
- ResizeObserver or IntersectionObserver
- ref updates
- template
useElementSize(target) gives a live width and height. The target is a template ref, a ref that Vue fills with an element of the template. In NoteSize.vue, useTemplateRef('note') creates one, and ref="note" marks the <textarea> that goes in it.
<script setup lang="ts">
const note = useTemplateRef('note')
const { width, height } = useElementSize(note)
</script>
<template>
<textarea ref="note" class="note" placeholder="Drag my corner" />
<p>Content box: {{ Math.round(width) }} × {{ Math.round(height) }} px</p>
</template>
Drag the corner of the text area in the preview and the numbers follow. They start at 240 × 60, the size set in the file's style block, which also gives the box 12 px of padding on each side. VueUse measures the content box by default: the area inside the padding and the border. Pass { box: 'border-box' } as the third argument when you need the outer size.
On screen, and outside the box
Two more sensors look after the photo and the menu: one notices when an element is on screen, the other a click outside it.
useElementVisibility(target) is true while any part of the element is visible. In PhotoFeed.vue, the mug's reviews sit in a scrolling box that ends in an empty photo slot marked ref="photo", and the script watches that slot:
const photo = useTemplateRef('photo')
const visible = useElementVisibility(photo)
Scroll the reviews down in the preview. When the slot comes into view, the photo appears and the line under the box reads "Image element: in the page". Scroll back up and the image is removed again: it carries v-if="visible", and v-if keeps an element only while its value is true.
onClickOutside(target, handler) runs your function, the handler, for every click outside the target: the usual way to close a menu. In MenuButton.vue, the Menu button opens a short list and then reads Close menu. A ref, open, says whether the list shows, and the handler sets it to false:
onClickOutside(menu, () => {
open.value = false
})
The template ref menu sits on the wrapper <div>, which holds both the button and the list. Click anywhere else in the preview and the list closes. A click on Close menu is inside, so only the button's own handler runs, and it closes the list.
The window itself: media queries
The last two sensors look at the window instead of one element, through CSS media queries: yes-or-no questions about the window or the device. useMediaQuery() turns one into a true-or-false ref that updates when the answer changes, so useMediaQuery('(prefers-reduced-motion: reduce)') is true while the person has asked their device for less motion.
useBreakpoints() builds on it with breakpoints, named widths. breakpointsTailwind is a ready-made set of them, in which sm is 640 px. In App.vue, the component that shows the other three, a narrow ref would take two lines:
const breakpoints = useBreakpoints(breakpointsTailwind)
const narrow = breakpoints.smaller('sm')
narrow is true while the window is under 640 px wide. Use refs like this for behaviour, such as turning off an animation on a phone, and let CSS handle the layout: the second mistake in What goes wrong shows why.
What goes wrong
The first of two mistakes puts ref="menu" on the list instead of the wrapper, so the Menu button is no longer inside the target.
<div class="menu">
<button @click="open = !open">{{ open ? 'Close menu' : 'Menu' }}</button>
<ul v-if="open" ref="menu">…</ul>
</div>
Clicking outside still closes the menu, but clicking Close menu does nothing. The button is outside the list, so onClickOutside() closes the menu first, then the button's own click handler flips open back to true, all within one click. Either put the ref back on the wrapper, as the starter does, or give the button ref="button" and tell onClickOutside() to ignore it:
const button = useTemplateRef('button')
onClickOutside(menu, () => {
open.value = false
}, { ignore: [button] })
Close menu closes the list again, because onClickOutside() now skips clicks on the button.
The second mistake lets a breakpoint choose the HTML on a site that builds each page on a server first, as Nuxt, a framework built on Vue, does. The Code tab builds its page in the browser only, so picture such a site's App.vue. It shows MobileNav, a menu for phones, while narrow is true, and DesktopNav, a menu for wider screens, otherwise:
<MobileNav v-if="narrow" />
<DesktopNav v-else />
A server has no window, so there every media query answers false: narrow is false, and the HTML holds the desktop menu. A phone then hydrates that HTML: Vue attaches itself to it. On the phone narrow is already true, so Vue expects the mobile menu and warns of a mismatch (Hydration node mismatch when the two menus start with different tags).
Give VueUse a width to assume on the server with ssrWidth. The browser then uses the same width until the component mounts, so hydration matches.
const breakpoints = useBreakpoints(breakpointsTailwind, { ssrWidth: 1024 })
const narrow = breakpoints.smaller('sm')
The menu still switches once the real width arrives. When the layout must be right as soon as the page appears, use a CSS media query, which the browser applies before any script runs.
What to remember
useElementSize()measures the content box by default, so padding and border are not included.useElementVisibility()follows the element both ways. When something should stay loaded, keep a ref that remembers it was seen.onClickOutside()treats the toggle button as outside unless it is inside the target or listed inignore.useBreakpoints()refs suit behaviour. Media queries answerfalseon the server, so let CSS pick the layout, or passssrWidthso hydration matches.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In App.vue, import
useBreakpointsandbreakpointsTailwindfrom@vueuse/core, addconst breakpoints = useBreakpoints(breakpointsTailwind)andconst narrow = breakpoints.smaller('sm'), and put<p>{{ narrow ? 'Narrow' : 'Wide' }} preview</p>in the template. Make the browser window narrower until the preview is under 640 px wide, or turn your phone: the word changes as you cross the line. - In MenuButton.vue, move
ref="menu"from the<div>to the<ul>. Open the menu and click Close menu: it stays open. Fix it: addref="button"to the button,const button = useTemplateRef('button'), and{ ignore: [button] }as the third argument ofonClickOutside(). Close menu closes it again. - In PhotoFeed.vue, add
const seen = ref(false)(importrefandwatchfromvue) andwatch(visible, (now) => { if (now) seen.value = true }), which runs each timevisiblechanges. Useseeninstead ofvisiblein the image'sv-ifand in the line under the box. Scroll down to the photo, then back up: the line still says in the page.