The Null Check That's Never Actually Null
Every component that drives a GSAP animation has this near the top of <script setup>:
const wrapper = ref<HTMLElement | null>(null)
const heading = ref<HTMLElement | null>(null)
const subtext = ref<HTMLElement | null>(null)
Then in onMounted:
onMounted(() => {
if (!wrapper.value || !heading.value || !subtext.value) return
const tl = gsap.timeline()
tl.fromTo(heading.value, { opacity: 0, y: 30 }, { opacity: 1, y: 0, duration: 0.8 })
tl.fromTo(subtext.value, { opacity: 0 }, { opacity: 1, duration: 0.5 }, '-=0.3')
})
That guard on line 2 — if (!wrapper.value || !heading.value || !subtext.value) return — isn't defending against anything real. By onMounted, those elements exist. You're writing it because TypeScript sees HTMLElement | null and won't let you call methods on the type until you prove it isn't null, not because you've ever actually hit this branch.
Vue 3.5 ships a composable that addresses this directly.
useTemplateRef
useTemplateRef() is a new composable in Vue 3.5. You give it a string that matches a ref attribute in your template, and it returns that element:
import { useTemplateRef, onMounted } from 'vue'
import { gsap } from 'gsap'
const heading = useTemplateRef<HTMLElement>('hero-heading')
onMounted(() => {
if (!heading.value) return
gsap.from(heading.value, { opacity: 0, y: 30, duration: 0.8 })
})
In the template:
<h1 ref="hero-heading">Hello</h1>
The type of heading is Readonly<ShallowRef<HTMLElement | null>>. Still nullable — Vue can't guarantee the element exists before mount. But the Readonly wrapper makes the intent clear: you read this ref, you don't reassign it. The old pattern allowed wrapper.value = someOtherElement by accident, which is never what you want with a template ref.
The Real Improvement: Composables Can Own Their Own Refs
The syntax is tidier, but it's not the main benefit.
With the old pattern, if you want to extract animation logic into a composable, you have a problem. The refs are declared in the component. The animation code needs those refs. Your options:
Option A: Pass the ref as an argument.
// composable
export function useHeroAnimation(headingEl: Ref<HTMLElement | null>) { ... }
// component
const heading = ref<HTMLElement | null>(null)
useHeroAnimation(heading)
This works, but now the composable's type signature is coupled to the component's ref declarations. If you rename the ref or change its type, you update both files.
Option B: Pass the element directly, called inside onMounted.
// component
const heading = ref<HTMLElement | null>(null)
onMounted(() => {
if (heading.value) useHeroAnimation(heading.value)
})
Now the component is managing the lifecycle, which defeats the purpose of extracting the logic.
With useTemplateRef, the composable can declare the DOM dependencies itself:
// composables/useHeroAnimation.ts
import { useTemplateRef, onMounted } from 'vue'
import { gsap } from 'gsap'
export function useHeroAnimation() {
const heading = useTemplateRef<HTMLElement>('hero-heading')
const subtext = useTemplateRef<HTMLElement>('hero-subtext')
onMounted(() => {
if (!heading.value) return
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
tl.from(heading.value, { opacity: 0, y: 40, duration: 0.9 })
if (subtext.value) {
tl.from(subtext.value, { opacity: 0, y: 20, duration: 0.6 }, '-=0.4')
}
})
}
The component that uses this:
<template>
<section>
<h1 ref="hero-heading">Aayush Ali</h1>
<p ref="hero-subtext">Full-stack developer</p>
</section>
</template>
<script setup lang="ts">
import { useHeroAnimation } from '~/composables/useHeroAnimation'
useHeroAnimation()
</script>
The component doesn't pass anything. The composable owns its own lifecycle. The ref="..." attribute in the template is the interface between the two.
useTemplateRef resolves against the component instance that's currently running setup — the one that called the composable. The string 'hero-heading' is scoped to that component's template. Two different components can both call useTemplateRef('wrapper') without collision.
Why the String Binding Is Safer Than You'd Think
The old pattern uses the variable name as an implicit binding:
const wrapper = ref<HTMLElement | null>(null)
<div ref="wrapper">
There's no contract here. The variable wrapper in <script setup> and the attribute value "wrapper" in the template are connected only by matching strings that nothing enforces. Rename the variable, move it into a composable, or mistype the attribute — TypeScript won't tell you. You find out at runtime when .value is null in onMounted and you can't figure out why.
useTemplateRef('wrapper') makes the binding explicit: this returns the element with ref="wrapper" in this component's template. If the element doesn't exist or the attribute is misspelled, the ref is null, and the cause is obvious. The string argument is the interface, not a coincidence.
Conditional Rendering
When an element is behind a v-if, the ref is null while it's absent and non-null while it's present — exactly what you'd expect:
const modal = useTemplateRef<HTMLElement>('modal')
watch(isOpen, (open) => {
if (open && modal.value) {
gsap.fromTo(modal.value, { scale: 0.9, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.25 })
}
})
You still need the null check inside the watcher because isOpen can become true before Vue has mounted the conditional element in the same tick. That check is meaningful — it's not just TypeScript noise.
When to Stick With the Old Pattern
If the ref is only used inside the component that declares it, the old pattern is fine. The useTemplateRef improvement is most visible when animation logic moves into composables that need direct DOM access.
For component refs — referencing a child component's exposed API — useTemplateRef works the same way:
const carousel = useTemplateRef<InstanceType<typeof CarouselComponent>>('carousel')
onMounted(() => {
carousel.value?.play()
})
Same ergonomics, same readonly guarantee.
Vue 3.5 shipped useTemplateRef alongside reactivity props destructuring and useId. Most posts about Vue 3.5 lead with props destructuring. useTemplateRef gets a footnote. If you're building components with complex DOM interactions — scroll animations, canvas work, anything that reaches past the template layer — it's the change that shows up most in day-to-day code.
The portfolio you're on now uses GSAP for the About section's horizontal reveal, the hero parallax, and the projects entrance animations. The pattern above — composables that declare their own refs via useTemplateRef — is exactly how the animation setup is structured. The component doesn't care how the animation works. The composable doesn't depend on the component passing anything in. The ref string in the template is the whole contract.