Amazing macOS Dock animation in Svelte
Read in light, dark or sepia theme
macOS Dock, when you hover over it, has such a good feedback animation. The current icon your cursor is over becomes big, and the nearby icons become bigger too, but as you move your cursor, the icons' size changes based on distance from your cursor, kinda like this below ๐
Pardon the bad quality ๐. Pretty slick, right? So interactive, so playful, its just freaking good ๐
But here's a twist. That isn't macOS. There are no tooltips, no desktop icons, even the animation looks bouncier.
Ladies and Gentlemen, what you see above is the final product we're gonna make in this blog post using Svelte. In less than 100 lines of logic ๐
That looks way too good for just 100 lines ๐ง๐ง
I know!! That's the beauty of Svelte ๐
Don't believe me? You can check it out right here: Demo. And here's the original source code @ Github: Source
Feel free to read the source code directly, if you feel like you can understand what is going on just from reading the source. And if you can't, no problem, that's what this article is for ๐
Let's dive into it!!
Tech Stack
Here's what the tech stack is gonna be for this little demo:
- Svelte
- popmotion library
- Typescript 4
- Vite
What's with Vite? ๐ค
Vite is the new cool kid in the block ๐. Its basically a bundler and dev server like Webpack(With the Dev Server plugin), only it comes with everything pre-built and pre-configured, so you can jump into the code directly.
It won't change how we write the code, only the way we refer to our assets changes a bit, so you don't have to worry about it much.
Popmotion?
Yeah, this is an animation library on which the utterly awesome Framer Motion library is build. We don't really need the animation related stuff from this library, only a small helper function that is essential for this demo. Install it beforehand
npm i popmotion
Or if you're a Yarn
person
yarn add popmotion
Preliminary setup
Our demo won't look good without that slick background and the great CSS everywhere. So let's set up the basic shell of our application.
So let's start with the root component.
<!-- App.svelte -->
<script>
import Dock from './lib/Dock.svelte';
</script>
<main>
<Dock />
</main>
<style>
main {
max-width: 100vw;
height: 100vh;
background: url('/wallpaper.jpg');
background-size: cover;
background-position: center center;
}
</style>
Let's break it down
We're importing the
Dock
component. It's not declared yet, so there will be red squiggles in there. We'll declare it in a moment.We have a
main
tag enclosing theDock
element.We have our styles here. These are simply set so that the beautiful macOS Big Sur wallpaper takes up all the space and adjusts to different viewport sizes gracefully.
So we end with this beautiful view ๐
Notice we're referencing
wallpaper.jpg
as if its next to our current file, but it isn't. It's inside Vite'spublic
folder. You can refer to those files as if you're current component in inside thepublic
folder. This holds for<link>
,<img>
,<a>
tags and what not. Go crazy ๐คช!
The Dock
So let's set up the actual Dock.svelte
component.
Here it is ๐
<script lang="ts">
import DockItem from './DockItem.svelte';
const apps: string[] = [
'calendar',
'facetime',
'finder',
'launchpad',
'mail',
'maps',
'messages',
'photos',
'scared-cat',
'safari',
'system-preferences',
'view-source',
];
let mouseX: number | null = null;
</script>
<section class="dock-container">
<div
class="dock-el"
on:mousemove="{(event) => (mouseX = event.x)}"
on:mouseleave="{() => (mouseX = null)}"
>
{#each apps as appID}
<DockItem {mouseX} {appID} />
{/each}
</div>
</section>
<style>
.dock-container {
margin-bottom: 0.3rem;
left: 0;
bottom: 0;
z-index: 9900;
position: fixed;
width: 100%;
height: 5rem;
padding: 0.4rem;
display: flex;
justify-content: center;
}
.dock-el {
backdrop-filter: blur(5px);
background-color: hsla(240, 3%, 11%, 0.4);
box-shadow: inset 0 0 0 0.2px rgb(245 245 245 / 70%), hsla(0, 0%, 0%, 0.3) 2px 5px 19px 7px;
padding: 0.3rem;
border-radius: 1.2rem;
height: 100%;
display: flex;
align-items: flex-end;
}
</style>
Ooh, there's a lot going on here!! Let's break it down.
import DockItem from './DockItem.svelte';
const apps: string[] = [
'calendar',
'facetime',
'finder',
'launchpad',
'mail',
'maps',
'messages',
'photos',
'scared-cat',
'safari',
'system-preferences',
'view-source',
];
let mouseX: number | null = null;
We're importing the
DockItem
component. It is the heart of the whole demo, as all the animation is handled within it. I'll show you how to write it soon.Next up we have a list of all app IDs. Why are these needed? Because our app icons are stored by these names only, so we can easily
<img src=
them.And here we have a
mouseX
variable, which we're gonna be tracking the current x-coordinate of our mouse (Distance of mouse from the left side of the screen). Notice we have defined its type asnumber
ornull
. It will benull
when the mouse isn't on the dock, so as to prevent any unintended resizing of elements. So we initialize it asnull
.
Now, analyzing the html part:
<section class="dock-container">
<div
class="dock-el"
on:mousemove="{(event) => (mouseX = event.x)}"
on:mouseleave="{() => (mouseX = null)}"
>
{#each apps as appID}
<DockItem {mouseX} {appID} />
{/each}
</div>
</section>
We have a section.dock-container
as the container for the whole dock. This itself isn't the dock, it contains the dock. Its just an invisible container to center the dock.
Next here we have the div.dock-el
, which is the dock itself.
Notice the event handlers we have put on this element.
on:mousemove="{(event) => (mouseX = event.x)}"
This simply sets the value of the mouseX
variable to the value of mouse's x-coordinate, or simply the distance from the left side of the screen.
on:mouseleave="{() => (mouseX = null)}"
This simply sets the value of mouseX
back to null, as a way of telling our components that dock isn;t being hovered over, so it can disable the animation.
How does this tie into the whole thing? I'll explain soon.
Next up we have this little section:
{#each apps as appID}
<DockItem {mouseX} {appID} />
{/each}
We're looping through all the apps
, which if you remember is the array of IDs of apps, by which name the app icons are stored in the public
folder.
Next up is the DockItem
taking in mouseX
and current appID
as props. We'll see how these are used within the component.
The juicy stuff ๐
Let's do the DockItem
element now.
I'll drop the whole code here. Its quite big. Try reading it before the explanation, then the explanation will make more sense.
<script lang="ts">
import { interpolate } from 'popmotion';
import { spring } from 'svelte/motion';
import ButtonBase from './ButtonBase.svelte';
/** Block 1 */
export let appID: string;
export let mouseX: number | null;
let el: HTMLImageElement;
/** Block 2 */
const baseWidth = 57.6;
const distanceLimit = baseWidth * 6;
const beyondTheDistanceLimit = distanceLimit + 1;
const distanceInput = [
-distanceLimit,
-distanceLimit / 1.25,
-distanceLimit / 2,
0,
distanceLimit / 2,
distanceLimit / 1.25,
distanceLimit,
];
const widthOutput = [
baseWidth,
baseWidth * 1.1,
baseWidth * 1.618,
baseWidth * 2.618,
baseWidth * 1.618,
baseWidth * 1.1,
baseWidth,
];
let distance = beyondTheDistanceLimit;
const widthPX = spring(baseWidth, {
damping: 0.38,
stiffness: 0.1,
});
$: $widthPX = interpolate(distanceInput, widthOutput)(distance);
let width: string;
$: width = `${$widthPX / 16}rem`;
/** Block 3 */
let raf: number;
function animate(mouseX: number | null) {
if (el && mouseX !== null) {
const rect = el.getBoundingClientRect();
// get the x coordinate of the img DOMElement's center
// the left x coordinate plus the half of the width
const imgCenterX = rect.left + rect.width / 2;
// difference between the x coordinate value of the mouse pointer
// and the img center x coordinate value
const distanceDelta = mouseX - imgCenterX;
distance = distanceDelta;
return;
}
distance = beyondTheDistanceLimit;
}
$: raf = requestAnimationFrame(() => animate(mouseX));
</script>
<section>
<ButtonBase>
<img bind:this="{el}" src="/app-icons/{appID}/256.png" alt="" style="width: {width};" />
</ButtonBase>
</section>
So let's break down the JS part of it. Notice there are blocks
in the JS code. As in ๐
/** Block 1 */
There are blocks in the code. So let's break them down block by block.
Block 1
/** Block 1 */
export let appID: string;
export let mouseX: number | null;
let el: HTMLImageElement;
Nothing much here. We're exporting the 2 props we passed to this element in the Dock
component above
<DockItem {mouseX} {appID} />
Then we have an el
. This is going to hold the reference to the main image element. This image element is the one whose width would be animated. We have to refer it so as to compute the correct width during the animation.
Block 2
/** Block 2 */
const baseWidth = 57.6;
const distanceLimit = baseWidth * 6;
const beyondTheDistanceLimit = distanceLimit + 1;
const distanceInput = [
-distanceLimit,
-distanceLimit / 1.25,
-distanceLimit / 2,
0,
distanceLimit / 2,
distanceLimit / 1.25,
distanceLimit,
];
const widthOutput = [
baseWidth,
baseWidth * 1.1,
baseWidth * 1.618,
baseWidth * 2.618,
baseWidth * 1.618,
baseWidth * 1.1,
baseWidth,
];
let distance = beyondTheDistanceLimit;
const widthPX = spring(baseWidth, {
damping: 0.38,
stiffness: 0.1,
});
$: $widthPX = interpolate(distanceInput, widthOutput)(distance);
let width: string;
$: width = `${$widthPX / 16}rem`;
Let's break it down.
First off we have baseWidth
variable. As the name suggests, it is gonna be the width of the images when they're not being hovered. But there's more. We're gonna build our whole animation calculations based on this variable.
distanceLimit
and beyondTheDistanceLimit
variables are used to setup how far from the cursor's position items should be scaled up
Next up we got an array distanceInput
. To explain it, let's take a look at the photo below ๐
As you can see, we are hovering over the messages app, so it's the biggest right now. The vertical green line is where the cursor would be lying at the moment. The arrows extend out to 3 icons, on each side of the currently hovered over icon. Notice, that the farther you go, the smaller the icons become.
We can assert from this that the size of the icon is a function of its distance from the cursor. But what is the function?
By function I refer to
mathematical functions
, as iny = x + 1
, ory = x^2 + x + 1
.
Don't worry, we won't go into any math here. We'll let a helper function figure out the relationship between the distance and the size. I'll explain that in a moment.
And next we have a widthOutput
array. Notice the pattern in here. First element is small, 2nd is bigger. 3rd is even bigger. 4th is the biggest! 5th is as small as the 3rd. 6th as same as 2nd, and 7th is the smallest, same as the first.
1
2
3
4
5
6
7
You can see this symmetricity in the distanceInput
too. 7 items, following a similar pattern to widthOutput
. So these arrays are related.Now all we need to do is find out how to relate them, so we can calculate the width for every icon based on its distance from mouse.
This is where the helper function to generate that relationship comes in. We're gonna use the popmotion
library finally. It has a function interpolate
that does that for you.
Before that, let's create our spring animation first.
let distance = beyondTheDistanceLimit;
const widthPX = spring(baseWidth, {
damping: 0.38,
stiffness: 0.1,
});
distance
here is the variable in which we're gonna track, you got it, the distance of current item's center point from cursor. For now we'll initialize with the value of beyondTheDistanceLimit
, so we don't get any accidental animations when user just hovers over dock.
widthPX
is the actual spring animation, imported from svelte/motion
. For an initial value, we simply pass it the baseWidth
, which we want the icon to have when we aren't hovering over it or when it is far away.
We also have a config for this spring animation, in which I have put some sensible values to make the animation look natural. Feel free to play around with these.
Now we have this little snippet:
$: $widthPX = interpolate(distanceInput, widthOutput)(distance);
We're using Svelte's reactive statements to watch distance
, and when it changes, our interpolate
function will basically figure out the width for the current app icon, and set the value of widthPX
to it. As widthPX
is a spring timed store, it will simply keep changing bit by bit after we have set its value, just like a spring.
Next up, we have some unit conversion:
let width: string;
$: width = `${$widthPX / 16}rem`;
widthPX is in pixels. Pixels will work too, but I'm a rem
and em
guy. I like all my units to be in rems
. So I will declare a value to hold the value in rem
As $widthPX
will be constantly changing, we need to put a reactive statement here to make sure width
is always synchronized.
Block 3
/** Block 3 */
let raf: number;
function animate(mouseX: number | null) {
if (el && mouseX !== null) {
const rect = el.getBoundingClientRect();
// get the x coordinate of the img DOMElement's center
// the left x coordinate plus the half of the width
const imgCenterX = rect.left + rect.width / 2;
// difference between the x coordinate value of the mouse pointer
// and the img center x coordinate value
const distanceDelta = mouseX - imgCenterX;
distance = distanceDelta;
return;
}
distance = beyondTheDistanceLimit;
}
$: raf = requestAnimationFrame(() => animate(mouseX));
Next up, we have the block 3 here.
We first declare a variable raf
. This will hold a requestAnimationFrame
instance. It's not needed, but not having it resulted in some bugs for me, so better keep it.
Next up, we have the animate
function. We're passing in mouseX
, the cursor's current position passed to it by the array element.
Inside the function, we got a condition checking if el
, the image element exists, and the mouseX
isn't null. This is the explanation folks, why mouseX value is either number
or null
. When it's set to null
by the Dock
component, the animation is simply ignored thanks to the if
statement here.
Next up, in the if
statement, we're simply doing some calculations to calculate distance of the center of the image from the cursor, adn finally setting the distance
variable to the distance.
If any of the conditions fail, we simply set distance
to be beyondTheDistanceLimit
, so it falls out of the widthOutput
range, and isn't animated
Remember how we have a reactive statement watching
distance
?$: $widthPX = interpolate(distanceInput, widthOutput)(distance);
When thedistance
changes,widthPX
will be updated. This in turn will updatewidth
to take on the value inrem
s.
Next up, we have a very important line here. A reactive statement requesting a requestAnimationFrame
when running this function.
Note: Not assigning the RequestAnimationFrame to a variable(
raf
in this case) results in some bugs.
This is the end of the logic. Most of the work is over now. All we need to do now is set up the markup
Markup
<section>
<ButtonBase>
<img bind:this="{el}" src="/app-icons/{appID}/256.png" alt="" style="width: {width};" />
</ButtonBase>
</section>
Just a section
wrapping our image element for semantics, then we got a custom ButtonBase
Svelte component. This is an unstyled button component that I created for general use. I'll drop the component code below. Not much to see here.
next up we have the star of the show: The img
element.
We're doing a
bind:this
to set element reference toel
variable.src
is requesting the icon from thepublic
folder. In Vite, if something is inpublic
folder, you can refer to it as if it was in the same directory as the file you're writing code in. We're injecting theappID
prop in the string.Lastly,
style="width: {width}"
is the one doing all the magic here. Simply settingwidth
property to thewidth
variable will do all we need it to do.
Lastly, this is the ButtonBase
code:
<button {...$$props}>
<slot />
</button>
<style lang="scss">
button {
color: inherit;
text-decoration: none;
vertical-align: middle;
border: 0;
border-radius: 0;
outline: 0;
margin: 0;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
user-select: none;
appearance: none;
background-color: transparent;
-webkit-tap-highlight-color: transparent;
&:not(:disabled) {
cursor: pointer;
}
}
</style>
And the animation is done.
Hope you got something good out of it
Signing off ๐