Building a swipeable card stack

I’d been wanting an opportunity to dip my toe into Svelte for a while, so I decided to build a swipeable card stack with Svelte and interact.js.

18 minute read

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:

shell
npx degit "sveltejs/sapper-template#rollup" my-app
cd my-app
npm install
npm run dev

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 included 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.

shell
npm install --save-dev postcss postcss-import rollup-plugin-postcss svelte-preprocess postcss-preset-env cssnano
js
// 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')
})
],
// ...
},
// ...
};

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

js
// src/server.js
// ...
import './css/main.css';
// ...

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:

svelte
<!-- 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>

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:

svelte
{#each cards as card, i}
<Card cardContent={card.content} isCurrent={i === 0}/>
{/each}

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:

svelte
<Card cardContent={"One"} isCurrent={true}/>
<Card cardContent={"Two"} isCurrent={false}/>
<Card cardContent={"Three"} isCurrent={false}/>

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

svelte
<script>
import Card from '../components/Card.svelte';
</script>

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.

shell
npm install --save-dev interactjs

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.

svelte
<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', "transition");
if (x > 0) {
x = offX;
} else {
x = (offX * -1);
}
// mark as done after CSS transition
event.target.addEventListener('transitionend', () => {
event.target.setAttribute('data-status', "done");
});
// 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>

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:

css
.card[data-dragging="false"] {
transition: transform 0.5s;
}

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.

Start a conversation. @ me on Mastodon.