Element Reference

March 11, 2024

7,008 words

Post contents

While React, Angular, and Vue all provide simple built-in APIs to access events, inputs, and other bindings to underlying HTML elements; sometimes it's just not enough.

For example, let's say that we want to build a right-click menu so that a user can see a relevant list of actions associated with the file the user is currently hovering over:

When the user right-clicks it shows a context menu of options like "Cut", "Copy", and "Paste"

We're able to build some of this functionality with the APIs we've covered thus far, namely, we can:

  • Using our framework's event binding to listen to the browser's contextmenu event which lets us know when the user has right-clicked
  • Conditionally rendering the context menu's elements until relevant
  • Binding the style attribute to position the popup's x and y value
/** * This code sample is inaccessible and generally not * production-grade. It's missing: * - Focus on menu open * - Closing upon external click * * Read on to learn how to add these */function App() {	const [mouseBounds, setMouseBounds] = useState({		x: 0,		y: 0,	});	const [isOpen, setIsOpen] = useState(false);	function onContextMenu(e) {		e.preventDefault();		setIsOpen(true);		setMouseBounds({			// Mouse position on click			x: e.clientX,			y: e.clientY,		});	}	return (		<>			<div style={{ marginTop: "5rem", marginLeft: "5rem" }}>				<div onContextMenu={onContextMenu}>Right click on me!</div>			</div>			{isOpen && (				<div					style={{						position: "fixed",						top: `${mouseBounds.y}px`,						left: `${mouseBounds.x}px`,						background: "white",						border: "1px solid black",						borderRadius: 16,						padding: "1rem",					}}				>					<button onClick={() => setIsOpen(false)}>X</button>					This is a context menu				</div>			)}		</>	);}
/** * This code sample is inaccessible and generally not * production-grade. It's missing: * - Focus on menu open * - Closing upon external click * * Read on to learn how to add these */@Component({	selector: "app-root",	standalone: true,	template: `		<div style="margin-top: 5rem; margin-left: 5rem">			<div (contextmenu)="open($event)">Right click on me!</div>		</div>		@if (isOpen) {			<div				[style]="					'		  position: fixed;		  top: ' +					mouseBounds.y +					'px;		  left: ' +					mouseBounds.x +					'px;		  background: white;		  border: 1px solid black;		  border-radius: 16px;		  padding: 1rem;		'				"			>				<button (click)="close()">X</button>				This is a context menu			</div>		}	`,})class AppComponent {	isOpen = false;	mouseBounds = {		x: 0,		y: 0,	};	close() {		this.isOpen = false;	}	open(e: MouseEvent) {		e.preventDefault();		this.isOpen = true;		this.mouseBounds = {			// Mouse position on click			x: e.clientX,			y: e.clientY,		};	}}
<!-- App.vue --><!-- This code sample is inaccessible and generally not --><!--  production-grade. It's missing:                   --><!--  - Focus on menu open                              --><!--  - Closing upon external click                     --><!--                                                    --><!--  Read on to learn how to add these                 --><script setup>import { ref } from "vue";const isOpen = ref(false);const mouseBounds = ref({	x: 0,	y: 0,});const close = () => {	isOpen.value = false;};const open = (e) => {	e.preventDefault();	isOpen.value = true;	mouseBounds.value = {		// Mouse position on click		x: e.clientX,		y: e.clientY,	};};</script><template>	<div style="margin-top: 5rem; margin-left: 5rem">		<div @contextmenu="open($event)">Right click on me!</div>	</div>	<div		v-if="isOpen"		:style="`      position: fixed;      top: ${mouseBounds.y}px;      left: ${mouseBounds.x}px;      background: white;      border: 1px solid black;      border-radius: 16px;      padding: 1rem;    `"	>		<button @click="close()">X</button>		This is a context menu	</div></template>

This works relatively well until we think about two features that are missing:

  • Listening for any click outside the popup's contents
  • Focusing on the popup's contents when the user right-clicks, keyboard shortcuts apply to the popup immediately

While these features are possible without any newly introduced APIs, they'd both require you to use browser APIs such as document.querySelector to eject away from the framework's limitations.

In those rare events, you want to eject away from the framework controlling your access to HTML nodes; each framework enables you to access the underlying DOM nodes without using browser APIs specifically. This allows our code to still retain full control over the underlying elements while remaining within the reactivity systems these frameworks provide.

In this chapter, we'll learn:

  • How to reference the underlying DOM element
  • How to reference an array of elements
  • Adding focus and external click listening to the context menu
  • A code challenge to re-enforce knowledge

Basic Element References



In React, there's no simpler demonstration of an element reference than passing a function to an element's ref property.

const RenderParagraph = () => {	// el is HTMLElement	return <p ref={(el) => console.log({ el: el })}>Check your console</p>;};

In this example, once the paragraph tags renders, it will console.log the underlying HTML DOM node.

You may be wondering where the ref property has come from, since it's not a known HTMLElement property. This is because ref is a reserved property by React for this special case.

Knowing that we can pass a function to gain access to the HTML DOM node, we can pass a function that adds an event listener to the HTML element.

const RenderButton = () => {	// el is HTMLElement	const addClickListener = (el) => {		el.addEventListener("click", () => {			alert("User has clicked!");		});	};	return <button ref={addClickListener}>Click me!</button>;};

This is just used as an example of what you can do with the underlying HTML element. While there are perfectly valid reasons for using ref to addEventListener (we'll touch on one such case later on), it's usually suggested to use onClick style event bindings instead.

useState refs

However, this is a problem because our addEventListener is never cleaned up! Remember, this is part of the API that useEffect provides.

As a result, let's store the value of el into a useState, then pass that value into a useEffect, which will then add the event listener:

const CountButton = () => {	const [count, setCount] = useState(0);	const [buttonEl, setButtonEl] = useState();	const storeButton = (el) => {		setButtonEl(el);	};	useEffect(() => {		// Initial render will not have `buttonEl` defined, subsequent renders will		if (!buttonEl) return;		const clickFn = () => {			/**			 * We need to use `v => v + 1` instead of `count + 1`, otherwise `count` will			 * be stale and not update further than `1`. More details in the next paragraph.			 */			setCount((v) => v + 1);		};		buttonEl.addEventListener("click", clickFn);		return () => {			buttonEl.removeEventListener("click", clickFn);		};	}, [buttonEl]);	return (		<>			<button ref={storeButton}>Add one</button>			<p>Count is {count}</p>		</>	);};

Once again: You should be using onClick to bind a method, this is only to demonstrate how element refs work

You'll notice in this example that within our useEffect, we're using a function to update setCount. This is because otherwise, we will run into a "Stale Closure", which means that our count value will never update past 1.

Why Aren't We Using useRef?

If you think back to an earlier chapter in the book, "Side Effects", you may remember our usage of a hook called "useRef". Sensibly, based on the name, it's very commonly used with an element's ref property. In fact, it's so commonly used to store an element's reference that it even has a shorthand:

const App = () => {	const divRef = useRef();	// Ta-da! No need to pass a function when using `useRef` and `ref` together	return <div ref={divRef} />;};

Knowing this, why aren't we using useRef in the previous button counter example? Well, the answer goes back to the "Side Effects" chapter once again. Back in the said chapter, we explained how useRef doesn't trigger useEffects as one might otherwise expect.

Let's look at how using an element reference using useRef could cause havoc when binding an event via addEventListener. Here, we can see an example of what useRef might look like in our CountButton example:

const CountButton = () => {	const [count, setCount] = useState(0);	const buttonRef = useRef();	useEffect(() => {		const clickFn = () => {			setCount((v) => v + 1);		};		buttonRef.current.addEventListener("click", clickFn);		return () => {			buttonRef.current.removeEventListener("click", clickFn);		};		// Do not use a useRef inside of a useEffect like this, it will likely cause bugs	}, [buttonRef.current]);	return (		<>			<button ref={buttonRef}>Add one</button>			<p>Count is {count}</p>		</>	);};

This works as we would expect because buttonRef is defined before the first run of useEffect. However, let's add a short delay to the button's rendering. We can do this using a setTimeout and another useEffect:

// This code intentionally doesn't work to demonstrate how `useRef`//  might not work with `useEffect` as you might thinkconst CountButton = () => {	const [count, setCount] = useState(0);	const buttonRef = useRef();	const [showButton, setShowButton] = useState(false);	useEffect(() => {		const interval = setInterval(() => {			setShowButton(true);		}, 1000);		return () => clearInterval(interval);	}, []);	useEffect(() => {		const clickFn = () => {			setCount((v) => v + 1);		};		if (!buttonRef.current) return;		buttonRef.current.addEventListener("click", clickFn);		return () => {			buttonRef.current.removeEventListener("click", clickFn);		};	}, [buttonRef.current]);	return (		<>			{showButton && <button ref={buttonRef}>Add one</button>}			<p>Count is {count}</p>		</>	);};

Now, if we wait the second it takes to render the <button>Add one</button> element and press the button, we'll see that our click event handler is never set properly.

This is because buttonRef.current is set to undefined in the first render, and the mutation of buttonRef when the <button> element is rendered does not trigger a re-render, which in turn does not re-run useEffect to add the event binding.

This is not to say that you shouldn't use useRef for element reference, just that you should be aware of its downfalls and alternatives.

We'll see some usage of the ref property with useRef in a bit.

Using ViewChild, we can access an HTMLElement that's within an Angular component's template:

@Component({	selector: "paragraph-tag",	standalone: true,	template: `<p #pTag>Hello, world!</p>`,})class RenderParagraphComponent {	@ViewChild("pTag") pTag!: ElementRef<HTMLElement>;}

You may notice that our <p> tag has an attribute prefixed with a pound sign (#). This pound-sign prefixed attribute allows Angular to associate the element with a "template reference variable," which can then be referenced inside our ViewChild to gain access to an element.

For example, the #pTag attribute assigns the template reference variable named "pTag" to the <p> element and allows ViewChild to find that element based on the variable's name.


Now that we have access to the underlying <p> element let's print it out inside a ngOnInit:

@Component({	selector: "paragraph-tag",	standalone: true,	template: `<p #pTag>Hello, world!</p>`,})class RenderParagraphComponent implements OnInit {	@ViewChild("pTag") pTag!: ElementRef<HTMLElement>;	ngOnInit() {		// This will show `undefined`		alert(this.pTag);	}}

Why does this log as undefined? How do we fix this?

Well, let's think about the following example:

@Component({	selector: "paragraph-tag",	standalone: true,	template: `		@if (true) {			<p #pTag>Hello, world!</p>		}	`,})class RenderParagraphComponent implements OnInit {	@ViewChild("pTag") pTag!: ElementRef<HTMLElement>;	ngOnInit() {		// This will still show `undefined`		alert(this.pTag);	}}

Here, we're conditionally rendering our p tag using an @if. But see, under the hood, @if won't initialize the <p> tag until after the ngOnInit lifecycle method is executed.

To solve this, we can do one of two things:

  1. Tell Angular that our code doesn't contain any dynamic HTML (IE: No @ifs or @fors)
  2. Use a different lifecycle method that occurs after ngOnInit.
Using {static: true} to Use ViewChild Immediately

To tell Angular that there is no dynamic HTML, and it should immediately query for the elements, you can use the {static: true} property on ViewChild:

@Component({	selector: "paragraph-tag",	standalone: true,	template: ` <p #pTag>Hello, world!</p> `,})class RenderParagraphComponent implements OnInit {	@ViewChild("pTag", { static: true }) pTag!: ElementRef<HTMLElement>;	ngOnInit() {		// This will log the HTML element		console.log(this.pTag.nativeElement);	}}

However, keep in mind that if you do later add any dynamic HTML our element will be undefined once again:

@Component({	selector: "paragraph-tag",	standalone: true,	template: `		@if (true) {			<p #pTag>Hello, world!</p>		}	`,})class RenderParagraphComponent implements OnInit {	@ViewChild("pTag", { static: true }) pTag!: ElementRef<HTMLElement>;	ngOnInit() {		// This will log `undefined`		console.log(this.pTag);	}}

To solve this, we'll have to use a different lifecycle method than ngOnInit.

Using ngAfterViewInit to Use a Deferred ViewChild

While the values of a dynamic HTML may not be defined in ngOnInit, there is a different lifecycle method to be called when Angular has fully initialized all the child values of your dynamic HTML: ngAfterViewInit.

import { AfterViewInit, Component, ElementRef, ViewChild } from "@angular/core";@Component({	selector: "paragraph-tag",	standalone: true,	template: `		@if (true) {			<p #pTag>Hello, world!</p>		}	`,})class RenderParagraphComponent implements AfterViewInit {	@ViewChild("pTag") pTag!: ElementRef<HTMLElement>;	ngAfterViewInit() {		console.log(this.pTag.nativeElement);	}}
Adding an Event Listener Using @ViewChild

Now that we know how to use ViewChild, we can add an addEventListener and removeEventListener to manually bind a button's click event:

@Component({	selector: "paragraph-tag",	standalone: true,	template: `		<button #btn>Add one</button>		<p>Count is {{ count }}</p>	`,})class RenderParagraphComponent implements AfterViewInit, OnDestroy {	@ViewChild("btn") btn!: ElementRef<HTMLElement>;	count = 0;	addOne = () => {		this.count++;	};	ngAfterViewInit() {		this.btn.nativeElement.addEventListener("click", this.addOne);	}	ngOnDestroy() {		this.btn.nativeElement.removeEventListener("click", this.addOne);	}}

Remember, the addOne function cannot be a class method, as otherwise it will not clean up inside the removeEventListener properly.

Vue's ability to store reactive data using ref enables a super simplistic API to access DOM nodes and create a ref with the same variable name as a ref property of an element's ref attribute value.

<!-- App.vue --><script setup>import { ref, onMounted } from "vue";// Assign the refconst el = ref();// Use the refonMounted(() => {	console.log(el);});</script><template>	<p ref="el">Check your console</p></template>

Here, el.value points to an HTMLElement of the p tag within template.

Vue also allows you to pass a function to ref in order to run a function when the ref is being set, like so:

<!-- App.vue --><script setup>function logEl(el) {	console.log(el);}</script><template>	<p :ref="logEl">Check your console</p></template>

How to Keep an Array of Element References

Let's say that we're building an email application and want to provide the user a button that scrolls them to the top of their messages quickly.

A mockup of a mail application that has a button to scroll to the top of the messages list

One way of building out this button is to store each underlying message's DOM element in the array into an element reference then use the top and bottom elements' scrollIntoView method to bring them onto the page visually.

Let's see how that's done with each framework.

React's ability to persist data within a useRef allows us to create an index-based array to store our elements into.

Using this array, we can then access the 0th and last index (using messages.length - 1) to indicate the first and last element, respectively.

const messages = [	"The new slides for the design keynote look wonderful!",	"Some great new colours are planned to debut with Material Next!",	"Hey everyone! Please take a look at the resources I’ve attached.",	"So on Friday we were thinking about going through that park you’ve recommended.",	"We will discuss our upcoming Pixel 6 strategy in the following week.",	"On Thursday we drew some great new ideas for our talk.",	"So the design teams got together and decided everything should be made of sand.",];function App() {	const messagesRef = useRef([]);	const scrollToTop = () => {		messagesRef.current[0].scrollIntoView();	};	const scrollToBottom = () => {		messagesRef.current[messagesRef.current.length - 1].scrollIntoView();	};	return (		<div>			<button onClick={scrollToTop}>Scroll to top</button>			<ul style={{ height: "50px", overflow: "scroll" }}>				{messages.map((message, i) => {					return (						<li key={i} ref={(el) => (messagesRef.current[i] = el)}>							{message}						</li>					);				})}			</ul>			<button onClick={scrollToBottom}>Scroll to bottom</button>		</div>	);}

Just as there is a ViewChild to gain access to a single underlying HTML element, you can also use a ViewChildren to access more than one or more template elements using similar APIs.

Using ViewChildren, we can access in-template variables (prefixed with #) to scrollIntoView the first and last elements.

@Component({	selector: "app-root",	standalone: true,	template: `		<div>			<button (click)="scrollToTop()">Scroll to top</button>			<ul style="height: 100px; overflow: scroll">				@for (message of messages; track message) {					<!-- Create a new template variable called listItem -->					<!-- for each item in the `messages` array -->					<li #listItem>{{ message }}</li>				}			</ul>			<button (click)="scrollToBottom()">Scroll to bottom</button>		</div>	`,})class AppComponent {	// Reference the template variable `listItem`	@ViewChildren("listItem") els!: QueryList<ElementRef<HTMLElement>>;	scrollToTop() {		this.els.get(0)!.nativeElement.scrollIntoView();	}	scrollToBottom() {		this.els.get(this.els.length - 1)!.nativeElement.scrollIntoView();	}	messages = [		"The new slides for the design keynote look wonderful!",		"Some great new colours are planned to debut with Material Next!",		"Hey everyone! Please take a look at the resources I’ve attached.",		"So on Friday we were thinking about going through that park you’ve recommended.",		"We will discuss our upcoming Pixel 6 strategy in the following week.",		"On Thursday we drew some great new ideas for our talk.",		"So the design teams got together and decided everything should be made of sand.",	];}

Vue has a handy feature that enables you to create an array of referenced elements using nothing more than a string inside a ref attribute. This then turns the ref of the same name into an array that we can access as expected.

<!-- App.vue --><script setup>import { ref } from "vue";const items = ref([]);function scrollToTop() {	items.value[0].scrollIntoView();}function scrollToBottom() {	items.value[items.value.length - 1].scrollIntoView();}const messages = [	"The new slides for the design keynote look wonderful!",	"Some great new colours are planned to debut with Material Next!",	"Hey everyone! Please take a look at the resources I’ve attached.",	"So on Friday we were thinking about going through that park you’ve recommended.",	"We will discuss our upcoming Pixel 6 strategy in the following week.",	"On Thursday we drew some great new ideas for our talk.",	"So the design teams got together and decided everything should be made of sand.",];</script><template>	<div>		<button @click="scrollToTop()">Scroll to top</button>		<ul style="height: 100px; overflow: scroll">			<li v-for="(message, i) of messages" :key="i" ref="items">				{{ message }}			</li>		</ul>		<button @click="scrollToBottom()">Scroll to bottom</button>	</div></template>

Real World Usage

Now that we know how to access an underlying HTML element in our given framework let's go back to our previous context menu example from the start of the chapter.

See, while our context menu was able to show properly, we were missing two distinct features:

  1. Focusing the dropdown element when opened
  2. Closing the context menu when the user clicks elsewhere
With the context menu open, when you left-click outside of the bounds of the context menu, it will close it

Let's add this functionality to our context menu component.

To add the first feature, we'll focus on the context menu using element.focus() in order to make sure that keyboard users aren't lost when trying to use the feature.

To add the second feature, let's:

This code in vanilla JavaScript might look something like this:

<button id="clickInside">	If you click outside of this button, it will hide</button><script>	const clickInsideButton = document.querySelector("#clickInside");	function listenForOutsideClicks(e) {		// This check is saying "`true` if the clicked element is a child of the 'clickInside' button"		const isClickInside = clickInsideButton.contains(e.target);		if (isClickInside) return;		// Hide the button using CSS. In frameworks, we'd use conditional rendering.		clickInsideButton.style.display = "none";	}	document.addEventListener("click", listenForOutsideClicks);</script>

Let's port this logic to React, Angular, and Vue:

Let's add a ref usage that stores our contextMenu inside of a useState.

Then, when we change the value of contextMenu, we can .focus the element and use the addEventListener code from above:

function App() {	const [mouseBounds, setMouseBounds] = useState({		x: 0,		y: 0,	});	const [isOpen, setIsOpen] = useState(false);	function onContextMenu(e) {		e.preventDefault();		setIsOpen(true);		setMouseBounds({			x: e.clientX,			y: e.clientY,		});	}	const [contextMenu, setContextMenu] = useState();	useEffect(() => {		if (contextMenu) {			contextMenu.focus();		}	}, [contextMenu]);	useEffect(() => {		if (!contextMenu) return;		const closeIfOutsideOfContext = (e) => {			const isClickInside = contextMenu.contains(e.target);			if (isClickInside) return;			setIsOpen(false);		};		document.addEventListener("click", closeIfOutsideOfContext);		return () => document.removeEventListener("click", closeIfOutsideOfContext);	}, [contextMenu]);	return (		<>			<div style={{ marginTop: "5rem", marginLeft: "5rem" }}>				<div onContextMenu={onContextMenu}>Right click on me!</div>			</div>			{isOpen && (				<div					ref={(el) => setContextMenu(el)}					tabIndex={0}					style={{						position: "fixed",						top: mouseBounds.y,						left: mouseBounds.x,						background: "white",						border: "1px solid black",						borderRadius: 16,						padding: "1rem",					}}				>					<button onClick={() => setIsOpen(false)}>X</button>					This is a context menu				</div>			)}		</>	);}

We can adopt the above code and place it within our ngAfterViewInit lifecycle method to add the addEventListener to our context menu.

Additionally, we'll use ViewChild to track the contextMenu element and .focus it when it becomes active.

We need to use a setTimeout in our open method to make sure the HTML element renders before our focus call.

@Component({	selector: "app-root",	standalone: true,	template: `		<div style="margin-top: 5rem; margin-left: 5rem">			<div (contextmenu)="open($event)">Right click on me!</div>		</div>		@if (isOpen) {			<div				tabIndex="0"				#contextMenu				[style]="					'		  position: fixed;		  top: ' +					mouseBounds.y +					'px;		  left: ' +					mouseBounds.x +					'px;		  background: white;		  border: 1px solid black;		  border-radius: 16px;		  padding: 1rem;		'				"			>				<button (click)="close()">X</button>				This is a context menu			</div>		}	`,})class AppComponent implements AfterViewInit, OnDestroy {	@ViewChild("contextMenu") contextMenu!: ElementRef<HTMLElement>;	isOpen = false;	mouseBounds = {		x: 0,		y: 0,	};	closeIfOutsideOfContext = (e: MouseEvent) => {		const contextMenuEl = this.contextMenu?.nativeElement;		if (!contextMenuEl) return;		const isClickInside = contextMenuEl.contains(e.target as HTMLElement);		if (isClickInside) return;		this.isOpen = false;	};	ngAfterViewInit() {		document.addEventListener("click", this.closeIfOutsideOfContext);	}	ngOnDestroy() {		document.removeEventListener("click", this.closeIfOutsideOfContext);	}	close() {		this.isOpen = false;	}	open(e: MouseEvent) {		e.preventDefault();		this.isOpen = true;		this.mouseBounds = {			x: e.clientX,			y: e.clientY,		};		// Wait until the element is rendered before focusing it		setTimeout(() => {			this.contextMenu.nativeElement.focus();		}, 0);	}}

Let's adopt the above click listener and apply it within our onMounted lifecycle method.

We'll also use a callback ref to run a function every time the context menu is open. This function will then either do nothing or call .focus on the element depending on whether it's rendered or not.

<!-- App.vue --><script setup>import { ref, onMounted, onUnmounted } from "vue";const isOpen = ref(false);const mouseBounds = ref({	x: 0,	y: 0,});const contextMenuRef = ref(null);function closeIfOutside(e) {	const contextMenuEl = contextMenuRef.value;	if (!contextMenuEl) return;	const isClickInside = contextMenuEl.contains(e.target);	if (isClickInside) return;	isOpen.value = false;}onMounted(() => {	document.addEventListener("click", closeIfOutside);});onUnmounted(() => {	document.removeEventListener("click", closeIfOutside);});const close = () => {	isOpen.value = false;};const open = (e) => {	e.preventDefault();	isOpen.value = true;	mouseBounds.value = {		x: e.clientX,		y: e.clientY,	};};function focusOnOpen(el) {	contextMenuRef.value = el;	if (!el) return;	el.focus();}</script><template>	<div style="margin-top: 5rem; margin-left: 5rem">		<div @contextmenu="open($event)">Right click on me!</div>	</div>	<div		v-if="isOpen"		:ref="(el) => focusOnOpen(el)"		tabIndex="0"		:style="`      position: fixed;      top: ${mouseBounds.y}px;      left: ${mouseBounds.x}px;      background: white;      border: 1px solid black;      border-radius: 16px;      padding: 1rem;    `"	>		<button @click="close()">X</button>		This is a context menu	</div></template>

Challenge

Let's build out a fresh component from our understanding of element reference.

Specifically, let's build out tooltip functionality so that when the user hovers over a button for a second or longer, it displays a popup message to help the user understand how it's used.

Hovering over a "send" button will show an alert above the button saying, "This will send an email to the recipients."

To do this, we'll need to consider a few things:

  1. How to track when the user has hovered over an element for a second or longer
  2. How to remove the popup when the user has moved their mouse
  3. Make sure the tooltip is positioned above the button
  4. Make sure the tooltip is horizontally centered
  5. Adding any necessary polish

Step 1: Track When the User Has Hovered an Element

To track when an element is being hovered, we can use the mouseover HTML event.

To make sure the user has been hovering for at least 1 second, we can add a setTimeout to delay the display of the tooltip.

Remember to clean up the setTimeout when the component is unrendered!

function App() {	const buttonRef = useRef();	const mouseOverTimeout = useRef();	const [tooltipMeta, setTooltipMeta] = useState({		show: false,	});	const onMouseOver = () => {		mouseOverTimeout.current = setTimeout(() => {			setTooltipMeta({				show: true,			});		}, 1000);	};	useEffect(() => {		return () => {			clearTimeout(mouseOverTimeout.current);		};	}, []);	return (		<div style={{ padding: "10rem" }}>			<button onMouseOver={onMouseOver} ref={buttonRef}>				Send			</button>			{tooltipMeta.show && <div>This will send an email to the recipients</div>}		</div>	);}
@Component({	selector: "app-root",	standalone: true,	template: `		<div style="padding: 10rem">			<button #buttonRef (mouseover)="onMouseOver()">Send</button>			@if (tooltipMeta.show) {				<div>					This will send an email to the recipients				</div>			}		</div>	`,})class AppComponent implements OnDestroy {	@ViewChild("buttonRef") buttonRef!: ElementRef<HTMLElement>;	tooltipMeta = {		show: false,	};	mouseOverTimeout: any = null;	onMouseOver() {		this.mouseOverTimeout = setTimeout(() => {			this.tooltipMeta = {				show: true,			};		}, 1000);	}	ngOnDestroy() {		clearTimeout(this.mouseOverTimeout);	}}
<!-- App.vue --><script setup>import { ref, onUnmounted } from "vue";const buttonRef = ref();const mouseOverTimeout = ref(null);const tooltipMeta = ref({	show: false,});const onMouseOver = () => {	mouseOverTimeout.value = setTimeout(() => {		tooltipMeta.value = {			show: true,		};	}, 1000);};onUnmounted(() => {	clearTimeout(mouseOverTimeout.current);});</script><template>	<div style="padding: 10rem">		<button ref="buttonRef" @mouseover="onMouseOver()">Send</button>		<div v-if="tooltipMeta.show">This will send an email to the recipients</div>	</div></template>

Step 2: Remove the Element When the User Stops Hovering

Now that we have our tooltip showing up when we'd expect it, let's remove it when we stop hovering on the button element.

To do this, we'll use the mouseleave HTML event to set show to false and cancel the timer to show the tooltip if the event is active.

function App() {	const buttonRef = useRef();	const mouseOverTimeout = useRef();	const [tooltipMeta, setTooltipMeta] = useState({		show: false,	});	const onMouseOver = () => {		mouseOverTimeout.current = setTimeout(() => {			setTooltipMeta({				show: true,			});		}, 1000);	};	const onMouseLeave = () => {		setTooltipMeta({			show: false,		});		clearTimeout(mouseOverTimeout.current);	};	useEffect(() => {		return () => {			clearTimeout(mouseOverTimeout.current);		};	}, []);	return (		<div style={{ padding: "10rem" }}>			<button				onMouseOver={onMouseOver}				onMouseLeave={onMouseLeave}				ref={buttonRef}			>				Send			</button>			{tooltipMeta.show && <div>This will send an email to the recipients</div>}		</div>	);}
@Component({	selector: "app-root",	standalone: true,	template: `		<div style="padding: 10rem">			<button				#buttonRef				(mouseover)="onMouseOver()"				(mouseleave)="onMouseLeave()"			>				Send			</button>			@if (tooltipMeta.show) {				<div>					This will send an email to the recipients				</div>			}		</div>	`,})class AppComponent implements OnDestroy {	@ViewChild("buttonRef") buttonRef!: ElementRef<HTMLElement>;	tooltipMeta = {		show: false,	};	mouseOverTimeout: any = null;	onMouseOver() {		this.mouseOverTimeout = setTimeout(() => {			this.tooltipMeta = {				show: true,			};		}, 1000);	}	onMouseLeave() {		this.tooltipMeta = {			show: false,		};		clearTimeout(this.mouseOverTimeout);	}	ngOnDestroy() {		clearTimeout(this.mouseOverTimeout);	}}
<!-- App.vue --><script setup>import { ref, onUnmounted } from "vue";const buttonRef = ref();const mouseOverTimeout = ref(null);const tooltipMeta = ref({	show: false,});const onMouseOver = () => {	mouseOverTimeout.value = setTimeout(() => {		tooltipMeta.value = {			show: true,		};	}, 1000);};const onMouseLeave = () => {	tooltipMeta.value = {		show: false,	};	clearTimeout(mouseOverTimeout.current);};onUnmounted(() => {	clearTimeout(mouseOverTimeout.current);});</script><template>	<div style="padding: 10rem">		<button			ref="buttonRef"			@mouseover="onMouseOver()"			@mouseleave="onMouseLeave()"		>			Send		</button>		<div v-if="tooltipMeta.show">This will send an email to the recipients</div>	</div></template>

Step 3: Placing the Tooltip above the Button

To place the tooltip above the button, we'll measure the button's position, height, and width using an element reference and the HTMLElement's method of getBoundingClientRect.

We'll then use this positional data alongside the CSS position: fixed to position the tooltip to be placed 8px above the y axis of the button:

function App() {	const buttonRef = useRef();	const mouseOverTimeout = useRef();	const [tooltipMeta, setTooltipMeta] = useState({		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	});	const onMouseOver = () => {		mouseOverTimeout.current = setTimeout(() => {			const bounding = buttonRef.current.getBoundingClientRect();			setTooltipMeta({				x: bounding.x,				y: bounding.y,				height: bounding.height,				width: bounding.width,				show: true,			});		}, 1000);	};	const onMouseLeave = () => {		setTooltipMeta({			x: 0,			y: 0,			height: 0,			width: 0,			show: false,		});		clearTimeout(mouseOverTimeout.current);	};	useEffect(() => {		return () => {			clearTimeout(mouseOverTimeout.current);		};	}, []);	return (		<div style={{ padding: "10rem" }}>			{tooltipMeta.show && (				<div					style={{						position: "fixed",						top: `${tooltipMeta.y - tooltipMeta.height - 8}px`,					}}				>					This will send an email to the recipients				</div>			)}			<button				onMouseOver={onMouseOver}				onMouseLeave={onMouseLeave}				ref={buttonRef}			>				Send			</button>		</div>	);}
@Component({	selector: "app-root",	standalone: true,	template: `		<div style="padding: 10rem">			@if (tooltipMeta.show) {				<div					[style]="						'			position: fixed;			top: ' +						(tooltipMeta.y - tooltipMeta.height - 8) +						'px;		  '					"				>					This will send an email to the recipients				</div>				<button					#buttonRef					(mouseover)="onMouseOver()"					(mouseleave)="onMouseLeave()"				>					Send				</button>			</div>		}	`,})class AppComponent implements OnDestroy {	@ViewChild("buttonRef") buttonRef!: ElementRef<HTMLElement>;	tooltipMeta = {		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	};	mouseOverTimeout: any = null;	onMouseOver() {		this.mouseOverTimeout = setTimeout(() => {			const bounding = this.buttonRef.nativeElement.getBoundingClientRect();			this.tooltipMeta = {				x: bounding.x,				y: bounding.y,				height: bounding.height,				width: bounding.width,				show: true,			};		}, 1000);	}	onMouseLeave() {		this.tooltipMeta = {			x: 0,			y: 0,			height: 0,			width: 0,			show: false,		};		clearTimeout(this.mouseOverTimeout);	}	ngOnDestroy() {		clearTimeout(this.mouseOverTimeout);	}}
<!-- App.vue --><script setup>import { ref, onUnmounted } from "vue";const buttonRef = ref();const mouseOverTimeout = ref(null);const tooltipMeta = ref({	x: 0,	y: 0,	height: 0,	width: 0,	show: false,});const onMouseOver = () => {	mouseOverTimeout.value = setTimeout(() => {		const bounding = buttonRef.value.getBoundingClientRect();		tooltipMeta.value = {			x: bounding.x,			y: bounding.y,			height: bounding.height,			width: bounding.width,			show: true,		};	}, 1000);};const onMouseLeave = () => {	tooltipMeta.value = {		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	};	clearTimeout(mouseOverTimeout.current);};// Just in caseonUnmounted(() => {	clearTimeout(mouseOverTimeout.current);});</script><template>	<div style="padding: 10rem">		<div			v-if="tooltipMeta.show"			:style="`        position: fixed;        top: ${tooltipMeta.y - tooltipMeta.height - 8}px;      `"		>			This will send an email to the recipients		</div>		<button			ref="buttonRef"			@mouseover="onMouseOver()"			@mouseleave="onMouseLeave()"		>			Send		</button>	</div></template>

Step 4: Centering the Tooltip Horizontally

To center a position: fixed element is a challenge and a half. While there's half a dozen ways we could go about this, we're going to opt for a solution that involves:

  • Creating a <div> with the same width as the button
  • Making this <div> a display: flex element with justify-content: center CSS applied to center all children
  • Allowing overflow inside the div using overflow: visible
  • Placing our tooltip's text inside the <div> with white-space: nowrap applied to avoid our text wrapping to meet the <div> width.

This works because the <div>'s position should mirror the button's and allow content to be centered around it, like so:

The divs position is above the button, demonstrated by the dev tools preview of the div's position

In the end, our styling should look something like this HTML markup:

<div style="padding: 10rem">	<!-- The PX values here may differ on your system -->	<div		style="      display: flex;      overflow: visible;      justify-content: center;      width: 40.4667px;      position: fixed;      top: 138.8px;      left: 168px;    "	>		<div style="white-space: nowrap">			This will send an email to the recipients		</div>	</div>	<button>Send</button></div>

Let's implement this within our frameworks:

function App() {	const buttonRef = useRef();	const mouseOverTimeout = useRef();	const [tooltipMeta, setTooltipMeta] = useState({		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	});	const onMouseOver = () => {		mouseOverTimeout.current = setTimeout(() => {			const bounding = buttonRef.current.getBoundingClientRect();			setTooltipMeta({				x: bounding.x,				y: bounding.y,				height: bounding.height,				width: bounding.width,				show: true,			});		}, 1000);	};	const onMouseLeave = () => {		setTooltipMeta({			x: 0,			y: 0,			height: 0,			width: 0,			show: false,		});		clearTimeout(mouseOverTimeout.current);	};	useEffect(() => {		return () => {			clearTimeout(mouseOverTimeout.current);		};	}, []);	return (		<div style={{ padding: "10rem" }}>			{tooltipMeta.show && (				<div					style={{						overflow: "visible",						position: "fixed",						top: `${tooltipMeta.y - tooltipMeta.height - 8}px`,						display: "flex",						justifyContent: "center",						width: `${tooltipMeta.width}px`,						left: `${tooltipMeta.x}px`,					}}				>					<div						style={{							whiteSpace: "nowrap",						}}					>						This will send an email to the recipients					</div>				</div>			)}			<button				onMouseOver={onMouseOver}				onMouseLeave={onMouseLeave}				ref={buttonRef}			>				Send			</button>		</div>	);}
@Component({	selector: "app-root",	standalone: true,	template: `		<div style="padding: 10rem">			@if (tooltipMeta.show) {				<div					[style]="						'			display: flex;			overflow: visible;			justify-content: center;			width: ' +						tooltipMeta.width +						'px;			position: fixed;			top: ' +						(tooltipMeta.y - tooltipMeta.height - 8) +						'px;			left: ' +						tooltipMeta.x +						'px;		  '					"				>					<div						style="			  white-space: nowrap;			"					>						This will send an email to the recipients					</div>				</div>			}			<button				#buttonRef				(mouseover)="onMouseOver()"				(mouseleave)="onMouseLeave()"			>				Send			</button>		</div>	`,})class AppComponent implements OnDestroy {	@ViewChild("buttonRef") buttonRef!: ElementRef<HTMLElement>;	tooltipMeta = {		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	};	mouseOverTimeout: any = null;	onMouseOver() {		this.mouseOverTimeout = setTimeout(() => {			const bounding = this.buttonRef.nativeElement.getBoundingClientRect();			this.tooltipMeta = {				x: bounding.x,				y: bounding.y,				height: bounding.height,				width: bounding.width,				show: true,			};		}, 1000);	}	onMouseLeave() {		this.tooltipMeta = {			x: 0,			y: 0,			height: 0,			width: 0,			show: false,		};		clearTimeout(this.mouseOverTimeout);	}	ngOnDestroy() {		clearTimeout(this.mouseOverTimeout);	}}
<!-- App.vue --><script setup>import { ref, onUnmounted } from "vue";const buttonRef = ref();const mouseOverTimeout = ref(null);const tooltipMeta = ref({	x: 0,	y: 0,	height: 0,	width: 0,	show: false,});const onMouseOver = () => {	mouseOverTimeout.value = setTimeout(() => {		const bounding = buttonRef.value.getBoundingClientRect();		tooltipMeta.value = {			x: bounding.x,			y: bounding.y,			height: bounding.height,			width: bounding.width,			show: true,		};	}, 1000);};const onMouseLeave = () => {	tooltipMeta.value = {		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	};	clearTimeout(mouseOverTimeout.current);};onUnmounted(() => {	clearTimeout(mouseOverTimeout.current);});</script><template>	<div style="padding: 10rem">		<div			v-if="tooltipMeta.show"			:style="`        display: flex;        overflow: visible;        justify-content: center;        width: ${tooltipMeta.width}px;        position: fixed;        top: ${tooltipMeta.y - tooltipMeta.height - 8}px;        left: ${tooltipMeta.x}px;      `"		>			<div				:style="`          white-space: nowrap;        `"			>				This will send an email to the recipients			</div>		</div>		<button			ref="buttonRef"			@mouseover="onMouseOver()"			@mouseleave="onMouseLeave()"		>			Send		</button>	</div></template>

Step 5: Adding Polish

Our tooltip works now! But, being honest, it's a bit plain-looking without much styling.

Let's fix that by adding:

  1. Background colors
  2. A dropdown arrow indicating the location of the element the tooltip is for

While the first item can be added using some background-color CSS, the dropdown arrow is a bit more challenging to solve.

The reason a dropdown arrow is more challenging is that CSS typically wants all elements to be represented as a square — not any other shape.

However, we can use this knowledge to use a square and trick the human eye into thinking it's a triangle by:

  1. Rotating a square 45 degrees to be "sideways" using CSS' transform
  2. Adding color to the square using background-color
  3. Positioning the square to only show the bottom half using position: absolute and a negative CSS top value
  4. Placing it under the tooltip background using a negative z-index
We start with a black square, rotate it 45 degrees, then color it blue. We then place it in the middle bottom of the tooltip with half of the arrow sticking out. Finally we move the square under the tooltip background

Let's build it!

function App() {	const buttonRef = useRef();	const mouseOverTimeout = useRef();	const [tooltipMeta, setTooltipMeta] = useState({		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	});	const onMouseOver = () => {		mouseOverTimeout.current = setTimeout(() => {			const bounding = buttonRef.current.getBoundingClientRect();			setTooltipMeta({				x: bounding.x,				y: bounding.y,				height: bounding.height,				width: bounding.width,				show: true,			});		}, 1000);	};	const onMouseLeave = () => {		setTooltipMeta({			x: 0,			y: 0,			height: 0,			width: 0,			show: false,		});		clearTimeout(mouseOverTimeout.current);	};	useEffect(() => {		return () => {			clearTimeout(mouseOverTimeout.current);		};	}, []);	return (		<div style={{ padding: "10rem" }}>			{tooltipMeta.show && (				<div					style={{						display: "flex",						overflow: "visible",						justifyContent: "center",						width: `${tooltipMeta.width}px`,						position: "fixed",						top: `${tooltipMeta.y - tooltipMeta.height - 16 - 6 - 8}px`,						left: `${tooltipMeta.x}px`,					}}				>					<div						style={{							whiteSpace: "nowrap",							padding: "8px",							background: "#40627b",							color: "white",							borderRadius: "16px",						}}					>						This will send an email to the recipients					</div>					<div						style={{							height: "12px",							width: "12px",							transform: "rotate(45deg) translateX(-50%)",							background: "#40627b",							bottom: "calc(-6px - 4px)",							position: "absolute",							left: "50%",							zIndex: -1,						}}					/>				</div>			)}			<button				onMouseOver={onMouseOver}				onMouseLeave={onMouseLeave}				ref={buttonRef}			>				Send			</button>		</div>	);}
@Component({	selector: "app-root",	standalone: true,	template: `		<div style="padding: 10rem">			@if (tooltipMeta.show) {				<div					[style]="						'			display: flex;			overflow: visible;			justify-content: center;			width: ' +						tooltipMeta.width +						'px;			position: fixed;			top: ' +						(tooltipMeta.y - tooltipMeta.height - 16 - 6 - 8) +						'px;			left: ' +						tooltipMeta.x +						'px;		  '					"				>					<div						style="			  white-space: nowrap;			  padding: 8px;			  background: #40627b;			  color: white;			  border-radius: 16px;			"					>						This will send an email to the recipients					</div>					<div						style="			  height: 12px;			  width: 12px;			  transform: rotate(45deg) translateX(-50%);			  background: #40627b;			  bottom: calc(-6px - 4px);			  position: absolute;			  left: 50%;			  zIndex: -1;			"					></div>				</div>			}			<button				#buttonRef				(mouseover)="onMouseOver()"				(mouseleave)="onMouseLeave()"			>				Send			</button>		</div>	`,})class AppComponent implements OnDestroy {	@ViewChild("buttonRef") buttonRef!: ElementRef<HTMLElement>;	tooltipMeta = {		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	};	mouseOverTimeout: any = null;	onMouseOver() {		this.mouseOverTimeout = setTimeout(() => {			const bounding = this.buttonRef.nativeElement.getBoundingClientRect();			this.tooltipMeta = {				x: bounding.x,				y: bounding.y,				height: bounding.height,				width: bounding.width,				show: true,			};		}, 1000);	}	onMouseLeave() {		this.tooltipMeta = {			x: 0,			y: 0,			height: 0,			width: 0,			show: false,		};		clearTimeout(this.mouseOverTimeout);	}	ngOnDestroy() {		clearTimeout(this.mouseOverTimeout);	}}
<!-- App.vue --><script setup>import { ref, onUnmounted } from "vue";const buttonRef = ref();const mouseOverTimeout = ref(null);const tooltipMeta = ref({	x: 0,	y: 0,	height: 0,	width: 0,	show: false,});const onMouseOver = () => {	mouseOverTimeout.value = setTimeout(() => {		const bounding = buttonRef.value.getBoundingClientRect();		tooltipMeta.value = {			x: bounding.x,			y: bounding.y,			height: bounding.height,			width: bounding.width,			show: true,		};	}, 1000);};const onMouseLeave = () => {	tooltipMeta.value = {		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	};	clearTimeout(mouseOverTimeout.current);};onUnmounted(() => {	clearTimeout(mouseOverTimeout.current);});</script><template>	<div style="padding: 10rem">		<div			v-if="tooltipMeta.show"			:style="`        display: flex;        overflow: visible;        justify-content: center;        width: ${tooltipMeta.width}px;        position: fixed;        top: ${tooltipMeta.y - tooltipMeta.height - 16 - 6 - 8}px;        left: ${tooltipMeta.x}px;      `"		>			<div				:style="`          white-space: nowrap;          padding: 8px;          background: #40627b;          color: white;          border-radius: 16px;        `"			>				This will send an email to the recipients			</div>			<div				:style="`          height: 12px;          width: 12px;          transform: rotate(45deg) translateX(-50%);          background: #40627b;          bottom: calc(-6px - 4px);          position: absolute;          left: 50%;          zIndex: -1;        `"			></div>		</div>		<button			ref="buttonRef"			@mouseover="onMouseOver()"			@mouseleave="onMouseLeave()"		>			Send		</button>	</div></template>
Previous articlePassing Children
Next article Component Reference

Subscribe to our newsletter!

Subscribe to our newsletter to get updates on new content we create, events we have coming up, and more! We'll make sure not to spam you and provide good insights to the content we have.