How to access DOM elements with useTemplateRef

In Vue 3.5, a new built-in composable was added: useTemplateRef.

It’s a much better way to access DOM elements directly than what we had before.

Here’s the most basic example:

<script setup>
import { useTemplateRef, onMounted } from 'vue'
const searchInput = useTemplateRef('search')
onMounted(() => {
if (!focusRef.value) return
// Focus on the search input when the component mounts
searchInput.value.focus()
})
</script>
<template>
<div>
<input ref="search" />
</div>
</template>

When this component mounts, we’ll immediately put focus into the search input. To do this, we first set up our template ref using useTemplateRef and passing it the key “search”. Then, in our template, we add a ref attribute with the same key to our input element.

Behind the scenes, Vue connects these two pieces, and now we can directly access our input element in our Vue code!

Why useTemplateRef was added to Vue

Before we move on to more advanced ways to use this and some best practices, let’s first talk about why this is important and useful.

Template refs aren’t new to Vue. Before, we would access them by declaring a regular ref like this:

<script setup>
import { ref, onMounted } from 'vue'
const searchInput = ref(null)
onMounted(() => {
if (!focusRef.value) return
// Focus on the search input when the component mounts
searchInput.value.focus()
})
</script>
<template>
<div>
<input ref="searchInput" />
</div>
</template>

There are two main differences here:

  1. We use ref(null) instead of useTemplateRef('search')
  2. We set the ref attribute to the variable itself instead of using a string

The code itself isn’t all that different, but this implementation presents us with three different challenges:

  1. It’s not that easy to read
  2. Composables that use template refs are hard to write
  3. Type inference isn’t that great

First, we can’t tell the difference between a “regular” ref, and a template ref. We have to look at the template to see how it’s being used. We can’t just look at the script block to get that info.

With useTemplateRef, we know at a glance, because it’s defined using useTemplateRef instead of ref like everything else is.

Second, writing composables that manipulate the DOM requires passing these template refs back and forth all of the time. If we took this focus example and bundled it up into a composable, we’d get this:

import { onMounted, ref } from 'vue'
export default function useFocus(focusRef) {
onMounted(() => {
// Make sure we were given a ref
if (focusRef.value) {
focusRef.value.focus()
}
})
}

To use it, we first have to create the ref and then pass it in:

<script setup>
import { ref } from 'vue'
import useFocus from './useFocus.js'
const searchInput = ref(null)
useFocus(searchInput)
</script>
<template>
<div>
<input ref="searchInput" />
</div>
</template>

Instead, with useTemplateRef, we can refactor it to be simpler and more flexible as well:

import { onMounted, useTemplateRef } from 'vue'
export default function useFocus() {
const focusRef = useTemplateRef('focus')
onMounted(() => {
if (!focusRef.value) return
focusRef.value.focus()
})
}

We’ll also have to update our template to use the “focus” key:

<script setup>
import useFocus from './useFocus.js'
useFocus()
</script>
<template>
<div>
<input ref="focus" />
</div>
</template>

With this update, we no longer have to pass the ref around! There are some other cool things we can do when writing composables with useTemplateRef, but we’ll tackle that later on in this article.

Lastly, the useTemplateRef composable gives us way better type inference in two ways:

  1. The actual ref type is automatically inferred
  2. We get autocomplete on the keys we’ve defined in our app

If we use useTemplateRef in a component or a composable (and the element we use it on is static), we get automatic type inference of that element. This means that if it’s a textarea we get the HTMLTextAreaElement type, and if it’s a div we’ll get back HTMLDivElement.

We also get fantastic autocomplete for the keys we pass into useTemplateRef, because TypeScript knows all about how it should be connected. This makes for a really great developer experience!

Okay, now that I’ve convinced you why we need it, let’s take a step deeper and look at how we can use this in our apps.

How to use the useTemplateRef composable

We’ve already seen the most basic way to use it:

<script setup>
import { useTemplateRef, onMounted } from 'vue'
const searchInput = useTemplateRef('search')
onMounted(() => {
if (!searchInput.value) return
// Focus on the search input when the component mounts
searchInput.value.focus()
})
</script>
<template>
<div>
<input ref="search" />
</div>
</template>

There are a few things we need to do:

  1. Set up the key in the template and pass it in to useTemplateRef
  2. Use onMounted to make sure the component has been rendered before we try to do anything with it
  3. Also check that the value isn’t null, especially if you’re using v-if on the element!

We can also use the created template ref like we did with previous template refs, and use it directly on the template:

