Building a swipeable card stack with interact.js and Svelte

smth

Sam Smith

Posted on October 7, 2019

Building a swipeable card stack with interact.js and Svelte

I've been waiting for an opportunity to dip my toe into some Svelte for a while. Faced with a little free time, I decided to create that opportunity. For anyone who hasn't heard of Svelte, it is a JavaScript / component framework, along the lines of React and Vue, but with an added compile step at build time. So what did I decide to use it for? Inspired by this post by Mateusz Rybczonek, I set myself the challenge of building a swipeable card stack interface. You can see the result here.

In this article I will explain the steps I took in building the above interface, and detail some of the approaches I took.

Step 1: Sapper

I really like static site generators (SSG), and will usually reach for one if a project has static content (such as this one). Fortunately there is a Svelte based SSG; its called Sapper. The Sapper template makes a pretty good starting point for a project like this, and comes in Rollup and Webpack variants. I went for Rollup, getting up and running like so:

npx degit "sveltejs/sapper-template#rollup" my-app
cd my-app
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

There were a few things in this template that I didn't need, which were either deleted or repurposed. The about and blog routes were removed, but not before repurposing blog/_posts.js, blog/index.json.js and blog/index.svelte to deliver the content for my app.

I used the include Nav component as a guide to creating my first Svelte component, the only component in this app. I'll get back to that in a moment.

Step 2: (optional) PostCSS

I like processing my styles with PostCSS, I tend to use preset-env to enable nesting and autoprefixing. I used this Tailwind template as a guide to set this up with Sapper. Installing the required/desired packages, editing the Rollup config, and importing the CSS file into server.js.

npm install --save-dev postcss postcss-import rollup-plugin-postcss svelte-preprocess postcss-preset-env cssnano
Enter fullscreen mode Exit fullscreen mode
// rollup.config.js
// ...
import getPreprocessor from 'svelte-preprocess';
import postcss from 'rollup-plugin-postcss';
import path from 'path';
// ...
const postcssPlugins = [
    require("postcss-import")(),
    require("postcss-preset-env")({
    features: {
      'nesting-rules': true
    }
  }),
    require("cssnano")()
]
const preprocess = getPreprocessor({
    transformers: {
        postcss: {
            plugins: postcssPlugins
        }
    }
});
// ...
export default {
    client: {
      // ...
        plugins: [
            postcss({extract: true}),
            svelte({
                // ...
                preprocess
            }),
            // ...
        ],
        // ...
    },
    server: {
        // ...
        plugins: [
            // ...
      postcss({
                plugins: postcssPlugins,
                extract: path.resolve(__dirname, './static/global.css')
            })
        ],
        // ...
    },
    // ...
};
Enter fullscreen mode Exit fullscreen mode

(Add styles to src/css/main.css)

// src/server.js
// ...
import './css/main.css';
// ...
Enter fullscreen mode Exit fullscreen mode

Its worth noting that using this particular approach means you won't be taking advantage of Sapper's code splitting when it comes to CSS, but given that this would be a single page app, I didn't see that as being an issue.

Step 3: Creating the Card component

There will be multiple cards in this interface, so it makes sense to create a component for them. This simply needs to be a template with some props, like so:

<!-- components/Card.svelte -->
<script>
    export let isCurrent;
    export let cardContent;
</script>

<p class="card" data-dragging="false" data-status="{isCurrent === true ? 'current' : 'waiting'}">
    <span class="card_content">{cardContent}</span>
</p>
Enter fullscreen mode Exit fullscreen mode

I've given the card a class so it can be styled as such, plus a couple of data attributes to hold some contextual information that will become useful later. All three attributes could be handled with classes, but I like to use a different syntax for contextual stuff to make my CSS easier to read. You might also think that the JavaScript to handle the dragging etc should live in this file. When I tried this I found that the script would run for each instance of the component (which is not what I wanted). There's probably a way of making it behave as I wanted, but as I had a layout template not really being used for much, I decided to put all the logic there.

If you were writing your CSS inside the component, it would live in a style tag within this file. My CSS lives in a good old CSS file. Its pretty simple so I won't go over it here. Essentially I have a fixed size card component, absolutely positioned.

Step 4: Putting your cards on the table

In index.svelte I add instances of the Card component to the page. As mentioned earlier, I made use of the blog code to store the content of each card in an array, which I then iterated over like so:

