Building a Polite Newsletter Popup With Nuxt 3
Tags: vue, nuxt, frontend, typescript
Published: 2022-10-24 14:47:43

---
title: Building a Polite Newsletter Popup With Nuxt 3
published: true
date: 2022-10-18 00:00:00 UTC
tags: vue,nuxt,frontend,typescript
canonical_url: https://mokkapps.de/blog/building-a-polite-newsletter-popup-with-nuxt-3
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y7wnpshdumumz1dy2s6l.jpg
---

This year I launched a [free eBook with 27 helpful tips for Vue developers](https://mokkapps.de/ebook/27-helpful-tips-for-vue-developers) for subscribers of my [weekly Vue newsletter](https://weekly-vue.news). For marketing purposes, I showed a popup on the landing page of my [portfolio page](https://mokkapps.de) 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](https://weekly-vue.news) using Nuxt 3.

## What is a polite popup?

![Photo by Emily Morter on Unsplash](https://res.cloudinary.com/mokkapps/image/upload/v1665756156/blog/building-a-polite-newsletter-popup-with-nuxt-3/photo-1484069560501-87d72b0c3669_yn7mhd.jpg)

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](https://v3.nuxtjs.org/).

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:

{% embed https://stackblitz.com/edit/polite-popup-nuxt-3?ctl=1&embed=1&file=README.md %}

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

```ts
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](https://vueuse.org/shared/usetimeoutfn) 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:

```ts
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](https://vueuse.org/core/usewindowsize/) to get reactive variable of the browser window height.
1. **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](https://vueuse.org/core/usewindowscroll) 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:

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

```vue
<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](https://tailwindcss.nuxtjs.org/) 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:

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

```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](https://res.cloudinary.com/mokkapps/image/upload/v1665906110/blog/building-a-polite-newsletter-popup-with-nuxt-3/polite-popup-demo_jhz4jz.png)

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

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

```ts
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](https://vueuse.org/core/uselocalstorage/#uselocalstorage) 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`:

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

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

```ts
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](https://mokkapps.de/building-a-polite-newsletter-popup-with-nuxt-3).

## 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](https://twitter.com/mokkapps) to get notified about new blog posts and more content from me.

Alternatively (or additionally), you can also [subscribe to my newsletter](https://mokkapps.de/newsletter).