I came across this amazing Dribbble shot by Jakub Reis a while back. It caught my eye and I knew that I just had to try recreating it in code. At that moment, I didn’t know how. I tried out a bunch of different things, and about a year later, I finally managed to make this demo.

I learned a couple of things along the way, so let me take you on a little journey of what I did to make this because you may learn a thing or two as well.

See the Pen Opening screen for a banking app by Kirill Kiyutin (@kiyutink) on CodePen.

Step 1: Split the work into parts

I watched the original GIF many times. My goal was to split the animation into small, digestible chunks and I was able to break it down like this:

I know, it looks a lot — but we can do this!

Step 2: Take the original demo apart frame-by-frame

I needed to extract as much info as I could out of the original GIF to have a good understanding of the animation, so I split it up into single frames. There actually are a lot of services that can do this for us. I used one at ezgif.com but it could have just as easily been something else. Either way, this enables us to get details such as the colors, sizes, and proportions of all the different elements we need to create.

Oh, and we still need to turn the fingerprint into an SVG. Again, there are plenty of apps that will help us here. I used Adobe Illustrator to trace the fingerprint with the pen tool to get this set of paths:

See the Pen css-t. paths by Kirill Kiyutin (@kiyutink) on CodePen.

We’ll go through the same process with the line chart that appears towards the end of the animation, so might as well keep that vector editor open. 🙂

Step 3: Implement the animations

I’ll explain how the animations work in the final pen, but you can also find some of the unsuccessful approaches I took along the way in the end of the article.

I’ll focus on the important parts here and you can refer to the demos for the full code.

Filling the fingerprint

Let’s create the HTML structure of the phone screen and the fingerprint.

<div class="demo"> <div class="demo__screen demo__screen--clickable"> <svg class="demo__fprint" viewBox="0 0 180 320"> <!-- removes-forwards and removes-backwards classes will be helpful later on --> <path class="demo__fprint-path demo__fprint-path--removes-backwards demo__fprint-path--pinkish" d="M46.1,214.3c0,0-4.7-15.6,4.1-33.3"/> <path class="demo__fprint-path demo__fprint-path--removes-backwards demo__fprint-path--purplish" d="M53.5,176.8c0,0,18.2-30.3,57.5-13.7"/> <path class="demo__fprint-path demo__fprint-path--removes-forwards demo__fprint-path--pinkish" d="M115.8,166.5c0,0,19.1,8.7,19.6,38.4"/> <!-- ... and about 20 more paths like this --> </svg>

The styles are quite simple so far. Note that I am using Sass throughout the demo — I find that it helps keep the work clean and helps with some of the heavier lifting we need to do.

// I use a $scale variable to quickly change the scaling of the whole pen, so I can focus on the animation and decide on the size later on. $scale: 1.65; $purplish-color: #8742cc; $pinkish-color: #a94a8c; $bg-color: #372546; // The main container .demo { background: linear-gradient(45deg, lighten($pinkish-color, 10%), lighten($purplish-color, 10%)); min-height: 100vh; display: flex; justify-content: center; align-items: center; font-size: 0; user-select: none; overflow: hidden; position: relative; // The screen that holds the login component &__screen { position: relative; background-color: $bg-color; overflow: hidden; flex-shrink: 0; &--clickable { cursor: pointer; -webkit-tap-highlight-color: transparent; } } // Styles the fingerprint SVG paths &__fprint-path { stroke-width: 2.5px; stroke-linecap: round; fill: none; stroke: white; visibility: hidden; transition: opacity 0.5s ease; &--pinkish { stroke: $pinkish-color; } &--purplish { stroke: $purplish-color; } } // Sizes positions the fingerprint SVG &__fprint { width: 180px * $scale; height: 320px * $scale; position: relative; top: 20px * $scale; overflow: visible; // This is going to serve as background to show "unfilled" paths. we're gonna remove it at the moment where the filling animation is over background-image: url('https://kiyutink.github.io/svg/fprintBackground.svg'); background-size: cover; &--no-bg { background-image: none; } } }

