A React Hook to Animate the Page (Document) Title and Favicon
Introducing react-use-please-stay: Animate the document title and favicon in your React projects with ease using this powerful hook!
Posted on April 26, 2021
TL;DR - Demo, npm Package, and Code
Here's a gif of what the hook looks like in action:

Enjoy!
Background Behind react-use-please-stay
Though I'm sure it's something I'm sure I've seen before, I stumbled upon an animated title and changing favicon while recently visiting the Dutch version of the Mikkeller Web Shop. The favicon changes to a sad-looking Henry (Henry and Sally are the famous Mikkeller mascots), and the tab title swaps between:
Henry is Sad.and
Remember your beersNot sure if the strange grammar is by design, but the whole thing cracked me up. 😂 After downloading the source and doing a bit of snooping around, (AKA by searching for document.title
), all I could manage to find was a file called pleasestay.js
, which contained the visibility change event listener, but it was all modularized and over 11000 lines long! It was definitely not in its usable form, and after a Google search, I could only find this GitHub gist with a JQuery implementation of the functionality.
Creation of the Package
I have to admit - the little animation on Mikkeler's Shop did pull me back to the site. At the very least, it's a nice touch that you don't see on very many websites. I thought it would make a great React hook - especially if I could make it configurable with multiple options and titles. So I built the react-use-please-stay package to do just that!
As I often do, I'm using my blog as a testbed for the hook. If you go to any other tab in your browser right now, you'll see my blog's favicon and title start animating.
Source Code as Of Writing this Post
Again, the package is completely open source, where you'll find the most up-to-date code, but if you'd like to get an idea of how the hook works right away, here it is:
import { useEffect, useRef, useState } from 'react';import { getFavicon } from '../../helpers/getFavicon';import { AnimationType } from '../../enums/AnimationType';import { UsePleaseStayOptions } from '../../types/UsePleaseStayOptions';import { useInterval } from '../useInterval';export const usePleaseStay = ({titles,animationType = AnimationType.LOOP,interval = 1000,faviconURIs = [],alwaysRunAnimations = false,}: UsePleaseStayOptions): void => {if (animationType === AnimationType.CASCADE && titles.length > 1) {console.warn(`You are using animation type '${animationType}' but passed more than one title in the titles array. Only the first title will be used.`,);}// State varsconst [shouldAnimate, setShouldAnimate] = useState<boolean>(false);// On cascade mode, we substring at the first character (0, 1).// Otherwise start at the first element in the titles array.const [titleIndex, setTitleIndex] = useState<number>(0);const [faviconIndex, setFaviconIndex] = useState<number>(0);const [isAppendMode, setIsAppendMode] = useState<boolean>(true);const [faviconURIsState, setFaviconURIsState] = useState<Array<string>>([]);// Ref varsconst originalDocumentTitle = useRef<string>();const originalFaviconHref = useRef<string>();const faviconRef = useRef<HTMLLinkElement>();// Handler for visibility change - only needed when alwaysRunAnimations is falseconst handleVisibilityChange = () => {document.visibilityState === 'visible'? restoreDefaults(): setShouldAnimate(true);};// The logic to modify the document title in cascade mode.const runCascadeLogic = () => {document.title = titles[0].substring(0, titleIndex);setTitleIndex(isAppendMode ? titleIndex + 1 : titleIndex - 1);if (titleIndex === titles[0].length - 1 && isAppendMode) {setIsAppendMode(false);}if (titleIndex - 1 === 0 && !isAppendMode) {setIsAppendMode(true);}};// The logic to modify the document title in loop mode.const runLoopLogic = () => {document.title = titles[titleIndex];setTitleIndex(titleIndex === titles.length - 1 ? 0 : titleIndex + 1);};// The logic to modify the document title.const modifyDocumentTitle = () => {switch (animationType) {// Cascade letters in the titlecase AnimationType.CASCADE:runCascadeLogic();return;// Loop over titlescase AnimationType.LOOP:default:runLoopLogic();return;}};// The logic to modify the favicon.const modifyFavicon = () => {if (faviconRef && faviconRef.current) {faviconRef.current.href = faviconURIsState[faviconIndex];setFaviconIndex(faviconIndex === faviconURIsState.length - 1 ? 0 : faviconIndex + 1,);}};// The logic to restore default title and favicon.const restoreDefaults = () => {setShouldAnimate(false);setTimeout(() => {if (faviconRef &&faviconRef.current &&originalDocumentTitle.current &&originalFaviconHref.current) {document.title = originalDocumentTitle.current;faviconRef.current.href = originalFaviconHref.current;}}, interval);};// On mount of this hook, save current defaults of title and favicon. also add the event listener. on un mount, remove ituseEffect(() => {// make sure to store originals via useRefconst favicon = getFavicon();if (favicon === undefined) {console.warn('We could not find a favicon in your application.');return;}// save originals - these are not to be manipulatedoriginalDocumentTitle.current = document.title;originalFaviconHref.current = favicon.href;faviconRef.current = favicon;// TODO: small preload logic for external favicon links? (if not a local URI)// Build faviconLinksState// Append current favicon href, since this is needed for an expected favicon toggle or animation patternsetFaviconURIsState([...faviconURIs, favicon.href]);// also add visibilitychange event listenerdocument.addEventListener('visibilitychange', handleVisibilityChange);return () => {document.removeEventListener('visibilitychange', handleVisibilityChange);};}, []);// State change effectsuseEffect(() => {// Change in alwaysRunAnimations change the shouldAnimate valuesetShouldAnimate(alwaysRunAnimations);// Update title indexsetTitleIndex(animationType === AnimationType.CASCADE ? 1 : 0);}, [animationType, alwaysRunAnimations]);// Change title and favicon at specified intervaluseInterval(() => {modifyDocumentTitle();// this is 1 because we append the existing favicon on mount - see abovefaviconURIsState.length > 1 && modifyFavicon();},shouldAnimate ? interval : null,);};
Thanks!
This was a fun little hook that took more than a few hours to work out all the kinks for. So far it has been stable on my site, and I'm open to pull requests, critiques, and further features!
Cheers! 🍺
-Chris