Building a Polite Newsletter Popup With Nuxt 3

Building a Polite Newsletter Popup With Nuxt 3

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.

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:

  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:

<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:

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:

interface PolitePopupStorageDTO {
  status: 'unsubscribed' | 'subscribed'
  seenCount: number
  lastSeenAt: number
}
  • 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:

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.

Did you find this article valuable?

Support Michael Hoffmann by becoming a sponsor. Any amount is appreciated!