Now the hard part: making the fingerprint interactive. You can read about the animation of SVG lines here. That’s the method we’ll use to fill in each individual path.

Let’s create a class that describes a path element so that it’s easier to manipulate the paths later on.

class Path { constructor(selector, index) { this.index = index; this.querySelection = document.querySelectorAll(selector)[index]; this.length = this.querySelection.getTotalLength(); this.$ = $(selector).eq(index); this.setDasharray(); this.removesForwards = this.$.hasClass('demo__fprint-path--removes-forwards'); } setDasharray() { this.$.css('stroke-dasharray', `${this.length} ${this.length + 2}`); return this; } offset(ratio) { this.$.css('stroke-dashoffset', -this.length * ratio + 1); return this; } makeVisible() { this.$.css('visibility', 'visible'); return this; } }

The general idea is this: Create an instance of this class for each path that we have in the fingerprint, and modify them in every frame. The paths will start with an offset ratio of -1 (fully invisible) and then will increase the offset ratio (which we’ll refer to as “offset” from here on) by a constant value each frame until they get to 0 (fully visible). The filling animation will be over at this point.

If you’ve never animated anything with this frame-by-frame approach, here’s a very simple demo to help understand how this works:

See the Pen 60fps raf animation proof of concept by Kirill Kiyutin (@kiyutink) on CodePen.

We should also handle the case where the user stops tapping or pressing the mouse button. In this case, we will animate in the opposite direction (subtracting a constant value from the offset each frame until it gets to -1 again).

Let’s create the function that calculates the offset increment for every frame — this’ll be useful later on.

