Javascript is required
·
8 min read
·
1861 views

Building a Polite Newsletter Popup With Nuxt 3

Building a Polite Newsletter Popup With Nuxt 3 Image

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?

Photo by Emily Morter on Unsplash

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.

Info

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.

Info

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:

composables/usePolitePopup.ts
1import { useWindowScroll, useWindowSize, useTimeoutFn } from '@vueuse/core'
2
3const config = {
4  timeoutInMs: 3000,
5  contentScrollThresholdInPercentage: 300,
6}
7
8export const usePolitePopup = () => {
9  const visible = useState('visible', () => false)
10  const readTimeElapsed = useState('read-time-elapsed', () => false)
11
12  const { start } = useTimeoutFn(
13    () => {
14      readTimeElapsed.value = true
15    },
16    config.timeoutInMs,
17    { immediate: false }
18  )
19  const { y: scrollYInPx } = useWindowScroll()
20  const { height: windowHeight } = useWindowSize()
21
22  // Returns percentage scrolled (ie: 80 or NaN if trackLength == 0)
23  const amountScrolledInPercentage = computed(() => {
24    const documentScrollHeight = document.documentElement.scrollHeight
25    const trackLength = documentScrollHeight - windowHeight.value
26    const percentageScrolled = Math.floor((scrollYInPx.value / trackLength) * 100)
27    return percentageScrolled
28  })
29
30  const scrolledContent = computed(() => amountScrolledInPercentage.value >= config.contentScrollThresholdInPercentage)
31
32  const trigger = () => {
33    readTimeElapsed.value = false
34    start()
35  }
36
37  watch([readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => {
38    if (newReadTimeElapsed && newScrolledContent) {
39      visible.value = true
40    }
41  })
42
43  return {
44    visible,
45    trigger,
46  }
47}

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:

composables/usePolitePopup.ts
1import { useWindowSize } from '@vueuse/core'
2
3const { height: windowHeight } = useWindowSize()
4
5// Returns percentage scrolled (ie: 80 or NaN if trackLength == 0)
6const amountScrolledInPercentage = computed(() => {
7  const documentScrollHeight = document.documentElement.scrollHeight
8  const trackLength = documentScrollHeight - windowHeight.value
9  const percentageScrolled = Math.floor((scrollYInPx.value / trackLength) * 100)
10  return percentageScrolled
11})

To get the total scrollable area of a document, we need to retrieve the following two measurements of the page:

  1. The height of the browser window: We use VueUse's useWindowSize composable to get reactive variable of the browser window height.
  2. 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:

[...slug
1<template>
2  <main>
3    <ContentDoc />
4  </main>
5</template>
6
7<script setup lang="ts">
8const route = useRoute()
9
10const { trigger } = usePolitePopup()
11
12if (route.path === '/vue') {
13  trigger()
14}
15</script>

Write the popup component

Now it's time to write the Vue component that renders the popup:

PolitePopup.vue
1<template>
2  <div v-if="visible" class="fixed bottom-0 right-0 z-50 rounded-md bg-white p-4 shadow-lg md:bottom-5 md:right-5">
3    <span>May I show you something cool?</span>
4    <div class="mt-4 flex gap-4">
5      <button @click="onClickOk">OK</button>
6      <button @click="onClickClose">Nah, thanks</button>
7    </div>
8  </div>
9</template>
10
11<script setup lang="ts">
12const { setClosed, visible } = usePolitePopup()
13
14const onClickOk = async () => {
15  setClosed()
16  navigateTo('/newsletter')
17}
18
19const onClickClose = () => {
20  setClosed()
21}
22</script>

Info

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:

composables/usePolitePopup.ts
1export const usePolitePopup = () => {
2  const visible = useState('visible', () => false)
3  ...
4
5  const setClosed = () => {
6    visible.value = false
7  }
8
9  return {
10    ...
11    setClosed
12  }
13}

The last step is to add our new PolitePopup.vue component to the template of app.vue:

app.vue
1<template>
2  <div>
3    <NuxtLayout>
4      <NuxtPage />
5    </NuxtLayout>
6    <PolitePopup />
7  </div>
8</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:

Polite Popup Demo

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:

1interface PolitePopupStorageDTO {
2  status: 'unsubscribed' | 'subscribed'
3  seenCount: number
4  lastSeenAt: number
5}
  • status is per default unsubscribed and is set to subscribed 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:

composables/usePolitePopup.ts
1interface PolitePopupStorageDTO {
2  status: 'unsubscribed' | 'subscribed'
3  seenCount: number
4  lastSeenAt: number
5}
6
7export const usePolitePopup = () => {
8  ...
9  const storedData: Ref<PolitePopupStorageDTO> = useLocalStorage('polite-popup', {
10    status: 'unsubscribed',
11    seenCount: 0,
12    lastSeenAt: 0,
13  })
14  ...
15  watch(
16    [readTimeElapsed, scrolledContent],
17    ([newReadTimeElapsed, newScrolledContent]) => {
18      if (newReadTimeElapsed && newScrolledContent) {
19        visible.value = true;
20        storedData.value.seenCount += 1;
21        storedData.value.lastSeenAt = new Date().getTime();
22      }
23    }
24  );
25  ...
26
27  return {
28    ...
29  }
30}

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:

pages/newsletter.vue
1<template>
2  <main class="flex flex-col gap-8">
3    <NuxtLink to="/">Back home</NuxtLink>
4    <button @click="setSubscribed">Subscribe</button>
5  </main>
6</template>
7
8<script setup lang="ts">
9const { setSubscribed } = usePolitePopup()
10</script>

and in

composables/usePolitePopup.ts
1export const usePolitePopup = () => {
2  ...
3
4  const setSubscribed = () => {
5    storedData.value.status = 'subscribed'
6  }
7
8  return {
9    ...
10    setSubscribed
11  }
12}

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:

composables/usePolitePopup.ts
1const isToday = (date: Date): boolean => {
2  const today = new Date();
3  return (
4    date.getDate() === today.getDate() &&
5    date.getMonth() === today.getMonth() &&
6    date.getFullYear() === today.getFullYear()
7  );
8};
9
10const config = {
11  timeoutInMs: 3000,
12  maxSeenCount: 5,
13  scrollYInPxThreshold: 300,
14};
15
16export const usePolitePopup = () => {
17  ...
18  watch(
19    [readTimeElapsed, scrolledContent],
20    ([newReadTimeElapsed, newScrolledContent]) => {
21      if (storedData.value.status === 'subscribed') {
22        return;
23      }
24
25      if (storedData.value.seenCount >= config.maxSeenCount) {
26        return;
27      }
28
29      if (
30        storedData.value.lastSeenAt &&
31        isToday(new Date(storedData.value.lastSeenAt))
32      ) {
33        return;
34      }
35
36      if (newReadTimeElapsed && newScrolledContent) {
37        visible.value = true;
38        storedData.value.seenCount += 1;
39        storedData.value.lastSeenAt = new Date().getTime();
40      }
41    }
42  };
43  ...
44
45  return {
46    ...
47  }
48}

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.

I will never share any of your personal data. You can unsubscribe at any time.

If you found this article helpful.You will love these ones as well.
Focus & Code Diff in Nuxt Content Code Blocks Image

Focus & Code Diff in Nuxt Content Code Blocks

A Comprehensive Guide to Data Fetching in Nuxt 3 Image

A Comprehensive Guide to Data Fetching in Nuxt 3

Use Shiki to Style Code Blocks in HTML Emails Image

Use Shiki to Style Code Blocks in HTML Emails

How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES Image

How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES