How I recreated svelte blur effect using alpinejs and tailwind.css

 · 14 min read
 · Baseplate-Admin
Last updated: June 19, 2023
Table of contents

It's not a surprise that both svelte and alpine.js are both popular framework. It's no surprise that both framework has some quirks up it's sleeve.

  • svelte offers a lightweight framework ( which can suffice even the most complex of features ) which adds syntactic sugar to make the developer's job easier.
  • alpine is a rugged, minimal tool for composing behavior directly in your markup. It adds some html directives to supercharge a MPA ( multi page application ).

We want to be greedy and get harness both of their features. More specifically we want to imitate awesome blur tranistion from svelte into an alpine.js powered application.

This article requires you to have basic knowledge of alpine.js and tailwindcss. Essentially the article assums that you are already familiar with basics of alpine.js and tailwindcss.

Now that we have that out of the way, let's get started.

How to replicate the transition using alpine.js

Essentially by reverse engineering a svelte powered website ( in our case a custom repl ) we can recreate what svelte is doing ( fireship has a great video on how to reverse engineer css ).

Here's the html ( note that we are using tailwind ) that can be used to emulate ( or closely simulate ) the svelte transition ( here's the original github source for this html ) :

<div
    class="transition-all duration-400 ease-in-out"
    x-show="$shown"
    x-transition.duration.400ms
    x-transition:enter-start="opacity-50 blur-sm"
    x-transition:enter-end="opacity-100 blur-none"
    x-transition:leave-start="opacity-100 blur-none"
    x-transition:leave-end="opacity-50 blur-sm"
/>

How did we mimic the svelte transition with alpine.js

"Let's start by saying CSS animations are hard."

The animation is actually a 4 stage process:

When x-show is true the component begins the enter-start stage. During this stage, the component will shift from its current transition state, to enter-start for the duration ( in our case 150ms ) of the transition and then to the enter-end stage once it's complete. When enter-end stage is complete, the component will shift from enter-end to leave-start for the duration ( again 150 ms ) of the transition and then to leave-end stage once it's complete. Finally ending the transition.

Now that we have that out of the way let's dive deeper.

  1. We are adding transition-all ( refer to tailwind docs to see what this class adds ) to the parent element. This ensures that we are doing transition on both opacity and blur.

  2. Then we are toggling state ( so that transition is started ) by binding x-show directive. ( refer to the alpine.js docs for how x-show works )

  3. When the transition starts. We are changing the opacity: 0.5; and at the same time adding filter: blur(4px); to the element.

  4. Then when the transition ends, we are shifting from opacity: 0.5; to opacity: 1; and at the same time removing blur ( remember that our effect is to meant the animation to fade in and out | This essentially makes the old element look like they blurred out )

... Repeat it from the opposite for the new element

How does svelte handle tranisiton

Let's take a look at this code ( example repl ):

<script lang='ts'>
    import { blur } from 'svelte/transition';

    let visible =true
</script>

<label>
    <input type="checkbox" bind:checked={visible}>
    visible
</label>

