Toby Hobson

Optimizing conversions with Svelte and Tailwind CSS

banner.png
Optimizing conversions with Svelte and Tailwind CSS
Whilst pop-ups have a bad reputation, if implemented correctly they can dramatically improve conversion rates. In this post I'll explain how to implement effective call-to-action pop-ups using Svelte and Tailwind CSS.

Good pop-ups?

So what makes a good, high performing pop-up. Marketeers and CRO experts can describe it far better than me, and I highly recommend Neil Patel’s examples of effective popups. Leaving aside the marketing/content aspect of the pop-up (I’m a programmer, not a copywriter) I’ll focus on the behavioural aspects of pop-ups. In short, effective pop-ups are:

1. Unobtrusive

Visitors didn’t come to your page looking for a popup, they want to see your content. You need to give them chance to see it before hitting them with a popup.

The most effective pop-ups are triggered by events - typically scrolling toward the bottom of the page or navigating away from it. I’ll cover the second strategy, known as an exit intent trigger as this is usually the most effective. I’ll cover the scroll based strategy in a subsequent post.

2. Restrained

Avoid bright colours, bold buttons and flashy animations, they look spammy. The best pop-ups are restrained affairs. Neils own pop-up is a great example of a minimalistic CTA pop-up:

A great example of an effective pop-up

3. Polite

Imagine seeing the same popup on each page load, it would drive you crazy! Effective pop-ups prompt the visitor to do something, but know when to take a hint. Once the visitor has seen the pop-up we need to set a flag to ensure they don’t see it again (at least not any time soon).

Svelte / Tailwind CSS implementation

We’ll build our CRO pop-up using Svelte Kit and Tailwind CSS, with the awesome DaisyUI plugin. You don’t have to use DaisyUI or Tailwind. The CSS itself is quite simple and could be implemented in any framework or in raw CSS. Our popup will look something like this:

Our highly performing CTA pop-up

Project setup

If you’re starting from scratch here are commands to set up your project:

$ npm create svelte@latest cro-popup
# Follow the prompts for a skeleton Typescript project
Creating a Sveltekit project

Now install Tailwind and DaisyUI:

$ cd cro-popup

# Add Tailwind & DaisyUI
$ npx svelte-add@latest tailwindcss --daisyui

$ npm install
$ git init && git add -A && git commit -m "Initial commit"

The HTML & CSS

Let’s start with a basic dialog that’s toggled by a simple boolean variable. For now, we’ll use a button to display our popup.

<!-- src/routes/page.svelte -->
<script lang="ts">
    let popupActive = false

    function showPopup() {
        popupActive = true
    }

    function hidePopup() {
        popupActive = false
    }
</script>

<!-- Open the popup -->
<div class="p-6 mx-auto">
    <button class="btn" on:click={showPopup}>Open popup</button>
</div>

