A React Hook to Animate the Page (Document) Title and Favicon
Chris Frewin
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 beers
Not 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 vars
const [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 vars
const originalDocumentTitle = useRef<string>();
const originalFaviconHref = useRef<string>();
const faviconRef = useRef<HTMLLinkElement>();
// Handler for visibility change - only needed when alwaysRunAnimations is false
const 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 title
case AnimationType.CASCADE:
runCascadeLogic();
return;
// Loop over titles
case 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 it
useEffect(() => {
// make sure to store originals via useRef
const favicon = getFavicon();
if (favicon === undefined) {
console.warn('We could not find a favicon in your application.');
return;
}
// save originals - these are not to be manipulated
originalDocumentTitle.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 pattern
setFaviconURIsState([...faviconURIs, favicon.href]);
// also add visibilitychange event listener
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// State change effects
useEffect(() => {
// Change in alwaysRunAnimations change the shouldAnimate value
setShouldAnimate(alwaysRunAnimations);
// Update title index
setTitleIndex(animationType === AnimationType.CASCADE ? 1 : 0);
}, [animationType, alwaysRunAnimations]);
// Change title and favicon at specified interval
useInterval(
() => {
modifyDocumentTitle();
// this is 1 because we append the existing favicon on mount - see above
faviconURIsState.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
Posted on April 26, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.