<script setup>
import { useTemplateRef, onMounted } from 'vue'
const searchInputRef = useTemplateRef('search')
onMounted(() => {
if (!searchInputRef.value) return
// Focus on the search input when the component mounts
searchInputRef.value.focus()
})
</script>
<template>
<div>
<input :ref="searchInputRef" />
</div>
</template>

If you use useTemplateRef inside of a v-for, you’ll actually get back an array of elements to work with. But be careful here, there’s no guarantee that they’ll be in the same order, so you’ll have to do a bit of extra work to keep things sorted:

<script setup>
import { useTemplateRef, onMounted, ref } from 'vue'
const questions = ref([
{ text: 'What is your mother\'s maiden name?', id: 'name' },
{ text: 'What is your birthday?', id: 'birthday' },
{ text: 'What street did you grow up on?', id: 'street' },
]);
const inputRefs = useTemplateRef('inputs')
onMounted(() => {
// Do something with the array of template refs
})
</script>
<template>
<div
v-for="question in questions"
:key="question.id"
>
{{ question.text }}
<input ref="inputs" />
</div>
</template>

(I’ve simplified the above example to keep it clear, but make sure your forms are actually accessible!)

Sometimes you’ll be making changes that cause a layout shift, or something else where you need to wait for the page to fully update before doing a second thing with it. In this case, nextTick is your friend:

<script setup>
import { useTemplateRef, onMounted, nextTick } from 'vue'
const templateRef = useTemplateRef('element')
onMounted(async () => {
// Let the browser calculate the height of the element
templateRef.value.style.height = 'auto'
// Wait for the layout to be recalculated
await nextTick()
// Lock in the height so it doesn't change after this
templateRef.value.style.height = `${templateRef.value.scrollHeight}px`
})
</script>
<template>
<div>
<div ref="element" />
</div>
</template>

I’ll provide a more complex example of a composable that uses all of these techniques at the end of the article, but for now let’s explore some other things we can do with useTemplateRefs.

Advanced Composables with useTemplateRef

Let’s go one step further and apply the Flexible Arguments Pattern so we can choose whether we’ll pass in our own template ref or not:

import { onMounted, useTemplateRef } from 'vue'
export default function useFocus(passedInRef) {
const focusRef = passedInRef || useTemplateRef('focus')
onMounted(() => {
if (!focusRef.value) return
focusRef.value.focus()
})
return focusRef
}

Normally, we’d use ref to make sure we’re always getting a ref in our composable:

// Normal usage of the Flexible Arguments Pattern
export default function useComposable(myRef) {
const internalRef = ref(myRef)
}

But here, we can’t pass a ref into useTemplateRef so we have to use a good old fashioned OR to check and create our own template ref if needed.

We can make this even better though. We can modify our input so we can pass either a string or a ref, so we can control what the key is:

import { onMounted, useTemplateRef, isRef } from 'vue'
export default function useFocus(refOrString) {
let focusRef
if (isRef(refOrString) {
// Use the template ref we've been given
focusRef = refOrString
} else if (typeof refOrString === 'string') {
// Use the string to create our own template ref
focusRef = useTemplateRef(refOrString)
} else {
// Create our own template ref with our own key
focusRef = useTemplateRef('focus')
}
onMounted(() => {
if (!focusRef.value) return
focusRef.value.focus()
})
// Return the template ref
return focusRef
}

This gives us ultimate control over how we use this composable. We can pass in our own template ref:

<script setup>
import useFocus from './useFocus.js'
import { useTemplateRef } from 'vue'
const el = useTemplateRef('some-other-key')
useFocus(el)
</script>
<template>
<div>
<input ref="some-other-key" />
</div>
</template>

Or we can pass in our own string:

<script setup>
import useFocus from './useFocus.js'
import { useTemplateRef } from 'vue'
const key = 'some-other-key'
useFocus(key)
</script>
<template>
<div>
<!-- We can also use a variable with the ref attribute -->
<input :ref="key" />
</div>
</template>

And if we don’t really care, we can use the default ref. But we have to know what string the composable is using as the key:

<script setup>
import useFocus from './useFocus.js'
useFocus()
</script>
<template>
<div>
<input ref="focus" />
</div>
</template>

To solve that, we can update our composable to return the default key. While we’re at it, we can also make sure that it returns whatever key is currently being used, whether we’ve passed in our own or are using the default:

import { onMounted, useTemplateRef, isRef } from 'vue'
const defaultKey = 'focus'
export default function useFocus(refOrString) {
let focusRef
let key = defaultKey
if (isRef(refOrString) {
// Use the template ref we've been given
focusRef = refOrString
} else if (typeof refOrString === 'string') {
// Use the string to create our own template ref
focusRef = useTemplateRef(refOrString)
// Update the key that we're using
key = refOrString
} else {
// Create our own template ref with our own key
focusRef = useTemplateRef(defaultKey)
}
onMounted(() => {
if (!focusRef.value) return
focusRef.value.focus()
})
return {
ref: focusRef,
key,
};
}

Now we don’t have to guess at what the composable will be using as the key:

<script setup>
import useFocus from './useFocus.js'
const { key } = useFocus()
</script>
<template>
<div>
<input :ref="key" />
</div>
</template>

Example: Advanced Composable

Here’s an example composable from Mastering Nuxt that we use to add some scrolling functionality to our AI chat app. It shows and hides a button that is used to scroll to the bottom, and if we’re already at the bottom, keeps us there as new messages come in:

/**
* A composable function that handles chat scroll behavior including:
* - Tracking scroll position
* - Smooth scrolling to bottom
* - Auto-scrolling on new messages
* - Scroll button visibility
*/
export default function useChatScroll() {
// Template refs for accessing DOM elements
const scrollContainer = useTemplateRef<HTMLDivElement>(
'scrollContainer'
)
const textareaRef =
useTemplateRef<HTMLTextAreaElement>('textareaRef')
// Reactive state for tracking scroll position
const isAtBottom = ref(true)
const showScrollButton = ref(false)
/**
* Checks if the chat is scrolled to the bottom
* Considers the chat "at bottom" if within 200px of the bottom
* Updates both isAtBottom and showScrollButton states
*/
const checkScrollPosition = (): void => {
if (scrollContainer.value) {
const { scrollTop, scrollHeight, clientHeight } =
scrollContainer.value
isAtBottom.value =
scrollTop + clientHeight >= scrollHeight - 200
showScrollButton.value = !isAtBottom.value
}
}
/**
* Smoothly scrolls the chat container to the bottom
* @param immediate - If true, scrolls instantly without animation
*/
const scrollToBottom = (immediate = false): void => {
if (!scrollContainer.value) return
// Calculate the target scroll position (bottom of container)
const targetScrollTop =
scrollContainer.value.scrollHeight -
scrollContainer.value.clientHeight
// If immediate scroll requested, do it instantly
if (immediate) {
scrollContainer.value.scrollTop = targetScrollTop
return
}
// Setup for smooth scrolling animation
const startScrollTop = scrollContainer.value.scrollTop
const distance = targetScrollTop - startScrollTop
const duration = 300 // Animation duration in milliseconds
// Animation frame handler for smooth scrolling
const startTime = performance.now()
function step(currentTime: number): void {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Cubic easing function for smooth acceleration/deceleration
const easeInOutCubic =
progress < 0.5
? 4 * progress * progress * progress
: 1 - Math.pow(-2 * progress + 2, 3) / 2
if (scrollContainer.value) {
scrollContainer.value.scrollTop =
startScrollTop + distance * easeInOutCubic
// Continue animation if not complete
if (progress < 1) {
requestAnimationFrame(step)
}
}
}
requestAnimationFrame(step)
}
/**
* Forces scroll to bottom when new messages arrive
* Only scrolls if already at bottom to prevent disrupting user's reading
*/
async function pinToBottom() {
if (isAtBottom.value) {
// Force immediate scroll without animation when messages change
if (scrollContainer.value) {
await nextTick()
scrollContainer.value.scrollTop =
scrollContainer.value.scrollHeight
}
}
}
// Lifecycle hooks
onMounted(() => {
if (scrollContainer.value) {
// Add scroll event listener to track position
scrollContainer.value.addEventListener(
'scroll',
checkScrollPosition
)
nextTick(() => {
scrollToBottom(true) // Initial scroll to bottom
textareaRef.value?.focus() // Focus the input
})
}
})
// Cleanup scroll event listener
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener(
'scroll',
checkScrollPosition
)
}
})
// Check scroll position after any updates
onUpdated(() => {
checkScrollPosition()
})
// Expose necessary functions and state
return {
isAtBottom,
showScrollButton,
scrollToBottom,
textareaRef,
pinToBottom,
}
}

Wrapping Up

I think that useTemplateRef is one of the most interesting features added to Vue recently, and you should really give it a try.

It makes dealing with DOM elements so much nicer, giving us more flexibility and a better developer experience.

You can check out the docs here to learn more, and also learn about the advanced typing you can do with it.

If you enjoyed this article and want to know when I publish more (along with some great tips on using Vue), check out my weekly newsletter on all things Vue and Nuxt.