{#each cards as card, i}
    <Card cardContent={card.content} isCurrent={i === 0}/>
{/each}
Enter fullscreen mode Exit fullscreen mode

Setting isCurrent to true for the first item in the array. For simplicity you might just want to put the cards directly into this page:

<Card cardContent={"One"} isCurrent={true}/>
<Card cardContent={"Two"} isCurrent={false}/>
<Card cardContent={"Three"} isCurrent={false}/>
Enter fullscreen mode Exit fullscreen mode

In either case, you also need to import the component into the page:

<script>
    import Card from '../components/Card.svelte';
</script>
Enter fullscreen mode Exit fullscreen mode

Step 5: Draggable cards

Now for the fun stuff, the interactivity. I put all the interactivity logic in my _layout.svelte file, which until this point was pretty much empty. The dragging relies on interact.js which we need to add to our project before importing into our template.

npm install --save-dev interactjs
Enter fullscreen mode Exit fullscreen mode

The basis for the below code is the dragging example given on the interact.js website. The alterations and additions I will outline here. First thing to note is in Svelte anything that relies on the DOM being ready goes inside an onMount function. To use this function, we first need to import { onMount } from 'svelte'. I took the concept of "interact threshold" and how that relates to rotation from Mateusz Rybczonek's article. interactThreshold represents how far a card needs to be dragged before it is considered dismissed. The interact.js example stores the draggable objects position in data attributes, and adds inline styles to transform its position. Preferring to keep the styles in the style sheet, I used CSS custom properties to store these variables, which are referenced in the CSS. To access the custom properties in the JavaScript, I used Andy Bell's getCSSCustomProp function. Finally, inside the onend function, we check whether the card has moved a sufficient amount to dismiss. If so we remove its current status and give it to the next card. We also move it off the screen to the left or right, depending on whether its x coordinate is positive or negative. If the card has not moved a sufficient amount, we reset its position and rotation custom properties.

<script context="module">
    import interact from "interactjs";
</script>

<script>
    import { onMount } from 'svelte';

    const interactThreshold = 100;
    const interactMaxRotation = 15;

    let rotation = 0;
    let x = 0;
    let y = 0;

    // https://hankchizljaw.com/wrote/get-css-custom-property-value-with-javascript/#heading-the-getcsscustomprop-function
    const getCSSCustomProp = (propKey, element = document.documentElement, castAs = 'string') => {
        let response = getComputedStyle(element).getPropertyValue(propKey);

        // Tidy up the string if there's something to work with
        if (response.length) {
            response = response.replace(/\'|"/g, '').trim();
        }

        // Convert the response into a whatever type we wanted
        switch (castAs) {
            case 'number':
            case 'int':
                return parseInt(response, 10);
            case 'float':
                return parseFloat(response, 10);
            case 'boolean':
            case 'bool':
                return response === 'true' || response === '1';
        }

        // Return the string response by default
        return response;
    };

    function dragMoveListener (event) {
        var target = event.target

        // keep the dragged position in the custom properties
        x = (getCSSCustomProp('--card-x', target, 'float') || 0) + event.dx
        y = (getCSSCustomProp('--card-y', target, 'float') || 0) + event.dy

        // add rotation based on card position
        rotation = interactMaxRotation * (x / interactThreshold);
        if (rotation > interactMaxRotation) rotation = interactMaxRotation;
        else if (rotation < -interactMaxRotation) rotation = -interactMaxRotation;

        // update styles
        target.style.setProperty('--card-x', x + 'px');
        target.style.setProperty('--card-y', y + 'px');
        target.style.setProperty('--card-r', rotation + 'deg');
    }

    onMount(() => {
        // get viewport width
        const vw = document.documentElement.clientWidth;
        // create an off canvas x coordinate
        let offX = 400;
        if (vw > 400) {
            offX = vw;
        }

        // interact.js
        interact('.card[data-status="current"]:not(:last-child)').draggable({

            onstart: () => {
                // signify dragging
                event.target.setAttribute('data-dragging', true);
            },

            // call this function on every dragmove event
            onmove: dragMoveListener,

            // call this function on every dragend event
            onend: (event) => {
                // signify dragging stopped
                event.target.setAttribute('data-dragging', false);

                // calculate how far card moved
                let moved = (Math.sqrt(Math.pow(event.pageX - event.x0, 2) + Math.pow(event.pageY - event.y0, 2) | 0));

                if (moved > interactThreshold) {
                    // remove card
                    event.target.setAttribute('data-status', "done");
                    if (x > 0) {
                        x = offX;
                    } else {
                        x = (offX * -1);
                    }
                    // activate next card
                    event.target.nextElementSibling.setAttribute('data-status', 'current');
                }
                else {
                    // reset vars
                    x = 0;
                    y = 0;
                    rotation = 0;
                    // update rotation
                    event.target.style.setProperty('--card-r', rotation + 'deg');
                }
                // update x and y pos
                event.target.style.setProperty('--card-x', x + 'px');
                event.target.style.setProperty('--card-y', y + 'px');
            }
        });
    });

</script>

<main class="container">
    <slot></slot>
</main>
Enter fullscreen mode Exit fullscreen mode

That's a big chunk of code, but pretty self explanatory I hope.

Step 6: Details and finessing

With the functionality in place, there remains some refining to do. For example, you're probably going to want to include some transitions in your CSS, to make the moving and rotations smooth. An important point to consider is that having a transition on the card while it is being dragged will cause problems. That's why we added the data-dragging attribute that is toggled to true when a card is being dragged. It means you can safely add something like this to your CSS:

.card[data-dragging="false"] {
    transition: transform 0.5s;
}
Enter fullscreen mode Exit fullscreen mode

I also added a small rotation to the next card in the stack, to indicate that there is a card below. There are many ways you could design this though, I'll leave that to you.

💖 💪 🙅 🚩
smth
Sam Smith

Posted on October 7, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related