function getPropertyIncrement(startValue, endValue, transitionDuration) { // We animate at 60 fps const TICK_TIME = 1000 / 60; const ticksToComplete = transitionDuration / TICK_TIME; return (endValue - startValue) / ticksToComplete; }

Now it’s time to animate! We will keep the fingerprint paths in a single array:

let fprintPaths = []; // We create an instance of Path for every existing path. // We don't want the paths to be visible at first and then // disappear after the JavaScript runs, so we set them to // be invisible in CSS. That way we can offset them first // and then make them visible. for (let i = 0; i < $(fprintPathSelector).length; i++) { fprintPaths.push(new Path(fprintPathSelector, i)); fprintPaths[i].offset(-1).makeVisible(); }

We will go through that array for each frame in the animation, animating the paths one by one:

let fprintTick = getPropertyIncrement(0, 1, TIME_TO_FILL_FPRINT); function fprintFrame(timestamp) { // We don't want to paint if less than 1000 / 65 ms elapsed // since the last frame (because there are faster screens // out there and we want the animation to look the same on // all devices). We use 65 instead of 60 because, even on // 60 Hz screens, `requestAnimationFrame` can sometimes be called // a little sooner, which can result in a skipped frame. if (timestamp - lastRafCallTimestamp >= 1000 / 65) { lastRafCallTimestamp = timestamp; curFprintPathsOffset += fprintTick * fprintProgressionDirection; offsetAllFprintPaths(curFprintPathsOffset); } // Schedule the next frame if the animation isn't over if (curFprintPathsOffset >= -1 && curFprintPathsOffset <= 0) { isFprintAnimationInProgress = true; window.requestAnimationFrame(fprintFrame); } // The animation is over. We can schedule next animation steps else if (curFprintPathsOffset > 0) { curFprintPathsOffset = 0; offsetAllFprintPaths(curFprintPathsOffset); isFprintAnimationInProgress = false; isFprintAnimationOver = true; // Remove the background with grey paths $fprint.addClass('demo__fprint--no-bg'); // Schedule the next animation step - transforming one of the paths into a string // (this function is not implemented at this step yet, but we'll do that soon) startElasticAnimation(); // Schedule the fingerprint removal (removeFprint function will be implemented in the next section) window.requestAnimationFrame(removeFprint); } // The fingerprint is back to the original state (the user has stopped holding the mouse down) else if (curFprintPathsOffset < -1) { curFprintPathsOffset = -1; offsetAllFprintPaths(curFprintPathsOffset); isFprintAnimationInProgress = false; } }

And we’ll attach some event listeners to the demo:

$screen.on('mousedown touchstart', function() { fprintProgressionDirection = 1; // If the animation is already in progress, // we don't schedule the next frame since it's // already scheduled in the `fprintFrame`. Also, // we obviously don't schedule it if the animation // is already over. That's why we have two separate // flags for these conditions. if (!isFprintAnimationInProgress && !isFprintAnimationOver) window.requestAnimationFrame(fprintFrame); }) // On `mouseup` / `touchend` we flip the animation direction $(document).on('mouseup touchend', function() { fprintProgressionDirection = -1; if (!isFprintAnimationInProgress && !isFprintAnimationOver) window.requestAnimationFrame(fprintFrame); })

…and now we should be done with the first step! Here’s how our work looks at this step:

See the Pen css-t. step 1 by Kirill Kiyutin (@kiyutink) on CodePen.

Removing the fingerprint

This part is pretty similar to the first one, only now we have to account for the fact that some of the paths remove in one direction and the rest of them in the other. That’s why we added the --removes-forwards modifier earlier.

First, we’ll have two additional arrays: one for the paths that are removed forwards and another one for the ones that are removed backwards:

const fprintPathsFirstHalf = []; const fprintPathsSecondHalf = []; for (let i = 0; i < $(fprintPathSelector).length; i++) { // ... if (fprintPaths[i].removesForwards) fprintPathsSecondHalf.push(fprintPaths[i]); else fprintPathsFirstHalf.push(fprintPaths[i]); }

…and we’ll write a function that offsets them in the right direction:

function offsetFprintPathsByHalves(ratio) { fprintPathsFirstHalf.forEach(path => path.offset(ratio)); fprintPathsSecondHalf.forEach(path => path.offset(-ratio)); }

We’re also going to need a function that draws the frames:

function removeFprintFrame(timestamp) { // Drop the frame if we're faster than 65 fps if (timestamp - lastRafCallTimestamp >= 1000 / 65) { curFprintPathsOffset += fprintTick * fprintProgressionDirection; offsetFprintPathsByHalves(curFprintPathsOffset); lastRafCallTimestamp = timestamp; } // Schedule the next frame if the animation isn't over if (curFprintPathsOffset >= -1) window.requestAnimationFrame(removeFprintFrame); else { // Due to the floating point errors, the final offset might be // slightly less than -1, so if it exceeds that, we'll just // assign -1 to it and animate one more frame curFprintPathsOffset = -1; offsetAllFprintPaths(curFprintPathsOffset); } } function removeFprint() { fprintProgressionDirection = -1; window.requestAnimationFrame(removeFprintFrame); }

Now all that’s left is to call removeFprint when we’re done filling the fingerprint:

function fprintFrame(timestamp) { // ... else if (curFprintPathsOffset > 0) { // ... window.requestAnimationFrame(removeFprint); } // ... }

Let’s check our work now:

See the Pen css-t. part 2 by Kirill Kiyutin (@kiyutink) on CodePen.

Animating the path ends

You can see that, as the fingerprint is almost removed, some of its paths are longer than they were in the beginning. I moved them into separate paths that start animating at the right moment. I could incorporate them into the existing paths, but it would be much harder and at 60fps would make next-to-no difference.

Let’s create them:

<path class="demo__ending-path demo__ending-path--pinkish" d="M48.4,220c-5.8,4.2-6.9,11.5-7.6,18.1c-0.8,6.7-0.9,14.9-9.9,12.4c-9.1-2.5-14.7-5.4-19.9-13.4c-3.4-5.2-0.4-12.3,2.3-17.2c3.2-5.9,6.8-13,14.5-11.6c3.5,0.6,7.7,3.4,4.5,7.1"/> <!-- and 5 more paths like this -->

…and apply some basic styles:

&__ending-path { fill: none; stroke-width: 2.5px; stroke-dasharray: 60 1000; stroke-dashoffset: 61; stroke-linecap: round; will-change: stroke-dashoffset, stroke-dasharray, opacity; transform: translateZ(0); transition: stroke-dashoffset 1s ease, stroke-dasharray 0.5s linear, opacity 0.75s ease; &--removed { stroke-dashoffset: -130; stroke-dasharray: 5 1000; } &--transparent { opacity: 0; } &--pinkish { stroke: $pinkish-color; } &--purplish { stroke: $purplish-color; } }

Now, we have to add the --removed modifier to flow these paths in at the right moment:

function removeFprint() { $endingPaths.addClass('demo__ending-path--removed'); setTimeout(() => { $endingPaths.addClass('demo__ending-path--transparent'); }, TIME_TO_REMOVE_FPRINT * 0.9); // ... }

Now our work is really starting to take shape:

See the Pen css-t. part 3 by Kirill Kiyutin (@kiyutink) on CodePen.

Morphing the fingerprint

OK, I found this part to be really hard to do on my own, but it’s really easy to implement with GSAP’s morphSVG plugin.

Let’s create the invisible paths (well, a path and a line to be exact 🙂) that will be the keyframes for our string:

<line id='demo__straight-path' x1="0" y1="151.3" x2="180" y2="151.3"/> <path class="demo__hidden-path" id='demo__arc-to-top' d="M0,148.4c62.3-13.5,122.3-13.5,180,0"/>

Then we’ll use morphSVG to transition the path in between the keyframes:

const $elasticPath = $('#demo__elastic-path'); const ELASTIC_TRANSITION_TIME_TO_STRAIGHT = 250; const WOBBLE_TIME = 1000; function startElasticAnimation() { $elasticPath.css('stroke-dasharray', 'none'); const elasticAnimationTimeline = new TimelineLite(); elasticAnimationTimeline .to('#demo__elastic-path', ELASTIC_TRANSITION_TIME_TO_STRAIGHT / 1000, { delay: TIME_TO_REMOVE_FPRINT / 1000 * 0.7, morphSVG: '#demo__arc-to-top' }) .to('#demo__elastic-path', WOBBLE_TIME / 1000, { morphSVG: '#demo__straight-path', // I played with the easing a bit to get that "vibration" effect ease: Elastic.easeOut.config(1, 0.3) }) }

We’ll call this function inside the fprintFrame once the fingerprint is filled:

function fprintFrame(timestamp) { // ... else if (curFprintPathsOffset > 0) { // ... startElasticAnimation(); // ... } // ... }

The outcome is this:

See the Pen css-t. part 4 by Kirill Kiyutin (@kiyutink) on CodePen.

Animating the floating bullet

For this, I used some simple straightforward CSS animations. I chose the timing functions to emulate the gravity. You can play around with the timing functions here or here.

Let’s create a div:

<div class="demo__bullet"></div>

…and apply some styles to it:

&__bullet { position: absolute; width: 4px * $scale; height: 4px * $scale; background-color: white; border-radius: 50%; top: 210px * $scale; left: 88px * $scale; opacity: 0; transition: all 0.7s cubic-bezier(0.455, 0.030, 0.515, 0.955); will-change: transform, opacity; // This will be applied after the bullet has descended, to create a transparent "aura" around it &--with-aura { box-shadow: 0 0 0 3px * $scale rgba(255, 255, 255, 0.3); } // This will be applied to make the bullet go up &--elevated { transform: translate3d(0, -250px * $scale, 0); opacity: 1; } // This will be applied to make the bullet go down &--descended { transform: translate3d(0, 30px * $scale, 0); opacity: 1; transition: all 0.6s cubic-bezier(0.285, 0.210, 0.605, 0.910); } }

Then we tie it together by adding and removing classes based on a user’s interactions:

const DELAY_TO_BULLET_AURA = 300; const ELEVATION_TIME = 700; const DELAY_AFTER_ELEVATION = 700; const $bullet = $('.demo__bullet'); function elevateBullet() { $bullet.addClass('demo__bullet--elevated'); } function descendBullet() { $bullet.addClass('demo__bullet--descended').removeClass('demo__bullet--elevated'); animateBulletAura(); } function animateBulletAura() { setTimeout(() => $bullet.addClass('demo__bullet--with-aura'), DELAY_TO_BULLET_AURA); } function animateBullet() { elevateBullet(); $screen.removeClass('demo__screen--clickable'); setTimeout(descendBullet, ELEVATION_TIME + DELAY_AFTER_ELEVATION); }

Now, we need to call the animateBullet function:

function startElasticAnimation() { // ... animateBullet(); }

Here’s where we are at this point:

See the Pen css-t. part 5 by Kirill Kiyutin (@kiyutink) on CodePen.

Morphing the string into a graph

Now, let’s turn that string into a graph where the bullet can land. We’ll add another keyframe to the morphSVG animation.

<path class="demo__hidden-path" id='demo__curve' d="M0,140.2c13.1-10.5,34.7-17,48.5-4.1c5.5,5.2,7.6,12.1,9.2,19.2c2.4,10.5,4.3,21,7.2,31.4c2.4,8.6,4.3,19.6,10.4,26.7c4.3,5,17.7,13.4,23.1,4.8c5.9-9.4,6.8-22.5,9.7-33c4.9-17.8,13-14.6,15.7-14.6c1.8,0,9,2.3,15.4,5.4c6.2,3,11.9,7.7,17.9,11.2c7,4.1,16.5,9.2,22.8,6.6"/>

We add this keyframe into our timeline like this:

const DELAY_TO_CURVE = 350; const ELASTIC_TRANSITION_TIME_TO_CURVED = 300; function startElasticAnimation() { // ... elasticAnimationTimeline // ... .to('#demo__elastic-path', ELASTIC_TRANSITION_TIME_TO_CURVED / 1000, { delay: DELAY_TO_CURVE / 1000, morphSVG: '#demo__curve' }) // ... }

Here’s what we get:

See the Pen css-t. part 6 by Kirill Kiyutin (@kiyutink) on CodePen.

Exploding the particles

This is a fun animation. First, we’ll create a couple of new divs that contain the particles that explode:

<div class="demo__logo-particles"> <div class="demo__logo-particle"></div> <!-- and several more of these --> </div> <div class="demo__money-particles"> <div class="demo__money-particle"></div> <!-- and several more of these --> </div>

The two explosions are practically the same with the exception of a few parameters. That’s where SCSS mixins will come in handy. We can write the function once and use it on our divs.

@mixin particlesContainer($top) { position: absolute; width: 2px * $scale; height: 2px * $scale; left: 89px * $scale; top: $top * $scale; // We'll hide the whole container to not show the particles initially opacity: 0; &--visible { opacity: 1; } } // The $sweep parameter shows how far from the center (horizontally) the initial positions of the particles can be @mixin particle($sweep, $time) { width: 1.5px * $scale; height: 1.5px * $scale; border-radius: 50%; background-color: white; opacity: 1; transition: all $time ease; position: absolute; will-change: transform; // Phones can't handle the particles very well :( @media (max-width: 400px) { display: none; } @for $i from 1 through 30 { &:nth-child(#{$i}) { left: (random($sweep) - $sweep / 2) * $scale + px; @if random(100) > 50 { background-color: $purplish-color; } @else { background-color: $pinkish-color; } } &--exploded:nth-child(#{$i}) { transform: translate3d((random(110) - 55) * $scale + px, random(35) * $scale + px, 0); opacity: 0; } } }

Note the comment in the code that the particles don’t perform particularly well on less powerful devices such as phones. Perhaps there’s another approach here that would solve this if anyone has ideas and wants to chime in.

Alright, let’s put the mixins to use on the elements:

&__logo-particles { @include particlesContainer(15px); } &__logo-particle { @include particle(50, 1.7s); } &__money-particles { @include particlesContainer(100px); } &__money-particle { @include particle(100, 1.5s); }

Now we add the classes to the divs at the right time in JavaScript:

const DELAY_TO_ANIMATE_MONEY_PARTICLES = 300; const DELAY_TO_ANIMATE_LOGO_PARTICLES = 500; const $moneyParticles = $('.demo__money-particle'); const $moneyParticlesContainer = $('.demo__money-particles'); const $logoParticlesContainer = $('.demo__logo-particles'); const $logoParticles = $('.demo__logo-particle'); function animateMoneyParticles() { setTimeout(() => { $moneyParticlesContainer.addClass('demo__money-particles--visible') $moneyParticles.addClass('demo__money-particle--exploded'); }, DELAY_TO_ANIMATE_MONEY_PARTICLES); } function animateLogoParticles() { setTimeout(() => { $logoParticlesContainer.addClass('demo__logo-particles--visible') $logoParticles.addClass('demo__logo-particle--exploded'); }, DELAY_TO_ANIMATE_LOGO_PARTICLES); } function elevateBullet() { // ... animateMoneyParticles(); animateLogoParticles(); }

Here’s where we’re at:

See the Pen css-t. part 7 by Kirill Kiyutin (@kiyutink) on CodePen.

Animating the account balance

Every digit will have a few random numbers that we’ll scroll through:

<div class="demo__money"> <div class="demo__money-currency">$</div> <!-- every digit will be a div like this one --> <div class="demo__money-digit"> 1 2 3 4 5 6 7 8 1 </div> // ... </div>

We will put different transition times on all of the digits so that the animations are staggered. We can use a SCSS loop for that:

&__money-digit { // ... // we start from 2 because the first child is the currency sign :) @for $i from 2 through 6 { &:nth-child(#{$i}) { transition: transform 0.1s * $i + 0.2s ease; transition-delay: 0.3s; transform: translate3d(0, -26px * $scale * 8, 0); } &--visible:nth-child(#{$i}) { transform: none; } } }

All that’s left is to add the CSS classes at the right time:

const $money = $('.demo__money'); const $moneyDigits = $('.demo__money-digit'); function animateMoney() { $money.addClass('demo__money--visible'); $moneyDigits.addClass('demo__money-digit--visible'); } function descendBullet() { // ... animateMoney(); // ... }

Now sit back and marvel at our work:

See the Pen css-t. part 8 by Kirill Kiyutin (@kiyutink) on CodePen.

The rest of the animations are fairly simple and involve light CSS transitions, so I won’t get into them to keep things brief. You can see all of the final code in the completed demo.

View Demo

Some final words

In my early attempts I tried using CSS transitions for all of the animation work. I found it virtually impossible to control the progress and direction of the animation, so shortly I abandoned that idea and waited a month or so before starting again. In reality, if I knew back then that the Web Animations API was a thing, I would have tried to make use of it.

I tried making the explosion with Canvas for better performance (using this article as a reference), but I found it difficult to control the frame rate with two separate requestAnimationFrame chains. If you know how to do that, then maybe you can tell me in the comments (or write an article for CSS-Tricks 🙂).

chains. If you know how to do that, then maybe you can tell me in the comments (or write an article for CSS-Tricks 🙂). After I got a first working prototype, I was really unhappy with its performance. I was hitting around 40-50fps on a PC, not to mention phones at all. I spent a lot of time optimizing the code and this article was a lot of help.

You can see that the graph has a gradient. I did that by declaring a gradient directly in the SVG defs block:

<defs> <linearGradient id="linear" x1="0%" y1="0%" x2="100%" y2="0%"> <stop offset="0%" stop-color="#8742cc"/> <stop offset="100%" stop-color="#a94a8c"/> </linearGradient> </defs>

…and then applied it in the CSS properties:

fill: url(#linear); stroke: url(#linear);

The whole process from start to finish — discovering the Dribbble shot and finishing the work — took me about a year. I was taking month-long breaks here and there either because I didn’t know how to approach a particular aspect or I simply didn’t have enough free time to work on it. The entire process was a really valuable experience and I learned a lot of new things along the way.

That being said, the biggest lesson to take away from this is that there’s no need to shy away from taking on an ambitious task, or feel discouraged if you don’t know how to approach it at first. The web is a big place and there is plenty of space to figure things out as you go along.