This year I launched a free eBook with 27 helpful tips for Vue developers for subscribers of my weekly Vue newsletter. For marketing purposes, I showed a popup on the landing page of my portfolio page each time a user visited my site. I was aware that users probably could get annoyed by that popup. Thus I added a "Don't show again" button to that popup. I thought I solved the problem!
But soon, I realized that many other sites solved that problem more elegantly. If you visit their website, stay for some time, and scroll through the content, a small notification appears at the bottom of the screen. It asks if you are interested in a specific product, and if you agree, it redirects to a page with information about this product.
In this article, I'll explain how I built a polite popup to ask people if they would like to subscribe to my newsletter using Nuxt 3.
What is a polite popup?
The goal of a so-called polite popup is to only ask for visitors emails if it detects that visitors are engaged with your content. This means they’ll be more likely to sign up by the time we ask them because it’ll be after they’ve decided they liked our content.
In the following sections, we'll build a popup that
- waits for a visitor to browse the website
- makes sure visitors are interested in the website
- appears off to the side in a non-intrusive way
- is easy to dismiss or ignore
- asks for permission first
- waits a bit before it appears again
Implementation
Now that we know the criteria of a polite popup let's start implementing it using Nuxt 3.
I use Nuxt.js in this example, but the concepts and solutions are not tied to any framework.
The demo code is interactively available at StackBlitz:
Implement the composable
The most exciting challenge of the polite popup is only showing it if visitors are interested in the website and content we want to promote.
Technically, we'll solve it this way:
- The visitor must be visiting a page with Vue-related content as my newsletter targets Vue developers.
- The visitor must be actively scrolling the current page for 6 seconds or more.
- The visitor must scroll through at least 35% of the current page during their visit.
If these numbers aren’t generating the amount of engagement you want to see from visitors, you can enable a more aggressive mode that will lower the threshold by about 20-30%.
Let's start by writing a Vue composable for our polite popup:
import { useWindowScroll, useWindowSize, useTimeoutFn } from '@vueuse/core'
const config = {
timeoutInMs: 3000,
contentScrollThresholdInPercentage: 300,
}
export const usePolitePopup = () => {
const visible = useState('visible', () => false)
const readTimeElapsed = useState('read-time-elapsed', () => false)
const { start } = useTimeoutFn(
() => {
readTimeElapsed.value = true
},
config.timeoutInMs,
{ immediate: false }
)
const { y: scrollYInPx } = useWindowScroll()
const { height: windowHeight } = useWindowSize()
// Returns percentage scrolled (ie: 80 or NaN if trackLength == 0)
const amountScrolledInPercentage = computed(() => {
const documentScrollHeight = document.documentElement.scrollHeight
const trackLength = documentScrollHeight - windowHeight.value
const percentageScrolled = Math.floor((scrollYInPx.value / trackLength) * 100)
return percentageScrolled
})
const scrolledContent = computed(() => amountScrolledInPercentage.value >= config.contentScrollThresholdInPercentage)
const trigger = () => {
readTimeElapsed.value = false
start()
}
watch([readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => {
if (newReadTimeElapsed && newScrolledContent) {
visible.value = true
}
})
return {
visible,
trigger,
}
}
We defined two state variables:
visible
: a boolean indicating if the popup should be visible or not.readTimeElapsed
: a boolean indicating if the user has spent the defined time on the page.
The trigger
method is exposed and triggers the a timer which is used to check if the visitor has spent a predefined amount of time on the page. A Vue watcher is used to set visible
to true
if the timer has expired and the scroll threshold is exceeded.
For the timer, we use VueUse's useTimeoutFn composable which runs a setTimeout
function and sets the readTimeElapsed
state variable to true
after the timer has expired.
Let's take a detailed look at the amountScrolledInPercentage
computed property:
import { useWindowSize } from '@vueuse/core'
const { height: windowHeight } = useWindowSize()
// Returns percentage scrolled (ie: 80 or NaN if trackLength == 0)
const amountScrolledInPercentage = computed(() => {
const documentScrollHeight = document.documentElement.scrollHeight
const trackLength = documentScrollHeight - windowHeight.value
const percentageScrolled = Math.floor((scrollYInPx.value / trackLength) * 100)
return percentageScrolled
})
To get the total scrollable area of a document, we need to retrieve the following two measurements of the page:
- The height of the browser window: We use VueUse's useWindowSize composable to get reactive variable of the browser window height.
- The height of the entire document: We use
document.documentElement.scrollHeight
to get the height of the document, including content not visible on the screen due to overflow.
By subtracting 2 from 1, we get the total scrollable area of the document. VueUse's useWindowScroll composable is used to access the number of pixels the document is currently scrolled along the vertical axis.
Move your eyes down to the trackLength
variable, which gets the total available scroll length of the document. The variable will contain 0 if the page is not scrollable. The percentageScrolled
variable then divides the scrollYInPx
variable (amount the user has scrolled) with trackLength
to derive how much the user has scrolled percentage wise.
Finally, we need to trigger the popup on certain pages. In our case, we only want to trigger it on the Vue route path:
<template>
<main>
<ContentDoc />
</main>
</template>
<script setup lang="ts">
const route = useRoute()
const { trigger } = usePolitePopup()
if (route.path === '/vue') {
trigger()
}
</script>
Write the popup component
Now it's time to write the Vue component that renders the popup:
<template>
<div v-if="visible" class="fixed z-50 right-0 bottom-0 md:right-5 md:bottom-5 p-4 rounded-md bg-white shadow-lg">
<span>May I show you something cool?</span>
<div class="flex gap-4 mt-4">
<button @click="onClickOk">OK</button>
<button @click="onClickClose">Nah, thanks</button>
</div>
</div>
</template>
<script setup lang="ts">
const { setClosed, visible } = usePolitePopup()
const onClickOk = async () => {
setClosed()
navigateTo('/newsletter')
}
const onClickClose = () => {
setClosed()
}
</script>
In this example, I'm using Nuxt Tailwind to style the component.
PolitePopup.vue
is rendered at a fixed position of the viewport if the visible
reactive variable value is true
. It offers two buttons, one to accept the offer and one to decline it.
As you can see, we are using setClosed
from our useShowPopup
composable, which we haven't defined yet. Let's define it:
export const usePolitePopup = () => {
const visible = useState('visible', () => false)
...
const setClosed = () => {
visible.value = false
}
return {
...
setClosed
}
}
The last step is to add our new PolitePopup.vue
component to the template of app.vue
:
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<PolitePopup />
</div>
</template>
At this point, we finished the basic implementation of the polite popup. If we navigate to /vue
, spend 3 seconds on the page, and scroll down more than 300 pixel the polite popup appears:
Add logic to wait a bit before the popup appears again
One problem with the current implementation: each time we reload the page and scroll down, the popup is triggered. But our popup should wait a bit before it appears again. So let's implement that logic.
First, we need a way to store the status of the polite popup in LocalStorage. For this, we use the following data model:
interface PolitePopupStorageDTO {
status: 'unsubscribed' | 'subscribed'
seenCount: number
lastSeenAt: number
}
status
is per defaultunsubscribed
and is set tosubscribed
if a visitor subscribes to the newsletter.seenCount
tracks how often the user has seen the popup.lastSeenAt
tracks the timestamp when the visitor has seen the popup.
Let's add that interface to our usePolitePopup
composable:
interface PolitePopupStorageDTO {
status: 'unsubscribed' | 'subscribed'
seenCount: number
lastSeenAt: number
}
export const usePolitePopup = () => {
...
const storedData: Ref<PolitePopupStorageDTO> = useLocalStorage('polite-popup', {
status: 'unsubscribed',
seenCount: 0,
lastSeenAt: 0,
})
...
watch(
[readTimeElapsed, scrolledContent],
([newReadTimeElapsed, newScrolledContent]) => {
if (newReadTimeElapsed && newScrolledContent) {
visible.value = true;
storedData.value.seenCount += 1;
storedData.value.lastSeenAt = new Date().getTime();
}
}
);
...
return {
...
}
}
We use VueUse's useLocalStorage composable to get a reactive variable of a LocalStorage entry. Each time our watcher is fired and set the popup visible, we increment seenCount
and set the current timestamp at lastSeenAt
in our LocalStorage object.
Let's store the information that the visitor has subscribed to the newsletter. Let's add that logic to newsletter.vue
:
<template>
<main class="flex flex-col gap-8">
<NuxtLink to="/">Back home</NuxtLink>
<button @click="setSubscribed">Subscribe</button>
</main>
</template>
<script setup lang="ts">
const { setSubscribed } = usePolitePopup()
</script>
and in
export const usePolitePopup = () => {
...
const setSubscribed = () => {
storedData.value.status = 'subscribed'
}
return {
...
setSubscribed
}
}
Extend visibility logic
The next step is only to show the popup if the current visitor
- hasn't subscribed yet
- has seen the popup more than three times
- has already seen the popup today
Let's implement that logic:
const isToday = (date: Date): boolean => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
const config = {
timeoutInMs: 3000,
maxSeenCount: 5,
scrollYInPxThreshold: 300,
};
export const usePolitePopup = () => {
...
watch(
[readTimeElapsed, scrolledContent],
([newReadTimeElapsed, newScrolledContent]) => {
if (storedData.value.status === 'subscribed') {
return;
}
if (storedData.value.seenCount >= config.maxSeenCount) {
return;
}
if (
storedData.value.lastSeenAt &&
isToday(new Date(storedData.value.lastSeenAt))
) {
return;
}
if (newReadTimeElapsed && newScrolledContent) {
visible.value = true;
storedData.value.seenCount += 1;
storedData.value.lastSeenAt = new Date().getTime();
}
}
};
...
return {
...
}
}
We are done!
You will probably also have seen this polite popup on this page if you read it on my portfolio website.
Conclusion
In my opinion, polite popups are the best way to convert visitors to my newsletter. This way, I can ensure that they are interested in my content and do not get annoyed by modals.
If you liked this article, follow me on Twitter to get notified about new blog posts and more content from me.
Alternatively (or additionally), you can also subscribe to my newsletter.