{#if visible}
    <div transition:blur="{{amount: 10}}">
        blurs in and out
    </div>
{/if}

If we take a look at the JS output :

/* App.svelte generated by Svelte v3.59.1 */
import {
    SvelteComponent,
    add_render_callback,
    append,
    attr,
    check_outros,
    create_bidirectional_transition,
    detach,
    element,
    empty,
    group_outros,
    init,
    insert,
    listen,
    safe_not_equal,
    space,
    text,
    transition_in,
    transition_out,
} from 'svelte/internal';

import { blur } from 'svelte/transition';

function create_if_block(ctx) {
    let div;
    let div_transition;
    let current;

    return {
        c() {
            div = element('div');
            div.textContent = 'blurs in and out';
        },
        m(target, anchor) {
            insert(target, div, anchor);
            current = true;
        },
        i(local) {
            if (current) return;

            add_render_callback(() => {
                if (!current) return;
                if (!div_transition)
                    div_transition = create_bidirectional_transition(
                        div,
                        blur,
                        { amount: 10 },
                        true
                    );
                div_transition.run(1);
            });

            current = true;
        },
        o(local) {
            if (!div_transition)
                div_transition = create_bidirectional_transition(
                    div,
                    blur,
                    { amount: 10 },
                    false
                );
            div_transition.run(0);
            current = false;
        },
        d(detaching) {
            if (detaching) detach(div);
            if (detaching && div_transition) div_transition.end();
        },
    };
}

function create_fragment(ctx) {
    let label;
    let input;
    let t0;
    let t1;
    let if_block_anchor;
    let current;
    let mounted;
    let dispose;
    let if_block = /*visible*/ ctx[0] && create_if_block(ctx);

    return {
        c() {
            label = element('label');
            input = element('input');
            t0 = text('\n\tvisible');
            t1 = space();
            if (if_block) if_block.c();
            if_block_anchor = empty();
            attr(input, 'type', 'checkbox');
        },
        m(target, anchor) {
            insert(target, label, anchor);
            append(label, input);
            input.checked = /*visible*/ ctx[0];
            append(label, t0);
            insert(target, t1, anchor);
            if (if_block) if_block.m(target, anchor);
            insert(target, if_block_anchor, anchor);
            current = true;

            if (!mounted) {
                dispose = listen(
                    input,
                    'change',
                    /*input_change_handler*/ ctx[1]
                );
                mounted = true;
            }
        },
        p(ctx, [dirty]) {
            if (dirty & /*visible*/ 1) {
                input.checked = /*visible*/ ctx[0];
            }

            if (/*visible*/ ctx[0]) {
                if (if_block) {
                    if (dirty & /*visible*/ 1) {
                        transition_in(if_block, 1);
                    }
                } else {
                    if_block = create_if_block(ctx);
                    if_block.c();
                    transition_in(if_block, 1);
                    if_block.m(if_block_anchor.parentNode, if_block_anchor);
                }
            } else if (if_block) {
                group_outros();

                transition_out(if_block, 1, 1, () => {
                    if_block = null;
                });

                check_outros();
            }
        },
        i(local) {
            if (current) return;
            transition_in(if_block);
            current = true;
        },
        o(local) {
            transition_out(if_block);
            current = false;
        },
        d(detaching) {
            if (detaching) detach(label);
            if (detaching) detach(t1);
            if (if_block) if_block.d(detaching);
            if (detaching) detach(if_block_anchor);
            mounted = false;
            dispose();
        },
    };
}

function instance($$self, $$props, $$invalidate) {
    let visible = true;

    function input_change_handler() {
        visible = this.checked;
        $$invalidate(0, visible);
    }

    return [visible, input_change_handler];
}

class App extends SvelteComponent {
    constructor(options) {
        super();
        init(this, options, instance, create_fragment, safe_not_equal, {});
    }
}

export default App;

we can see that svelte is calling create_bidirectional_transition under the hood. Which just adds a style=animation: ${time} linear 0ms 1 normal both running tag and a svelte specific class that specifies the tranistion type to the html component ( in our case the blur effect ).

Under the hood svelte rapidly switches between the 4 stage animation which we can see here :

The first enter stage is in this function :

i(local) {
    if (current) return;

    add_render_callback(() => {
    if (!current) return;
    if (!div_transition)
        div_transition = create_bidirectional_transition(
        div,
        blur,
        { amount: 10 },
        true
        );
    div_transition.run(1);
    });

    current = true;
}

The second leave stage is in this function :

o(local) {
    if (!div_transition)
    div_transition = create_bidirectional_transition(
        div,
        blur,
        { amount: 10 },
        false
    );
    div_transition.run(0);
    current = false;
}

With this we can conclude that svelte is also running ( albeit with less hassle ) a 4 stage animation ( like the one we created before with alpine.js ).

Then what the heck does import { blur } from 'svelte/transition'; do ?

Lets refer to the source

export function blur(
    node: Element,
    {
        delay = 0,
        duration = 400,
        easing = cubicInOut,
        amount = 5,
        opacity = 0,
    }: BlurParams = {}
): TransitionConfig {
    const style = getComputedStyle(node);
    const target_opacity = +style.opacity;
    const f = style.filter === 'none' ? '' : style.filter;

    const od = target_opacity * (1 - opacity);
    const [value, unit] = split_css_unit(amount);
    return {
        delay,
        duration,
        easing,
        css: (_t, u) =>
            `opacity: ${target_opacity - od * u}; filter: ${f} blur(${
                u * value
            }${unit});`,
    };
}

We can see that svelte is running a:

  1. duration of 400
  2. easing is cubic-in-out
  3. With optional control of opacity

Overall pretty close to our alpinejs implementation.

Conclusion

While the transition is not perfect ( nothing is ), it gives developers a taste of what's possible with alpine.js.

The vast majority of the web is based on MPA and alpine.js is progressively enhancing those MPA's with SPA like features.

Developers should add more eye candy into their good ol' dandy website instead of chasing after the shiny new JS frameworks.

If you have see any problems in this article please raise an issue the github repository