{#if popupActive}
   <div class="fixed inset-0 bg-base-200 bg-opacity-95 overflow-y-auto h-full w-full z-20 flex justify-center items-center">
       <div class="z-30 max-w-xl flex flex-col gap-4 p-6">
           <h3 class="font-bold text-4xl md:text-6xl text-center">Buy from me!</h3>
           <div class="text-xl md:text-2xl text-center">Don't forget to give me some money!</div>
           <div class="text-center">
               <button class="btn">Shop now!</button>
           </div>
       </div>
   
       <!-- Close button, pin to top right corner -->
       <div class="absolute top-6 right-6 h-16 w-16">
           <button class="btn !btn-circle btn-outline" on:click={hidePopup}>
               <!-- Heroicons close button -->
               <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
                    stroke="currentColor">
                   <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
               </svg>
           </button>
       </div>
   </div>
{/if}

You’re probably wondering why I wrapped the pop-up in an if block when I could have just toggled the visibility using a CSS class. As you’ll see later, this approach makes it easy to apply a subtle transition effect.

Triggers

We can’t expect our visitors to click a “show popup” button, so let’s use an exit-intent trigger to activate it.

Exit intent trigger

The goal is to display the pop-up when the user navigates away from the page. In the old days we could intercept the browser back button and display a popup. Inevitably, shady developers abused this feature, some even tried to prevent people leaving the page!. Browsers quickly closed the loophole, so we need a different approach.

Whilst we can’t intercept the back button, we can guess when the user is about to press it, at least on desktop devices. It’s not an exact science, but we basically do two things:

  1. We attach an onmouseleave event handler to the HTML body. This tells us the user moved their pointer away from the document itself.
  2. We check the Y coordinates of the pointer. Assuming that browser navigation tools (and other tabs/windows) appear along the top of the screen we can discount any leave event that’s outside this region.

Some years ago Carl Sednaoui wrote a JS library to handle exit intents. I’ve used his OuiBounce code as inspiration for my own Svelte based implementation. I encourage you to take a look at Carl’s code.

The on:mouseleave handler

function handleMouseLeave(e: MouseEvent) {
  // probably not hitting the top navigation bar
  if (e.clientY > 200) return

  showPopup()
}

Attaching the handler is easy in Svelte:

<svelte:body on:mouseleave={handleMouseLeave} />

Try moving your cursor towards the back button and the popup will appear. Don’t get too excited though because there’s a problem: pretty much any movement will trigger the popup, even moving the mouse pointer down towards the centre of the page.

To understand what’s happening, open up the src/app.html page and change the body’s background:

<body data-sveltekit-preload-data="hover" class="bg-red-500">
<div style="display: contents">%sveltekit.body%</div>
</body>

You’ll need to refresh your browser to see the change:

Body too short

The popup is firing because we’re moving away from the HTML body, yet we’re still in the top region of the page. We need to make the body fill the entire screen, easily done in tailwind:

<body data-sveltekit-preload-data="hover" class="h-screen">
...
</body>

You can now move your pointer around the document without triggering the popup, it’s only when you move it towards the navigation bar that the popup fires.

Checking if we already displayed the pop-up

The pop-up is pretty annoying because it fires every time we move the pointer away from the document. We need to add a flag to ensure we fire only once. There are two approaches: the most obvious strategy is to set a cookie, but we can also use the browser’s local storage.

Personally I prefer the latter, because the flag is only used client side - there’s no need to send it to the server. If you employ a cookie management tool, allowing visitors to opt-out of certain cookies you’d have to class this cookie as “non-essential”.

Let’s use local storage to set a simple flag, ensuring we only fire once.

function showPopup() {
  popupActive = true
  window.localStorage.setItem("popup.fired", "true")
}

function handleMouseLeave(e: MouseEvent) {
  const alreadyTriggered = "true" === window.localStorage.getItem("popup.fired")
  if (alreadyTriggered || e.clientY > 200) return

  showPopup()
}

You may decide that this approach is a bit too conservative. If so, you could use a timestamp instead of a boolean flag and check if the pop-up fired within the last N days:

function showPopup() {
  popupActive = true
  window.localStorage.setItem("popup.fired", new Date().toISOString())
}

function isAlreadyFired() {
  const alreadyFired = "true" === window.localStorage.getItem("popup.fired")
  if (!alreadyFired) return false

  const now = Date.now()
  const then = Date.parse(alreadyFired)
  const oneDayAgo = 1000 * 60 * 60 * 24
  return ((now - then) < oneDayAgo) // prompted 24 hours ago
}

function handleMouseLeave(e: MouseEvent) {
  if (isAlreadyFired() || e.clientY > 200) return

  showPopup()
}

Improving the UX

We probably only want to display our pop-up with its call to action for visitors who are engaged in the site and content. We can improve the effectiveness of our pop-up by setting a delay, basically ignoring mouseleave events for users who spend less than N seconds on the page.

import { onMount } from "svelte"

let loadedAt: Date | null = null

onMount(() => {
  loadedAt = new Date()
})

function isPending() {
  if (!loadedAt) return true

  const now = Date.now()
  // Wait at least 10 seconds before arming the trigger
  return (now - loadedAt.getTime()) < 10_000 
}

function handleMouseLeave(e: MouseEvent) {
  if (isAlreadyFired() || isPending() || e.clientY > 200) return

  showPopup()
}

Applying a transition

Our popup still feels a bit in your face. We can improve it by applying a CSS transition, so it fades in and out. Tailwind supplies classes for this but there’s a much simpler way. Svelte’s transition directives look great, and are a breeze to use. Let’s fade our pop-up in (and out).

<script lang="ts">
   import { fade } from "svelte/transition"
</script>

{#if popupActive}
   <div transition:fade class="fixed inset-0 bg-base-200 ...">
      ...
   </div>
{/if}

Hopefully you can now see why I chose to wrap the div in an if block. If we use Tailwind’s transition helpers we would need to manually change the relevant properties or use a component like Svelte class transition

Creating a re-usable component

Turning this into a re-usable component is trivial:

<!-- src/lib/components/CtaPopup.svelte -->
<script lang="ts">
  export let storageKey = "popup.fired"
  
  function showPopup() {
    window.localStorage.setItem(storageKey, new Date().toISOString())
    ...
  }
  
  function isAlreadyFired() {
    const alreadyFired = window.localStorage.getItem(storageKey)
    ...
  }
</script>

{#if popupActive}
    <div transition:fade class="fixed inset-0 bg-base-200 ...">
        <slot />

        <!-- Pin to top right corner -->
        <div class="absolute top-6 right-6 h-16 w-16">
            <button class="btn !btn-circle btn-outline" on:click={hidePopup}>
                <!-- Heroicons close button -->
                ...
            </button>
        </div>
    </div>
{/if}

We just need to define a key for the local storage flag (so we can display more than one popup if we wish), and use the <slot /> tag.

Using our component:

<!-- src/routes/+page.svelte -->
<script lang="ts">
    import CtaPopup from "$lib/components/CtaPopup.svelte"
</script>

<CtaPopup>
    <div class="z-30 max-w-xl flex flex-col gap-4 p-6">
        <h3 class="font-bold text-4xl md:text-6xl text-center">Buy from me!</h3>
        <div class="text-xl md:text-2xl text-center">Don't forget to give me some money!</div>
        <div class="text-center">
            <button class="btn">Shop now!</button>
        </div>
    </div>
</CtaPopup>

Analytics

Which pop-up content is most effective? What parameters should you use for the arming delay and popup.fired flag ? If you use Google Analytics you can create an A/B test. In my experience data layer variable targeting works best with Svelte apps.

The finished article

Here’s the full code for the CtaPopup component:

<!-- src/lib/components/CtaPopup.svelte -->
<script lang="ts">
    import { fade } from "svelte/transition"
    import { onMount } from "svelte"

    let popupActive = false
    let loadedAt: Date | null = null
    export let storageKey = "popup.fired"

    onMount(() => {
        loadedAt = new Date()
    })

    function showPopup() {
        popupActive = true
        window.localStorage.setItem(storageKey, new Date().toISOString())
    }

    function hidePopup() {
        popupActive = false
    }

    function isPending() {
        if (! loadedAt) return true

        const now = Date.now()
        console.log(now - loadedAt.getDate())
        return (now - loadedAt.getTime()) < 10_000 // ready after 10 seconds
    }

    function isAlreadyFired() {
        const alreadyFired = window.localStorage.getItem(storageKey)
        if (! alreadyFired) return false

        const now = Date.now()
        const then = Date.parse(alreadyFired)
        const oneDayAgo = 1000 * 60 * 60 * 24
        return ((now - then) < oneDayAgo) // prompted 24 hours ago
    }

    function handleMouseLeave(e: MouseEvent) {
        if (isAlreadyFired() || isPending() || e.clientY > 200) return

        showPopup()
    }
</script>

<svelte:body on:mouseleave={handleMouseLeave} />

{#if popupActive}
    <div transition:fade class="fixed inset-0 bg-base-200 bg-opacity-95 overflow-y-auto h-full w-full z-20 flex 
    justify-center items-center">
        <slot />

        <!-- Pin to top right corner -->
        <div class="absolute top-6 right-6 h-16 w-16">
            <button class="btn !btn-circle btn-outline" on:click={hidePopup}>
                <!-- Heroicons close button -->
                <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                </svg>
            </button>
        </div>
    </div>
{/if}

Usage:

<!-- src/routes/+page.svelte -->
<script lang="ts">
    import CtaPopup from "$lib/components/CtaPopup.svelte"
</script>

<CtaPopup>
    <div class="z-30 max-w-xl flex flex-col gap-4 p-6">
        <h3 class="font-bold text-4xl md:text-6xl text-center">Buy from me!</h3>
        <div class="text-xl md:text-2xl text-center">Don't forget to give me some money!</div>
        <div class="text-center">
            <button class="btn">Shop now!</button>
        </div>
    </div> 
</CtaPopup>

Wrapping up

Hopefully you’ve seen how easy it is to deliver very effective popups using the Svelte framework. Experiment to see what works best for you and let me know how you get on.

comments powered by Disqus

Need help with your project?

Do you need some help or guidance with your project? Reach out to me (email is best)