# sections

id: good-vs-great-animations/accessibility/workflow
lesson_id: good-vs-great-animations/accessibility
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: accessibility
lesson_title: Accessibility
heading: Workflow
excerpt: Here’s a workflow for you to use when building animations:
url_path: /lessons/good-vs-great-animations/accessibility/#workflow
markdown_path: /md/sections/good-vs-great-animations/accessibility/workflow.md
body:
Here’s a workflow for you to use when building animations:

Build the animation. Make changes to account for prefers-reduced-motion. You can test prefers-reduced-motion being on with the browser’s DevTools. Ship the animation with 2 variants: 1 for prefers-reduced-motion: no-preference and 1 for prefers-reduced-motion: reduce.


id: good-vs-great-animations/accessibility/how-should-animations-be-changed
lesson_id: good-vs-great-animations/accessibility
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: accessibility
lesson_title: Accessibility
heading: How should animations be changed?
url_path: /lessons/good-vs-great-animations/accessibility/#how-should-animations-be-changed
markdown_path: /md/sections/good-vs-great-animations/accessibility/how-should-animations-be-changed.md
excerpt:
To prevent people from feeling sick or getting distracted, our animations need to be changed based on these general guidelines:

body:
To prevent people from feeling sick or getting distracted, our animations need to be changed based on these general guidelines:

Disable autoplaying animations. Only animate properties like opacity, color, background-color. Avoid animating properties like transform that adds motion or changes layout, ensure that no elements move.

reduced-motion does not mean no animations. Remember, animations are used to make our UIs easier to understand. Disabling animations all together would reduce the understandability of our UIs. Animations should help convey meaningful information to the user.

Here’s a modal that opens with a scale animation. Everytime you click the button, a fake prefers-reduced-motion media query is toggled and the animation is adjusted.


id: good-vs-great-animations/accessibility/how-to-implement-it-in-code
lesson_id: good-vs-great-animations/accessibility
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: accessibility
lesson_title: Accessibility
heading: How to implement it in code?
excerpt: We can use a media query in CSS to adjust the animation based on the user’s preference:
url_path: /lessons/good-vs-great-animations/accessibility/#how-to-implement-it-in-code
markdown_path: /md/sections/good-vs-great-animations/accessibility/how-to-implement-it-in-code.md
body:
We can use a media query in CSS to adjust the animation based on the user’s preference:

.element {
  animation: bounce 0.2s;
}

@media (prefers-reduced-motion: reduce) {
  .element {
    animation: fade 0.2s;
  }
}

.element {
  animation: bounce 0.2s;
}

@media (prefers-reduced-motion: reduce) {
  .element {
    animation: fade 0.2s;
  }
}

Tailwind provides a utility class for this media query:

<button type="button">

  <svg class="motion-safe:animate-bounce motion-reduce:animate-fade" viewBox="0 0 24 24">
    <!-- ... -->
  </svg>
  Loading
</button>

<button type="button">

  <svg class="motion-safe:animate-bounce motion-reduce:animate-fade" viewBox="0 0 24 24">
    <!-- ... -->
  </svg>
  Loading
</button>

Framer Motion gives us a useReducedMotion hook that returns true if the user prefers reduced motion. We can use this hook to adjust our animations.

You can read more about this implementation here.

import { useReducedMotion, motion } from "motion/react"

export function Sidebar({ isOpen }) {
  const shouldReduceMotion = useReducedMotion();
  const closedX = shouldReduceMotion ? 0 : "-100%";

  return (
    <motion.div animate={{
      opacity: isOpen ? 1 : 0,
      x: isOpen ? 0 : closedX
    }} />
  )
}

import { useReducedMotion, motion } from "motion/react"

export function Sidebar({ isOpen }) {
  const shouldReduceMotion = useReducedMotion();
  const closedX = shouldReduceMotion ? 0 : "-100%";

  return (
    <motion.div animate={{
      opacity: isOpen ? 1 : 0,
      x: isOpen ? 0 : closedX
    }} />
  )
}

You can also use this hook without Framer Motion, this is how a dependency-free version looks like:

import { useState, useRef, useEffect } from "react";

export function useReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  const { current: mediaQuery } = useRef(
    window ? window.matchMedia("(prefers-reduced-motion: reduce)") : null
  );

  useEffect(() => {
    const listener = () => {
      setPrefersReducedMotion(!!mediaQuery.matches);
    };
    mediaQuery.addEventListener("change", listener);
    return () => {
      mediaQuery.removeEventListener("change", listener);
    };
  }, [mediaQuery]);

  return prefersReducedMotion;
}

import { useState, useRef, useEffect } from "react";

export function useReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  const { current: mediaQuery } = useRef(
    window ? window.matchMedia("(prefers-reduced-motion: reduce)") : null
  );

  useEffect(() => {
    const listener = () => {
      setPrefersReducedMotion(!!mediaQuery.matches);
    };
    mediaQuery.addEventListener("change", listener);
    return () => {
      mediaQuery.removeEventListener("change", listener);
    };
  }, [mediaQuery]);

  return prefersReducedMotion;
}

We’ve talked about MotionConfig before, it’s also useful for respecting the user’s preference for reduced motion. When the reducedMotion prop is set to user, Framer Motion will respect the user’s preference and animate opacity and backgroundColor only. The default here is never so you need to set it yourself.

import { MotionConfig } from "motion/react";

// ...

<MotionConfig reducedMotion="user">{children}</MotionConfig>

import { MotionConfig } from "motion/react";

// ...

<MotionConfig reducedMotion="user">{children}</MotionConfig>

This could be a wrapper around your whole application, that way you don’t have to remember to adjust the animation for reduced motion every time.


id: good-vs-great-animations/accessibility/mental-model-for-visuals
lesson_id: good-vs-great-animations/accessibility
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: accessibility
lesson_title: Accessibility
heading: Mental model for Visuals
url_path: /lessons/good-vs-great-animations/accessibility/#mental-model-for-visuals
markdown_path: /md/sections/good-vs-great-animations/accessibility/mental-model-for-visuals.md
excerpt:
Anything that looks like an image or video. These are often used to add a visual metaphor for concepts. Like this visual we’ve seen before:

body:
Anything that looks like an image or video. These are often used to add a visual metaphor for concepts. Like this visual we’ve seen before:

Here we could jump to each frame, instead of animating between them. We can’t remove the animation altogether, as it’s essential to understanding the concept.


id: good-vs-great-animations/accessibility/disable-smooth-scrolling
lesson_id: good-vs-great-animations/accessibility
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: accessibility
lesson_title: Accessibility
heading: Disable smooth scrolling
url_path: /lessons/good-vs-great-animations/accessibility/#disable-smooth-scrolling
markdown_path: /md/sections/good-vs-great-animations/accessibility/disable-smooth-scrolling.md
excerpt:
When no preference, enables smooth scrolling on the document, when reduced-motion disables smooth scrolling.

body:
When no preference, enables smooth scrolling on the document, when reduced-motion disables smooth scrolling.

@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}

@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}


id: good-vs-great-animations/accessibility/autoplaying-images
lesson_id: good-vs-great-animations/accessibility
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: accessibility
lesson_title: Accessibility
heading: Autoplaying images
excerpt: When no preference, shows a looping animated image, when reduced-motion shows a static fallback.
url_path: /lessons/good-vs-great-animations/accessibility/#autoplaying-images
markdown_path: /md/sections/good-vs-great-animations/accessibility/autoplaying-images.md
body:
When no preference, shows a looping animated image, when reduced-motion shows a static fallback.

<picture>
  <!-- Animated versions -->
  <source
    srcset="animated.avifs"
    type="image/avif"
    media="(prefers-reduced-motion: no-preference)"
  />
  <source
    srcset="animated.gif"
    type="image/gif"
    media="(prefers-reduced-motion: no-preference)"
  />
  <!-- Static versions -->
  <img src="static.png" />
</picture>

<picture>
  <!-- Animated versions -->
  <source
    srcset="animated.avifs"
    type="image/avif"
    media="(prefers-reduced-motion: no-preference)"
  />
  <source
    srcset="animated.gif"
    type="image/gif"
    media="(prefers-reduced-motion: no-preference)"
  />
  <!-- Static versions -->
  <img src="static.png" />
</picture>


id: good-vs-great-animations/accessibility/autoplaying-videos
lesson_id: good-vs-great-animations/accessibility
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: accessibility
lesson_title: Accessibility
heading: Autoplaying videos
url_path: /lessons/good-vs-great-animations/accessibility/#autoplaying-videos
markdown_path: /md/sections/good-vs-great-animations/accessibility/autoplaying-videos.md
excerpt:
When no preference, autoplays the video, when reduced-motion shows the video paused and with controls to manually start the video. This is a vanilla version, but it can be easily adapted to any framework.

body:
When no preference, autoplays the video, when reduced-motion shows the video paused and with controls to manually start the video. This is a vanilla version, but it can be easily adapted to any framework.

<figure>
  <div>
    <video controls muted loop>
      <source src="video.mp4" type="video/mp4">
    </video>
    <button type="button" data-btn aria-live="polite">Play</button>
  </div>
</figure

<figure>
  <div>
    <video controls muted loop>
      <source src="video.mp4" type="video/mp4">
    </video>
    <button type="button" data-btn aria-live="polite">Play</button>
  </div>
</figure

// Query the DOM for the button and video elements using their data attributes
const btn = document.querySelector('button');
const video = document.querySelector('video');

// Check if the user has a preference for reduced motion
const noMotionPreference = window.matchMedia('(prefers-reduced-motion: no-preference)');

/**
 * Initializes the video player by configuring UI elements and autoplay settings.
 */
const initVideo = () => {
  // Remove the default video controls to allow for custom control implementation
  video.removeAttribute('controls');

  // Make the custom play button visible
  btn.hidden = false;

  // Enable autoplay if the user has not expressed a preference for reduced motion
  if (noMotionPreference.matches) {
    video.setAttribute('autoplay', true);
    btn.innerText = 'Pause'; // Set the button text to "Pause" as the video will start playing
  }
};

// Set up an event listener for the play button to control video playback
btn.addEventListener('click', () => {
  // Check if the video is currently paused
  if (video.paused) {
    video.play();           // Play the video
    btn.innerText = 'Pause'; // Update the button text to "Pause"
  } else {
    video.pause();           // Pause the video
    btn.innerText = 'Play';  // Update the button text to "Play"
  }
});

// Call the function to initialize the video setup
initVideo();

// Query the DOM for the button and video elements using their data attributes
const btn = document.querySelector('button');
const video = document.querySelector('video');

// Check if the user has a preference for reduced motion
const noMotionPreference = window.matchMedia('(prefers-reduced-motion: no-preference)');

/**
 * Initializes the video player by configuring UI elements and autoplay settings.
 */
const initVideo = () => {
  // Remove the default video controls to allow for custom control implementation
  video.removeAttribute('controls');

  // Make the custom play button visible
  btn.hidden = false;

  // Enable autoplay if the user has not expressed a preference for reduced motion
  if (noMotionPreference.matches) {
    video.setAttribute('autoplay', true);
    btn.innerText = 'Pause'; // Set the button text to "Pause" as the video will start playing
  }
};

// Set up an event listener for the play button to control video playback
btn.addEventListener('click', () => {
  // Check if the video is currently paused
  if (video.paused) {
    video.play();           // Play the video
    btn.innerText = 'Pause'; // Update the button text to "Pause"
  } else {
    video.pause();           // Pause the video
    btn.innerText = 'Play';  // Update the button text to "Play"
  }
});

// Call the function to initialize the video setup
initVideo();


id: good-vs-great-animations/accessibility/providing-a-hero-for-looping-animations
lesson_id: good-vs-great-animations/accessibility
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: accessibility
lesson_title: Accessibility
heading: Providing a “hero” for looping animations
url_path: /lessons/good-vs-great-animations/accessibility/#providing-a-hero-for-looping-animations
markdown_path: /md/sections/good-vs-great-animations/accessibility/providing-a-hero-for-looping-animations.md
excerpt:
Vercel does that on their rendering page. When the user prefers reduced motion, the animation is paused on a frame from the animation.

body:
.animation {
  animation: shake 0.2s infinite;
}

@media (prefers-reduced-motion: reduce) {
	.animation {
    animation-play-state: paused;
  	/* Pauses the animation on the frame at 0.4s Try different values and see which frame looks the best. */
    animation-delay: -0.4s;
	}
}

.animation {
  animation: shake 0.2s infinite;
}

@media (prefers-reduced-motion: reduce) {
	.animation {
    animation-play-state: paused;
  	/* Pauses the animation on the frame at 0.4s Try different values and see which frame looks the best. */
    animation-delay: -0.4s;
	}
}

Vercel does that on their rendering page. When the user prefers reduced motion, the animation is paused on a frame from the animation.


id: good-vs-great-animations/accessibility/exercise
lesson_id: good-vs-great-animations/accessibility
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: accessibility
lesson_title: Accessibility
heading: Exercise
url_path: /lessons/good-vs-great-animations/accessibility/#exercise
markdown_path: /md/sections/good-vs-great-animations/accessibility/exercise.md
excerpt:
Our multi-step component that we built earlier doesn’t respect the user’s preference for reduced motion. Let’s fix that. When the user prefers reduced motion, we should only animate the opacity of the steps.

body:
Our multi-step component that we built earlier doesn’t respect the user’s preference for reduced motion. Let’s fix that. When the user prefers reduced motion, we should only animate the opacity of the steps.

import { useMemo, useState } from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import useMeasure from "react-use-measure";
import "./styles.css";

export default function MultiStepComponent() {
const [currentStep, setCurrentStep] = useState(0);
const [direction, setDirection] = useState();
const [ref, bounds] = useMeasure();

const content = useMemo(() => {
  switch (currentStep) {
    case 0:
      return (
        <>
          <h2 className="heading">This is step one</h2>
          <p>
            Usually in this step we would explain why this thing exists and
            what it does. Also, we would show a button to go to the next step.
          </p>
          <div className="skeletons">
            <div className="skeleton" style={{ width: 256 }} />
            <div className="skeleton" style={{ width: 192 }} />
            <div className="skeleton" />
            <div className="skeleton" style={{ width: 384 }} />
          </div>
        </>
      );
    case 1:
      return (
        <>
          <h2 className="heading">This is step two</h2>
          <p>
            Usually in this step we would explain why this thing exists and
            what it does. Also, we would show a button to go to the next step.
          </p>
          <div className="skeletons">
            <div className="skeleton" style={{ width: 256 }} />
            <div className="skeleton" style={{ width: 192 }} />
            <div className="skeleton" style={{ width: 384 }} />
          </div>
        </>
      );
    case 2:
      return (
        <>
          <h2 className="heading">This is step three</h2>
          <p>
            Usually in this step we would explain why this thing exists and
            what it does. Also, we would show a button to go to the next step.
          </p>
          <div className="skeletons">
            <div className="skeleton" style={{ width: 256 }} />
            <div className="skeleton" style={{ width: 192 }} />
            <div className="skeleton" style={{ width: 128 }} />
            <div className="skeleton" style={{ width: 224 }} />
            <div className="skeleton" style={{ width: 384 }} />
          </div>
        </>
      );
  }
}, [currentStep]);

return (
  <MotionConfig transition={{ duration: 0.5, type: "spring", bounce: 0 }}>
    <motion.div
      animate={{ height: bounds.height }}
      className="multi-step-wrapper"
    >
      <div className="multi-step-inner" ref={ref}>
        <AnimatePresence mode="popLayout" initial={false} custom={direction}>
          <motion.div
            key={currentStep}
            variants={variants}
            initial="initial"
            animate="active"
            exit="exit"
            custom={direction}
          >
            {content}
          </motion.div>
        </AnimatePresence>
        <motion.div layout className="actions">
          <button
            className="secondary-button"
            disabled={currentStep === 0}
            onClick={() => {
              if (currentStep === 0) {
                return;
              }
              setDirection(-1);
              setCurrentStep((prev) => prev - 1);
            }}
          >
            Back
          </button>
          <button
            className="primary-button"
            disabled={currentStep === 2}
            onClick={() => {
              if (currentStep === 2) {
                setCurrentStep(0);
                setDirection(-1);
                return;
              }
              setDirection(1);
              setCurrentStep((prev) => prev + 1);
            }}
          >
            Continue
          </button>
        </motion.div>
      </div>
    </motion.div>
  </MotionConfig>
);
}

const variants = {
initial: (direction) => {
  return { x: `${110 * direction}%`, opacity: 0 };
},
active: { x: "0%", opacity: 1 },
exit: (direction) => {
  return { x: `${-110 * direction}%`, opacity: 0 };
},
};


id: good-vs-great-animations/animations-of-the-future/overview
lesson_id: good-vs-great-animations/animations-of-the-future
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: animations-of-the-future
lesson_title: Animations of the future
heading: Overview
url_path: /lessons/good-vs-great-animations/animations-of-the-future/#overview
markdown_path: /md/sections/good-vs-great-animations/animations-of-the-future/overview.md
excerpt:
Static transitions can disrupt the user’s sense of flow and orientation. You should envision your interface as a constantly evolving space, where any element can transform into another.

body:
Static transitions can disrupt the user’s sense of flow and orientation. You should envision your interface as a constantly evolving space, where any element can transform into another.

Family's iOS App is a great example of a fluid interface. This video is made by Benji,

We did this in the course already with shared layout animations. Rather than rendering a separate component for the form, we morph our button into its new state.

Another example of it, is the trash interaction we made. The items don’t suddenly appear in the trash, they are "thrown" into it.


id: good-vs-great-animations/animations-of-the-future/what-about-text
lesson_id: good-vs-great-animations/animations-of-the-future
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: animations-of-the-future
lesson_title: Animations of the future
heading: What about text?
excerpt: Applying this principle to text adds another layer of fluidity.
url_path: /lessons/good-vs-great-animations/animations-of-the-future/#what-about-text
markdown_path: /md/sections/good-vs-great-animations/animations-of-the-future/what-about-text.md
body:
Applying this principle to text adds another layer of fluidity.

This is not only appealing, but it also helps the user notice the change. In a static interface, this change would happen instantly. By morphing the text we highlight the transition in a subtle, yet effective way.

When I shared this transition on Twitter, some people said that this is distracting, but that’s exactly the point! By morphing the text we want to emphasize the change, we want to let the user know that the button he is about to press has different consequences.


id: good-vs-great-animations/animations-of-the-future/fluidity-and-perceived-performance
lesson_id: good-vs-great-animations/animations-of-the-future
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: animations-of-the-future
lesson_title: Animations of the future
heading: Fluidity and perceived performance
url_path: /lessons/good-vs-great-animations/animations-of-the-future/#fluidity-and-perceived-performance
excerpt:
Seamless movement in our app can improve user’s perception of speed. Below you can see Family’s graph transition on the left, and CashApp’s static transition on the right. The one on the left seems faster, even though it takes roughly the same time to load.

markdown_path:
/md/sections/good-vs-great-animations/animations-of-the-future/fluidity-and-perceived-performance.md

body:
Seamless movement in our app can improve user’s perception of speed. Below you can see Family’s graph transition on the left, and CashApp’s static transition on the right. The one on the left seems faster, even though it takes roughly the same time to load.

This video is made by Benji, the idea is heavily inspired by Benji as well


id: good-vs-great-animations/animations-of-the-future/fluid-interfaces-are-hard-especially-on-the-web
lesson_id: good-vs-great-animations/animations-of-the-future
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: animations-of-the-future
lesson_title: Animations of the future
heading: Fluid interfaces are hard, especially on the web
excerpt:
A fluid interface on the web, is a seamless interface. Navigating through such interface is effortless for the user. iOS has the tools that enable you to easily achieve this (Swift UI), and, there’s much less screen estate, there are more opportunities for meaningful transitions.

url_path:
/lessons/good-vs-great-animations/animations-of-the-future/#fluid-interfaces-are-hard-especially-on-the-web

markdown_path:
/md/sections/good-vs-great-animations/animations-of-the-future/fluid-interfaces-are-hard-especially-on-the-web.md

body:
A fluid interface on the web, is a seamless interface. Navigating through such interface is effortless for the user. iOS has the tools that enable you to easily achieve this (Swift UI), and, there’s much less screen estate, there are more opportunities for meaningful transitions.

I built the component below to demonstrate where a fluid web interface makes sense. The illustration transforms into an input. Additionally, after the input has a value, we morph the paste button into an "X" button. All of this feels like a natural progression, it gives you more joy than a static transition.

There are a lot of flaws in this animation, because I time-boxed myself to 60 minutes to build this component. I wanted to show that perfecting fluid animations is time-consuming, and that’s why you don’t see them a lot.


id: good-vs-great-animations/animations-of-the-future/add-users-to-your-group
lesson_id: good-vs-great-animations/animations-of-the-future
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: animations-of-the-future
lesson_title: Animations of the future
heading: Add Users to your Group
excerpt: Start by adding users to your group or skip this step and do it later.
url_path: /lessons/good-vs-great-animations/animations-of-the-future/#add-users-to-your-group
markdown_path: /md/sections/good-vs-great-animations/animations-of-the-future/add-users-to-your-group.md
body:
Start by adding users to your group or skip this step and do it later.

In order to achieve a fluid interface, you need to think about the animations before you design the UI. The illustration in the example above is positioned and styled in a way that makes the transition seamless.


id: good-vs-great-animations/animations-of-the-future/should-i-always-try-to-build-fluid-interfaces
lesson_id: good-vs-great-animations/animations-of-the-future
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: animations-of-the-future
lesson_title: Animations of the future
heading: Should I always try to build fluid interfaces?
excerpt:
I can’t think of any other app that nails fluidity as well as Family does. You don’t see them a lot, because it’s time-consuming and requires a lot of thought. If you have the resources and the time, then you should do it. If your product misses features, has bugs, or lacks other important aspects, then you should focus on those first, otherwise, the fluidity will be overshadowed by those issues.

url_path:
/lessons/good-vs-great-animations/animations-of-the-future/#should-i-always-try-to-build-fluid-interfaces

markdown_path:
/md/sections/good-vs-great-animations/animations-of-the-future/should-i-always-try-to-build-fluid-interfaces.md

body:
I can’t think of any other app that nails fluidity as well as Family does. You don’t see them a lot, because it’s time-consuming and requires a lot of thought. If you have the resources and the time, then you should do it. If your product misses features, has bugs, or lacks other important aspects, then you should focus on those first, otherwise, the fluidity will be overshadowed by those issues.

On top of that, the level of fluidity we talked about here is very hard to achieve on the web. I truly believe that more and more companies will start to invest into this, but for now, the technology is not quite there yet.

This chapter is called "Good vs Great animations" though, so I wanted to touch on this subject. I think it’s important to know what great looks like, even if it’s not really realistic at the moment I believe that this is the future of great web animations.


id: good-vs-great-animations/animations-of-the-future/disclaimer
lesson_id: good-vs-great-animations/animations-of-the-future
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: animations-of-the-future
lesson_title: Animations of the future
heading: Disclaimer
url_path: /lessons/good-vs-great-animations/animations-of-the-future/#disclaimer
markdown_path: /md/sections/good-vs-great-animations/animations-of-the-future/disclaimer.md
excerpt:
This lesson is heavily inspired by the way Benji thinks about motion design. The work of his team can be seen in Family.

body:
This lesson is heavily inspired by the way Benji thinks about motion design. The work of his team can be seen in Family.


id: good-vs-great-animations/performance/overview
lesson_id: good-vs-great-animations/performance
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: performance
lesson_title: Performance
heading: Overview
url_path: /lessons/good-vs-great-animations/performance/#overview
markdown_path: /md/sections/good-vs-great-animations/performance/overview.md
excerpt:
The interactive examples in this article are crucial to understanding the concepts, which is why I decided to make this lesson text-only.

body:
The interactive examples in this article are crucial to understanding the concepts, which is why I decided to make this lesson text-only.

Making animations that are performant on all devices is actually pretty tricky. What CSS properties should you animate? Should you use Javascript or CSS animations? What are hardware-accelerated animations? There are a lot of questions to answer, and we have to answer them, because if our animations are not performant, everything we learned so far will not create the desired effect.

Imagine if Sonner ran on 30 frames per second. Theo probably wouldn’t be as excited as he was when it ran at 60 frames per second.

Sonner running at 30 frames per second


id: good-vs-great-animations/performance/what-are-performant-animations
lesson_id: good-vs-great-animations/performance
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: performance
lesson_title: Performance
heading: What are performant animations?
url_path: /lessons/good-vs-great-animations/performance/#what-are-performant-animations
markdown_path: /md/sections/good-vs-great-animations/performance/what-are-performant-animations.md
excerpt:
To keep it simple, performant animations are those that run at 60 frames per second, the same as most screen refresh rates. It means that we need to be able to re-render in ~16.7 milliseconds (1000/60). This is required in order for our brain to perceive motion as fluid.

body:
To keep it simple, performant animations are those that run at 60 frames per second, the same as most screen refresh rates. It means that we need to be able to re-render in ~16.7 milliseconds (1000/60). This is required in order for our brain to perceive motion as fluid.


id: good-vs-great-animations/performance/how-do-we-make-our-animations-performant
lesson_id: good-vs-great-animations/performance
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: performance
lesson_title: Performance
heading: How do we make our animations performant?
excerpt: Let’s start by explaining how animations work in the browser.
url_path: /lessons/good-vs-great-animations/performance/#how-do-we-make-our-animations-performant
markdown_path: /md/sections/good-vs-great-animations/performance/how-do-we-make-our-animations-performant.md
body:
Let’s start by explaining how animations work in the browser.

When we animate an element the browser needs to reflect this change. The steps a browser’s renderer takes to do this are:

Layout: Calculate the size and position of elements on the page. Paint: Draw the page into graphical layers - essentially individual images that make up the page. Composite: Draw these layers to the viewport.

Animating properties like padding, margin, and height will cause the browser to recalculate the layout of the page, because if our element is shrinking or growing, it might affect the position of other elements. This recalculation is expensive and might cause our animation to drop frames.

Whether the animation will drop frames while using these properties depends on how much of the layout is affected. If the element has position: absolute or very few children, then we might get away with animating margin or padding.

The animation below is using padding to create this "scale" animation and it works fine on my device, but I can imagine that it might drop frames on a slower device.

Thankfully, we don’t have to take this risk. We can achieve the same animation by using scale in CSS and not worry about any of the potential issues mentioned above. The rule of thumb here is that you should try to animate with transform and opacity as they only trigger the third rendering step (composite) while padding or margin triggers all three. The less work the browser has to do, the better the performance.

Transforms don’t trigger layout recalculation as they only affect the visual representation of an element, not its position in the document flow.

The blue rectangle shows the actual DOM position of the animated element.

If we animated using margin instead, we’d see the text moving as well. This is an innocent example as we only move one text node, but imagine if we moved a whole dashboard view, by animating a sidebar, that’d be a lot of work for the browser.


id: good-vs-great-animations/performance/js-vs-css-animations
lesson_id: good-vs-great-animations/performance
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: performance
lesson_title: Performance
heading: JS vs CSS animations
url_path: /lessons/good-vs-great-animations/performance/#js-vs-css-animations
markdown_path: /md/sections/good-vs-great-animations/performance/js-vs-css-animations.md
excerpt:
CSS is usually the better choice when we are talking strictly about performance, if it’s done right.

body:
CSS is usually the better choice when we are talking strictly about performance, if it’s done right.

The problem with JS animations arises when they are using requestAnimationFrame to animate. Javascript code will always run on the main thread. This means that if the browser is busy doing something else, it might skip a frame, causing the animation to drop frames.

Some CSS and WAAPI animations are hardware-accelerated, which means that the browser can offload the work to the GPU. A hardware-accelerated animation will remain smooth, no matter how busy the main thread is. If you are animating using transform in CSS the animation will likely be hardware-accelerated.

The interactive demo below demonstrates how the business of the main thread affects animations. The more logos you add, the more work the browser has to do, and the more likely that the Framer Motion animation will drop frames, as it’s using requestAnimationFrame. CSS and WAAPI animations are hardware-accelerated and will remain smooth.

This example is inspired by the Sidebar Animation Performance post by Josh Wootonn.

This issue happened in the Vercel’s dashboard where we animated the active highlight of a tab. The transition was done with Shared Layout Animations, and because the browser was busy loading the new page, the animation dropped frames. We fixed this by using CSS animations which moved the animation off the CPU.


id: good-vs-great-animations/performance/transform-shift
lesson_id: good-vs-great-animations/performance
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: performance
lesson_title: Performance
heading: Transform shift
url_path: /lessons/good-vs-great-animations/performance/#transform-shift
markdown_path: /md/sections/good-vs-great-animations/performance/transform-shift.md
excerpt:
Sometimes one animation can be handled by both the CPU and the GPU, there’s a hand-off between the two. This can cause our animation to shift sometimes. To avoid this, we can use the will-change property to ensure that the animation will be handled exclusively by the GPU.

body:
Sometimes one animation can be handled by both the CPU and the GPU, there’s a hand-off between the two. This can cause our animation to shift sometimes. To avoid this, we can use the will-change property to ensure that the animation will be handled exclusively by the GPU.

.element {
  /* This lets the browser know that this animation should be handled by the GPU. */
  will-change: transform;
}

.element {
  /* This lets the browser know that this animation should be handled by the GPU. */
  will-change: transform;
}

I was aware of this fix for a while, but Josh Comeau’s css-for-js course helped me understand why I’m adding this property, and that’s where this part comes from.


id: good-vs-great-animations/performance/re-renders-in-react
lesson_id: good-vs-great-animations/performance
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: performance
lesson_title: Performance
heading: Re-renders in React
url_path: /lessons/good-vs-great-animations/performance/#re-renders-in-react
markdown_path: /md/sections/good-vs-great-animations/performance/re-renders-in-react.md
excerpt:
When talking about animations in React we have to think about re-renders as well. Animations libraries like react-spring or Framer Motion animate things outside of React’s render cycle, but if you ever try to animate something in React that depends on state you might run into issues if you re-render too often.

body:
When talking about animations in React we have to think about re-renders as well. Animations libraries like react-spring or Framer Motion animate things outside of React’s render cycle, but if you ever try to animate something in React that depends on state you might run into issues if you re-render too often.

Below we modify the y position of each logo directly through the style property, we do that roughly every 16.7 milliseconds via requestAnimationFrame. When we click on the button to unoptimize, we’ll switch to updating the state rather than modifying the style directly. This will cause a re-render on every frame, which will then cause the animation to drop frames.


id: good-vs-great-animations/performance/hardware-accelerated-animations-in-framer-motion
lesson_id: good-vs-great-animations/performance
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: performance
lesson_title: Performance
heading: Hardware-accelerated animations in Framer Motion
url_path: /lessons/good-vs-great-animations/performance/#hardware-accelerated-animations-in-framer-motion
excerpt:
Hardware acceleration is also available in Framer Motion actually, but you have to remember to animate transforms as a string. Most people animate using x and y values, which is more readable, but if you’d need to hardware accelerate the animation, you can keep this in mind.

markdown_path:
/md/sections/good-vs-great-animations/performance/hardware-accelerated-animations-in-framer-motion.md

body:
Hardware acceleration is also available in Framer Motion actually, but you have to remember to animate transforms as a string. Most people animate using x and y values, which is more readable, but if you’d need to hardware accelerate the animation, you can keep this in mind.


id: good-vs-great-animations/performance/exceptions
lesson_id: good-vs-great-animations/performance
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: performance
lesson_title: Performance
heading: Exceptions
excerpt: When I was building Vaul I ran into a performance issue that I couldn’t solve for days.
url_path: /lessons/good-vs-great-animations/performance/#exceptions
markdown_path: /md/sections/good-vs-great-animations/performance/exceptions.md
body:
When I was building Vaul I ran into a performance issue that I couldn’t solve for days.

Once the content of the drawer got bigger than ~20 list items the drag gesture became laggy, and I couldn’t figure out why. Dragging was done without any re-renders, so what could cause it to drop frames? Every time the drag position gets updated, I changed a CSS variable which is then used as the value for translateY().

const style = {
  "--swipe-amount": `${draggedDistance}px`,
};

const style = {
  "--swipe-amount": `${draggedDistance}px`,
};

Since CSS Variables are inheritable, changing them will cause style recalculation for all children, meaning the more items I have in my drawer, the more expensive the calculation gets. Updating the style directly on the element fixed the issue. This seems like a quick fix in retrospect, but it took me hours to figure out.

const style = {
  transform: `translateY(${draggedDistance}px)`,
};

const style = {
  transform: `translateY(${draggedDistance}px)`,
};

Another exception is that blur animations become very laggy very quickly, especially on Safari. That’s why you should not use a higher value than ~20px for the blur filter.


id: good-vs-great-animations/performance/does-this-mean-i-should-avoid-js-animations
lesson_id: good-vs-great-animations/performance
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: performance
lesson_title: Performance
heading: Does this mean I should avoid JS animations?
url_path: /lessons/good-vs-great-animations/performance/#does-this-mean-i-should-avoid-js-animations
markdown_path: /md/sections/good-vs-great-animations/performance/does-this-mean-i-should-avoid-js-animations.md
excerpt:
No. The problem with JS animations arises when your website is performing heavy processing during an animation. This rarely happens, and if it does, you now read this article and know how to fix or avoid it.

body:
No. The problem with JS animations arises when your website is performing heavy processing during an animation. This rarely happens, and if it does, you now read this article and know how to fix or avoid it.

On top of that libraries like Framer Motion offer a lot more than CSS animations. You can use spring animations, shared layout animations, and more. Framer Motion does use requestAnimationFrame though and it’s a pretty big package, so you should really think about whether you need the features it offers.

Personally, I often times combine CSS animations with Framer Motion. CSS for simple animations and those that should be hardware-accelerated, and Framer Motion for more complex/sophisticated animations.


id: good-vs-great-animations/the-big-little-details/feeling
lesson_id: good-vs-great-animations/the-big-little-details
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: the-big-little-details
lesson_title: The big little details
heading: Feeling
url_path: /lessons/good-vs-great-animations/the-big-little-details/#feeling
markdown_path: /md/sections/good-vs-great-animations/the-big-little-details/feeling.md
excerpt:
Design has its rules for making a website appear luxurious, whimsical, or geeky. Using a serif font can make a website feel more premium, while using a monospace font indicates that the website is related to tech. Blue color is often used for trust, think of PayPal or Goldman Sachs. All of this helps us convey the feeling of the brand. But what about animations?

body:
Design has its rules for making a website appear luxurious, whimsical, or geeky. Using a serif font can make a website feel more premium, while using a monospace font indicates that the website is related to tech. Blue color is often used for trust, think of PayPal or Goldman Sachs. All of this helps us convey the feeling of the brand. But what about animations?

PayPal’s website is full of blue color, which is often associated with trust.

If we look at Stripe for example, we can see that their animations are pretty slow, they take their time. It feels premium and reliable to me, as if they are saying "we are not in a hurry, we are here for you". I’m talking strictly about the marketing website here, not the dashboard.

If you are a young, forward-thinking agency, you could go for more "edgy" ease-in-out curves.

When I worked at Vercel, I maintained our design system and we decided to make our animations very fast or in some cases even instant (no animation at all). Vercel is a company that is all about speed, so it made sense to make the product itself feel fast.

I wanted Sonner to feel premium and elegant. That’s why I made the animations feel a bit slower than usual. It also uses an ease curve rather than ease-out, it doesn’t feel as snappy, but it makes it feel more elegant, it fits the vibe I was going for.

Usually, our main goal should be to build interfaces that feel fast and are not annoying. Having slower animations on your marketing page is fine, as they are mostly informative and not interactive, and that’s where you should try and convey the feeling of your brand. But when it comes to the product itself, we should aim for speed. Family is the perfect example of that. I know that I mention Family a lot, but it just nails everything we talk about in this course.


id: good-vs-great-animations/the-big-little-details/orchestration
lesson_id: good-vs-great-animations/the-big-little-details
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: the-big-little-details
lesson_title: The big little details
heading: Orchestration
url_path: /lessons/good-vs-great-animations/the-big-little-details/#orchestration
markdown_path: /md/sections/good-vs-great-animations/the-big-little-details/orchestration.md
excerpt:
A "detail" that can take your animation to the next level. Visiting Paco’s site feels like a moment of joy. The enter animation is sequenced perfectly, it feels like a wave. It’s way nicer than if everything just animated in together.

body:
A "detail" that can take your animation to the next level. Visiting Paco’s site feels like a moment of joy. The enter animation is sequenced perfectly, it feels like a wave. It’s way nicer than if everything just animated in together.

Another great example of it is Apple’s navigation menu where they fade in the columns with a slight delay. This feels like a wave effect to me again. It’s hard to do achieve such effect, as the delay has to be just right.

I can’t give you any specific tips here, as I believe that this type of details are just a matter of trial and error until feels right. I told you in the taste lesson that you need to surround yourself with great work and practice. So let’s try, and recreate Paco’s enter animation!

Framer Motion makes orchestration easier with stagger. But let’s use pure CSS here so that you can see how it’s done without any libraries.

import "./styles.css";

export default function Orchestration() {
return (
  <div className="orchestration">
    <h1>Jon Doe</h1>
    {COPY.map((copy) => (
      <p key={copy}>{copy}</p>
    ))}
  </div>
);
}



const COPY = [
`Using Apple's Sheet component on iOS feels natural, I wanted to create the same experience, but for the web. That's how Vaul, the React component was born.`,
`Open-sourcing meant that more people will use it, which will result in more feedback, ultimately making the component better.`,
`I chose to build Vaul on top of Radix's Dialog primitive. Radix ensures the component is accessible, handles focus management etc. I also made Vaul's API is very similar to Radix's, so that it feels familiar.`,
`Once the content of the drawer got bigger than ~20 list items the drag gesture became laggy, and I couldn't figure out why. `,
`Since CSS Variables are inheritable, changing them will cause style recalculation for all children, meaning the more items I have in my drawer, the more expensive the calculation gets.`,
];


id: good-vs-great-animations/the-big-little-details/blur
lesson_id: good-vs-great-animations/the-big-little-details
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: the-big-little-details
lesson_title: The big little details
heading: Blur
url_path: /lessons/good-vs-great-animations/the-big-little-details/#blur
markdown_path: /md/sections/good-vs-great-animations/the-big-little-details/blur.md
excerpt:
Blur is used more and more in web animations. It’s a nice way to create a better sense of motion and to mask any potential imperfections in the animation. We’ve used this in our feedback component for the success state.

body:
Blur is used more and more in web animations. It’s a nice way to create a better sense of motion and to mask any potential imperfections in the animation. We’ve used this in our feedback component for the success state.

The Dynamic Island that’ll build soon is another example of where blur makes a difference. You can try turning the blur on and off in the component below, and see how it affects the animation. You might not notice a huge change, but it definitely feels better with the blur on.


id: good-vs-great-animations/the-big-little-details/reviewing-your-work
lesson_id: good-vs-great-animations/the-big-little-details
module_slug: good-vs-great-animations
module_title: Good vs Great Animations
lesson_slug: the-big-little-details
lesson_title: The big little details
heading: Reviewing your work
url_path: /lessons/good-vs-great-animations/the-big-little-details/#reviewing-your-work
markdown_path: /md/sections/good-vs-great-animations/the-big-little-details/reviewing-your-work.md
excerpt:
I’ve spoken about it before, but I want to emphasize the importance of reviewing your work. The animations I post on twitter are never shared the day they are made. I give myself at least a day to look at them with fresh eyes. It’s the same as with design, after stepping away, you’ll notice things you didn’t see before.

body:
I’ve spoken about it before, but I want to emphasize the importance of reviewing your work. The animations I post on twitter are never shared the day they are made. I give myself at least a day to look at them with fresh eyes. It’s the same as with design, after stepping away, you’ll notice things you didn’t see before.

Replay your animation, see how it moves, how it can be improved, step away from the code. Leonardo da Vinci would spend hours just staring at his work, not applying a single brushstroke. I’m obviously not comparing us to someone like da Vinci, but I do believe that people don’t give their animations enough time. They focus on the code, write it, and move on. The greatest animations take time, not only to code, but to review and improve it.

The transitions in Sonner were done in a few days, but I kept replaying them literally everyday until I published them. Who knows, maybe those additional few tweaks made Theo appreciate the animation the way he did.


id: hero-illustration/click-animation/what-we-re-working-with
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: What we’re working with
excerpt: The hand SVG we’ll be working with has three main elements:
url_path: /lessons/hero-illustration/click-animation/#what-we-re-working-with
markdown_path: /md/sections/hero-illustration/click-animation/what-we-re-working-with.md
body:
The hand SVG we’ll be working with has three main elements:

Background: We’ll scale this when the hand clicks for extra feedback. Hand: A single <path> element we’ll morph between resting and clicked shapes. Three lines: All <line> elements are of the same length and will be animate with the strokeDashoffset method discussed in the previous lesson.

Notice the data-animate attributes on each element below. We’ll use these to target elements for animation without needing refs. The data-index attribute will be used to distinguish between the lines to select animation values and for staggered timing.

<svg width="125" height="119" viewBox="0 0 125 119">
  <g>
    <g data-animate="background">
      <path d="..." />
    </g>

    <path data-animate="hand" d="..." />

    <line data-animate="line" data-index="2" ... />
    <line data-animate="line" data-index="1" ... />
    <line data-animate="line" data-index="0" ... />
  </g>
</svg>

<svg width="125" height="119" viewBox="0 0 125 119">
  <g>
    <g data-animate="background">
      <path d="..." />
    </g>

    <path data-animate="hand" d="..." />

    <line data-animate="line" data-index="2" ... />
    <line data-animate="line" data-index="1" ... />
    <line data-animate="line" data-index="0" ... />
  </g>
</svg>


id: hero-illustration/click-animation/why-useanimate
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: Why useAnimate?
url_path: /lessons/hero-illustration/click-animation/#why-useanimate
markdown_path: /md/sections/hero-illustration/click-animation/why-useanimate.md
excerpt:
Before we dive in, let’s talk about why we’re using useAnimate instead of Motion’s declarative props like initial, animate, whileHover, etc.

body:
Before we dive in, let’s talk about why we’re using useAnimate instead of Motion’s declarative props like initial, animate, whileHover, etc.

Those work great for simple cases, but here we’re coordinating multiple SVG elements at once - pulse the background, move the hand, morph the hand shape, and draw in three motion lines with staggered timing. All triggered from different events (hover, click, idle timer) with precise coordination between them.

A declarative approach would require managing animation states:

const [animationState, setAnimationState] = useState("idle"); // idle, hover, leave, click
const [canClick, setCanClick] = useState(false);

const [animationState, setAnimationState] = useState("idle"); // idle, hover, leave, click
const [canClick, setCanClick] = useState(false);

Every state change triggers a re-render, even though we’re just updating animation phases.

Then there’s path morphing - there’s no declarative way to interpolate between SVG paths, so we’re forced into imperative code regardless:

const handPathProgress = useMotionValue(0);
const handPath = useTransform(handPathProgress, [0, 1], handPaths);

useEffect(() => {
  // Manually animate path for each state: hover, click, idle, leave...
  if (animationState === "hover") {
    animate(handPathProgress, [0, 1, 0], {
      /* timing config */
    });
  }
  // ... more state checks
}, [animationState]);

const handPathProgress = useMotionValue(0);
const handPath = useTransform(handPathProgress, [0, 1], handPaths);

useEffect(() => {
  // Manually animate path for each state: hover, click, idle, leave...
  if (animationState === "hover") {
    animate(handPathProgress, [0, 1, 0], {
      /* timing config */
    });
  }
  // ... more state checks
}, [animationState]);

Now we’re managing both declarative variants (for the background and lines) and imperative animations (for the path) simultaneously.

The biggest pain is coordinating completion. When the user’s mouse leaves, we need to wait for all the “leave” animations to finish before starting the idle loop. With declarative variants, there’s no built-in way to await completion across multiple elements, so we’d need completion tracking:

const [leaveCompleted, setLeaveCompleted] = useState({
  background: false,
  hand: false,
  lines: false,
});

// Add onAnimationComplete to every element
<motion.g
  animate={animationState}
  onAnimationComplete={() => {
    setLeaveCompleted((prev) => ({ ...prev, background: true }));
  }}
/>;

// Then check when everything's done
useEffect(() => {
  if (
    animationState === "leave" &&
    leaveCompleted.background &&
    leaveCompleted.hand &&
    leaveCompleted.lines
  ) {
    setAnimationState("idle"); // Finally start idle loop
  }
}, [animationState, leaveCompleted]);

const [leaveCompleted, setLeaveCompleted] = useState({
  background: false,
  hand: false,
  lines: false,
});

// Add onAnimationComplete to every element
<motion.g
  animate={animationState}
  onAnimationComplete={() => {
    setLeaveCompleted((prev) => ({ ...prev, background: true }));
  }}
/>;

// Then check when everything's done
useEffect(() => {
  if (
    animationState === "leave" &&
    leaveCompleted.background &&
    leaveCompleted.hand &&
    leaveCompleted.lines
  ) {
    setAnimationState("idle"); // Finally start idle loop
  }
}, [animationState, leaveCompleted]);

That’s a lot of ceremony for “wait for these animations to finish, then start the next one”.

With useAnimate, we get imperative control that lets us orchestrate everything from event handlers. We can coordinate timing, sequence animations, and wait for completion naturally - without the state management overhead. Let’s see how this works.


id: hero-illustration/click-animation/getting-started
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: Getting started
url_path: /lessons/hero-illustration/click-animation/#getting-started
markdown_path: /md/sections/hero-illustration/click-animation/getting-started.md
excerpt:
First, we need to convert our static SVG elements to motion components and import the useAnimate hook:

body:
First, we need to convert our static SVG elements to motion components and import the useAnimate hook:

import { useAnimate, motion } from "motion/react";

export default function ClickAnimation() {
  const [scope, animate] = useAnimate();

  return (
    <svg width="125" height="119" viewBox="0 0 125 119" fill="none">
      {/* Scope defines the boundary for our animate() selectors */}
      <motion.g
        ref={scope}
        onMouseEnter={() => {}}
        onMouseLeave={() => {}}
      >
        {/* Each element gets a data-animate attribute for targeting */}
        <motion.g data-animate="background" filter="...">
          <path d="..." />
        </motion.g>

        <motion.path data-animate="hand" d="..." />

        {/* data-index helps us distinguish between lines */}
        <motion.line data-animate="ray" data-index="2" ... />
        <motion.line data-animate="ray" data-index="1" ... />
        <motion.line data-animate="ray" data-index="0" ... />
      </motion.g>
    </svg>
  );
}

import { useAnimate, motion } from "motion/react";

export default function ClickAnimation() {
  const [scope, animate] = useAnimate();

  return (
    <svg width="125" height="119" viewBox="0 0 125 119" fill="none">
      {/* Scope defines the boundary for our animate() selectors */}
      <motion.g
        ref={scope}
        onMouseEnter={() => {}}
        onMouseLeave={() => {}}
      >
        {/* Each element gets a data-animate attribute for targeting */}
        <motion.g data-animate="background" filter="...">
          <path d="..." />
        </motion.g>

        <motion.path data-animate="hand" d="..." />

        {/* data-index helps us distinguish between lines */}
        <motion.line data-animate="ray" data-index="2" ... />
        <motion.line data-animate="ray" data-index="1" ... />
        <motion.line data-animate="ray" data-index="0" ... />
      </motion.g>
    </svg>
  );
}

Notice how the background is wrapped in a motion.g (group), but the hand and lines are motion.path and motion.line directly. This is intentional.

Groups serve multiple purposes in SVG animations. The outer group wraps all our elements together so we can attach event handlers and apply animations to the entire illustration. The background gets its own group because it has a filter effect applied - we’ll animate the group rather than the path directly to keep the filter styling unchanged. The hand and lines can be animated directly since they don’t need this separation.

This pattern also lets you layer multiple animations on the same element by wrapping it in nested groups, where each group handles a different transform. We’ll see this in action in the next lessons.

The scope ref on the outer group defines the boundary for our animate() function - it tells Motion which part of the DOM to search when we use selectors.


id: hero-illustration/click-animation/the-background-scale-effect
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: The background scale effect
url_path: /lessons/hero-illustration/click-animation/#the-background-scale-effect
markdown_path: /md/sections/hero-illustration/click-animation/the-background-scale-effect.md
excerpt:
Now let’s add the background transform animation. The animate function takes three arguments: a selector, animation values, and options where we can define our transition values.

body:
Now let’s add the background transform animation. The animate function takes three arguments: a selector, animation values, and options where we can define our transition values.

const [scope, animate] = useAnimate();

const handleMouseEnter = () => {
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    { duration: 0.53, ease: easeOut },
  );
};

return (
  <svg
    width="125"
    height="119"
    viewBox="0 0 125 119"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    className="overflow-visible"
  >
    <g ref={scope} onMouseEnter={handleMouseEnter}>
      <motion.g data-animate="background">
        <path d="..." />
      </motion.g>
    </g>
  </svg>
);

const [scope, animate] = useAnimate();

const handleMouseEnter = () => {
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    { duration: 0.53, ease: easeOut },
  );
};

return (
  <svg
    width="125"
    height="119"
    viewBox="0 0 125 119"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    className="overflow-visible"
  >
    <g ref={scope} onMouseEnter={handleMouseEnter}>
      <motion.g data-animate="background">
        <path d="..." />
      </motion.g>
    </g>
  </svg>
);

Let’s break down what’s happening here.

We’re calling useAnimate which gives us a scope ref and an animate function. The ref gets attached to a parent <g> element, creating a scope for our animations. Any element inside this scope can be targeted by our animate function.

When the mouse enters, we use animate with a CSS selector "[data-animate='background']" to find our background element. This is more flexible than using refs when you need to animate multiple elements.

The animation itself is a keyframe sequence. We pass an array of transform values creating compress → overshoot → settle.

We don’t need to explicitly define transform-box: fill-box style on the motion.g element as motion does this by default on SVG elements, and sets the transform-origin to 50% 50% (or center) which is exactly what we need here.

We also set overflow: visible on the SVG to make sure that the SVG doesn’t clip the background path when it overshoots the SVG bounds.

Hover over the background to see the animation.


id: hero-illustration/click-animation/moving-the-hand-into-position
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: Moving the hand into position
url_path: /lessons/hero-illustration/click-animation/#moving-the-hand-into-position
markdown_path: /md/sections/hero-illustration/click-animation/moving-the-hand-into-position.md
excerpt:
Looking at the full animation, you’ll see the background scaling is delayed. It doesn’t happen when you hover - it syncs with the click for a more cohesive feel.

body:
Looking at the full animation, you’ll see the background scaling is delayed. It doesn’t happen when you hover - it syncs with the click for a more cohesive feel.

The hand needs to shift position to center the index finger with the middle line. Finding these values takes some trial and error - I used CSS transforms directly in the browser console until it felt right: translateX(-4px) translateY(3px) rotate(25deg).

We’ll also use the times option which let’s us change the timing of each keyframe. Each value in the times array is a value between 0 and 1, representing when that keyframe occurs during the animation’s duration. For example, with times: [0, 0.4] on a 1s animation, the second keyframe happens at 0.4s (40% through).

Now let’s add the transform keyframes and coordinate the animations:

const handleMouseEnter = () => {
  // Background: delayed start
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.53,
      ease: easeOut,
      times: [0, 0.2, 0.6, 1],
      delay: 0.2, // Syncs with hand path morphing
    },
  );

  // Hand: moves into position
  animate(
    "[data-animate='hand']",
    {
      transform: [
        "translateX(0px) translateY(0px) rotate(0deg)",
        "translateX(-4px) translateY(3px) rotate(25deg)",
      ],
    },
    {
      duration: 0.53,
      times: [0, 0.4],
      ease: easeInOut,
    },
  );
};

const handleMouseLeave = () => {
  animate("[data-animate='hand']", {
    transform: "translateX(0px) translateY(0px) rotate(0deg)",
  });
};

const handleMouseEnter = () => {
  // Background: delayed start
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.53,
      ease: easeOut,
      times: [0, 0.2, 0.6, 1],
      delay: 0.2, // Syncs with hand path morphing
    },
  );

  // Hand: moves into position
  animate(
    "[data-animate='hand']",
    {
      transform: [
        "translateX(0px) translateY(0px) rotate(0deg)",
        "translateX(-4px) translateY(3px) rotate(25deg)",
      ],
    },
    {
      duration: 0.53,
      times: [0, 0.4],
      ease: easeInOut,
    },
  );
};

const handleMouseLeave = () => {
  animate("[data-animate='hand']", {
    transform: "translateX(0px) translateY(0px) rotate(0deg)",
  });
};

The hand reaches its final position at 40% through the animation (times: [0, 0.4]), while the background starts later with a 0.2s delay. This layered timing, elements starting and finishing at different moments, makes the animation feel more organic than if everything moved in lockstep.

We also added a handleMouseLeave callback to reset the hand to its initial position when the mouse leaves the hand.

Hover over the hand below to see the effect of our implementation above using the times option.


id: hero-illustration/click-animation/path-morphing-the-clicking-motion
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: Path morphing: the clicking motion
url_path: /lessons/hero-illustration/click-animation/#path-morphing-the-clicking-motion
markdown_path: /md/sections/hero-illustration/click-animation/path-morphing-the-clicking-motion.md
excerpt:
To sell the “click” animation, we could take the easy route and scale the hand down. But with Motion’s useTransform, we can morph the SVG path itself - animating the index finger into a clicking position.

body:
To sell the “click” animation, we could take the easy route and scale the hand down. But with Motion’s useTransform, we can morph the SVG path itself - animating the index finger into a clicking position.

From the previous lesson, you know a path is a set of commands that draw a shape. useTransform can interpolate between two paths as long as they have the same structure and number of points. Our hand paths both have the same sequence of move and curve commands, making them perfect for simple interpolation.

If you needed to morph between very different paths (different number of points or command types), you’d use a library like flubber - here’s an example from Motion’s docs. But for our matching paths, useTransform is all we need.

The pattern for path morphing with Motion looks like this:

const handPaths = [
  "M58.5431 71.4419C60.2401...", // open hand
  "M58.7957 70.4997C60.4927...", // clicking hand
];

const handPathProgress = useMotionValue(0);
const handPath = useTransform(handPathProgress, [0, 1], handPaths);

// In the animation:
animate(handPathProgress, [0, 1, 0], {
  duration: 0.53,
  times: [0.4, 0.6, 0.9],
  ease: easeOut,
});

// In the JSX:
<motion.path d={handPath} />;

const handPaths = [
  "M58.5431 71.4419C60.2401...", // open hand
  "M58.7957 70.4997C60.4927...", // clicking hand
];

const handPathProgress = useMotionValue(0);
const handPath = useTransform(handPathProgress, [0, 1], handPaths);

// In the animation:
animate(handPathProgress, [0, 1, 0], {
  duration: 0.53,
  times: [0.4, 0.6, 0.9],
  ease: easeOut,
});

// In the JSX:
<motion.path d={handPath} />;

Here’s how this works. We create a useMotionValue for the animation progress, then useTransform converts that progress into interpolated SVG paths. We pass it the progress value, the input range [0, 1], and the two paths to morph between. When the progress value changes, the path automatically updates.

When we animate the progress from 0 → 1 → 0, we get open hand → clicking hand → open hand. The times array controls when these transitions happen - the hand starts morphing at 40% through the animation, reaches full curl at 60%, and returns to open by 90%.

"use client";

import {
motion,
useAnimate,
useMotionValue,
useTransform,
easeOut,
easeInOut,
} from "motion/react";

const handPaths = [
"M58.5431 71.4419C60.2401 71.6239 61.3091 69.6649 60.2391 68.3369L49.2671 54.7189C48.6804 53.9554 48.4158 52.9927 48.5299 52.0366C48.6439 51.0805 49.1275 50.207 49.8773 49.6028C50.6271 48.9987 51.5835 48.712 52.542 48.804C53.5004 48.8959 54.3849 49.3593 55.0061 50.0949L58.3501 54.2449C58.8429 54.8569 59.5061 55.3089 60.2559 55.5437C61.0057 55.7785 61.8083 55.7855 62.5621 55.5639L70.6681 53.1809C72.8705 52.5335 75.23 52.6775 77.3372 53.5881C79.4444 54.4987 81.1663 56.1184 82.2041 58.1659L84.9681 63.6179C85.7915 65.2412 86.0321 67.098 85.6498 68.8775C85.2675 70.657 84.2855 72.2511 82.8681 73.3929L74.6611 80.0039C73.7139 80.7673 72.604 81.3028 71.417 81.5691C70.23 81.8355 68.9976 81.8255 67.8151 81.5399L55.1911 78.4919C54.2783 78.2707 53.4839 77.7106 52.9688 76.9252C52.4537 76.1399 52.2566 75.188 52.4174 74.2627C52.5781 73.3374 53.0848 72.5078 53.8346 71.9422C54.5844 71.3766 55.5212 71.1173 56.4551 71.2169L58.5431 71.4419Z",
"M58.7957 70.4997C60.4927 70.6817 61.3092 69.6653 60.2392 68.3373L51.8366 57.8368C51.2499 57.0733 50.867 55.9897 50.981 55.0336C51.0951 54.0776 51.5787 53.204 52.3285 52.5999C53.0783 51.9958 54.0347 51.7091 54.9931 51.801C55.9516 51.893 56.7918 52.4457 57.413 53.1813L58.3502 54.2453C58.8429 54.8573 59.5062 55.3093 60.256 55.5441C61.0058 55.7789 61.8084 55.7859 62.5622 55.5643L70.6681 53.1813C72.8705 52.5339 75.23 52.6779 77.3373 53.5885C79.4445 54.4991 81.1664 56.1187 82.2042 58.1663L84.9682 63.6183C85.7915 65.2416 86.0322 67.0984 85.6499 68.8778C85.2676 70.6573 84.2855 72.2515 82.8682 73.3933L74.6611 80.0043C73.714 80.7677 72.604 81.3032 71.417 81.5695C70.23 81.8358 68.9977 81.8258 67.8151 81.5403L55.5115 77.3318C54.5988 77.1105 53.8043 76.5504 53.2892 75.765C52.7742 74.9797 52.577 74.0279 52.7378 73.1025C52.8986 72.1772 53.4052 71.3476 54.155 70.782C54.9048 70.2165 55.8416 69.9572 56.7755 70.0567L58.7957 70.4997Z"
];

export default function ClickAnimation() {
const [scope, animate] = useAnimate();
const handPathProgress = useMotionValue(0);
const handPath = useTransform(handPathProgress, [0, 1], handPaths);

const handleMouseEnter = () => {
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.55,
      times: [0, 0.2, 0.6, 1],
      ease: easeOut,
      delay: 0.2,
    }
  );

  animate(
    "[data-animate='hand']",
    {
      transform: [
        "translateX(0px) translateY(0px) rotate(0deg)",
        "translateX(-4px) translateY(3px) rotate(25deg)",
      ],
    },
    {
      duration: 0.55,
      times: [0, 0.4],
      ease: easeInOut,
    }
  );

  animate(handPathProgress, [0, 1, 0], {
    duration: 0.55,
    times: [0.4, 0.6, 0.9],
    ease: easeOut,
  });
};

const handleMouseLeave = () => {
  animate("[data-animate='hand']", {
    transform: "translateX(0px) translateY(0px) rotate(0deg)",
  });
};

return (
  <svg
    width="125"
    height="119"
    viewBox="0 0 125 119"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    style={{ overflow: "visible" }}
  >
    <g
      ref={scope}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      <g
        data-animate="background"
        filter="url(#filter0_i_269_329)"
      >
        <path
          d="M14.9066 10.1626C18.9956 19.8776 23.4146 30.4306 25.5696 35.7296C26.3866 37.7366 25.6336 39.9006 23.7896 41.0376L12.7336 47.8526C12.1283 48.2259 11.6112 48.7261 11.218 49.3187C10.8249 49.9113 10.5651 50.5822 10.4565 51.285C10.3479 51.9879 10.3932 52.7059 10.5891 53.3895C10.7851 54.0732 11.1272 54.7061 11.5916 55.2446L19.3536 64.2426C20.1213 65.1328 20.5445 66.2687 20.5464 67.4443C20.5482 68.6198 20.1286 69.757 19.3636 70.6496L1.34161 91.6766C-1.69439 95.2186 1.45161 100.601 6.02761 99.6936L31.5196 94.6386C32.7448 94.3961 34.0163 94.6291 35.0758 95.2904C36.1353 95.9516 36.9033 96.9915 37.2236 98.1986L41.1356 112.939C42.0306 116.315 46.0646 117.704 48.8496 115.596L60.7136 106.617C61.5752 105.964 62.628 105.614 63.7089 105.62C64.7897 105.627 65.8382 105.99 66.6916 106.654L81.3666 118.048C84.2466 120.285 88.4726 118.716 89.1956 115.143L92.5696 98.4746C92.7053 97.8046 92.9793 97.1702 93.3741 96.6121C93.7689 96.054 94.2759 95.5844 94.8625 95.2333C95.4491 94.8823 96.1026 94.6575 96.781 94.5733C97.4595 94.4892 98.148 94.5475 98.8026 94.7446L115.49 99.7726C119.957 101.119 123.61 96.0636 120.929 92.2446L106.344 71.4686C105.633 70.4557 105.331 69.2121 105.497 67.986C105.663 66.7599 106.286 65.6417 107.241 64.8546L123.32 51.6046C126.177 49.2496 125.503 44.7016 122.085 43.2766L109.166 37.8936C108.082 37.4416 107.195 36.6163 106.667 35.5672C106.139 34.5182 106.004 33.3146 106.287 32.1746L111.616 10.7026C112.746 6.15163 107.466 2.75564 103.793 5.67064L84.2026 21.2176C81.6436 23.2486 77.8526 22.2626 76.6066 19.2426L70.0536 3.36063C68.5736 -0.224368 63.7166 -0.762367 61.4886 2.41363L51.8826 16.1066C51.1775 17.1118 50.1215 17.8164 48.9227 18.0819C47.7239 18.3473 46.4692 18.1542 45.4056 17.5406L21.8996 3.98863C17.8176 1.63563 13.0796 5.82063 14.9066 10.1626Z"
          fill="#F8F8F8"
        />
      </g>

      <motion.path
        d={handPath}
        data-animate="hand"
        initial={{
          transform: "translateX(0px) translateY(0px) rotate(0deg)",
        }}
        fill="#989898"
      />

      <line
        data-animate="line"
        data-index="2"
        x1="62.8541"
        y1="39.4586"
        x2="64.5595"
        y2="34.929"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        stroke="#989898"
      />
      <line
        data-animate="line"
        data-index="1"
        x1="53.0633"
        y1="38.313"
        x2="52.2947"
        y2="33.5344"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        stroke="#989898"
      />
      <line
        data-animate="line"
        data-index="0"
        x1="44.047"
        y1="42.3621"
        x2="41.0059"
        y2="38.5917"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        stroke="#989898"
      />
    </g>

    <defs>
      <filter
        id="filter0_i_269_329"
        x="0"
        y="0"
        width="124.969"
        height="118.764"
        filterUnits="userSpaceOnUse"
        colorInterpolationFilters="sRGB"
      >
        <feFlood floodOpacity="0" result="BackgroundImageFix" />
        <feBlend
          mode="normal"
          in="SourceGraphic"
          in2="BackgroundImageFix"
          result="shape"
        />
        <feColorMatrix
          in="SourceAlpha"
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
          result="hardAlpha"
        />
        <feOffset />
        <feGaussianBlur stdDeviation="6.551" />
        <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
        <feColorMatrix
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"
        />
        <feBlend mode="normal" in2="shape" result="effect1_innerShadow_269_329" />
      </filter>
    </defs>
  </svg>
);
}


id: hero-illustration/click-animation/drawing-the-motion-lines
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: Drawing the motion lines
excerpt: Now we’ll animate the motion lines using the stroke dash technique from the previous lessons.
url_path: /lessons/hero-illustration/click-animation/#drawing-the-motion-lines
markdown_path: /md/sections/hero-illustration/click-animation/drawing-the-motion-lines.md
body:
Now we’ll animate the motion lines using the stroke dash technique from the previous lessons.

Here’s how we set up each line:

<motion.line
  data-animate="line"
  data-index="0"
  initial={{ strokeDashoffset: 0 }}
  strokeLinecap="round"
  strokeDasharray="1px 1.1px"
  pathLength="1"
/>

<motion.line
  data-animate="line"
  data-index="0"
  initial={{ strokeDashoffset: 0 }}
  strokeLinecap="round"
  strokeDasharray="1px 1.1px"
  pathLength="1"
/>

We’re normalizing the line length with pathLength="1" and creating a dashed pattern with strokeDasharray="1px 1.1px". Notice the gap (1.1px) is larger than the dash (1px).

This is because strokeLinecap="round" extends the caps (ends) beyond the mathematical dash length. If the gap matched the dash exactly, those rounded caps would peek through when the line should be fully hidden. The extra 0.1px ensures clean visibility control when we offset the pattern.

Now let’s get all the lines within the scope and animate them to their appropriate values:

// Initial offsets create the staggered appearance
const defaultStrokeDashoffsets = [0, 0.55, 0.9];

const handleMouseEnter = () => {
  // ...other animations

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = line.getAttribute("data-index");
    animate(
      line,
      // Middle line (1) draws fully, outer lines stop at 60%
      { strokeDashoffset: index === "1" ? [1.05, 0] : [1.05, 0.4] },
      {
        delay: index === "1" ? 0 : 0.04, // Delay the outer lines
        duration: 0.53,
        times: [0.7, 0.9], // Quick reveal
      }
    );
  });
};

const handleMouseLeave = () => {
  // ...other animations

  // Animate the lines back to their default positions
  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = line.getAttribute("data-index");
    animate(line, {
      strokeDashoffset: defaultStrokeDashoffsets[Number(index)],
    });
  });
};

// Initial offsets create the staggered appearance
const defaultStrokeDashoffsets = [0, 0.55, 0.9];

const handleMouseEnter = () => {
  // ...other animations

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = line.getAttribute("data-index");
    animate(
      line,
      // Middle line (1) draws fully, outer lines stop at 60%
      { strokeDashoffset: index === "1" ? [1.05, 0] : [1.05, 0.4] },
      {
        delay: index === "1" ? 0 : 0.04, // Delay the outer lines
        duration: 0.53,
        times: [0.7, 0.9], // Quick reveal
      }
    );
  });
};

const handleMouseLeave = () => {
  // ...other animations

  // Animate the lines back to their default positions
  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = line.getAttribute("data-index");
    animate(line, {
      strokeDashoffset: defaultStrokeDashoffsets[Number(index)],
    });
  });
};

Each line starts at a different offset (defined in defaultStrokeDashoffsets), creating that initial staggered appearance. On hover, we animate them from 1.05 (hidden) to their target positions - the middle line draws in fully (0), while the outer lines stop at 0.4, showing only 60% of their length.

The middle line starts immediately while the others have a 40ms delay, and all animations happen in 20% of the duration (times: [0.7, 0.9]). This creates a quick, punchy reveal that coincides with the hand path animation ending.

On mouse leave, each line returns to its initial offset position, ready for the next interaction.

"use client";

import {
motion,
useAnimate,
useMotionValue,
useTransform,
easeOut,
easeInOut,
} from "motion/react";

const handPaths = [
"M58.5431 71.4419C60.2401 71.6239 61.3091 69.6649 60.2391 68.3369L49.2671 54.7189C48.6804 53.9554 48.4158 52.9927 48.5299 52.0366C48.6439 51.0805 49.1275 50.207 49.8773 49.6028C50.6271 48.9987 51.5835 48.712 52.542 48.804C53.5004 48.8959 54.3849 49.3593 55.0061 50.0949L58.3501 54.2449C58.8429 54.8569 59.5061 55.3089 60.2559 55.5437C61.0057 55.7785 61.8083 55.7855 62.5621 55.5639L70.6681 53.1809C72.8705 52.5335 75.23 52.6775 77.3372 53.5881C79.4444 54.4987 81.1663 56.1184 82.2041 58.1659L84.9681 63.6179C85.7915 65.2412 86.0321 67.098 85.6498 68.8775C85.2675 70.657 84.2855 72.2511 82.8681 73.3929L74.6611 80.0039C73.7139 80.7673 72.604 81.3028 71.417 81.5691C70.23 81.8355 68.9976 81.8255 67.8151 81.5399L55.1911 78.4919C54.2783 78.2707 53.4839 77.7106 52.9688 76.9252C52.4537 76.1399 52.2566 75.188 52.4174 74.2627C52.5781 73.3374 53.0848 72.5078 53.8346 71.9422C54.5844 71.3766 55.5212 71.1173 56.4551 71.2169L58.5431 71.4419Z",
"M58.7957 70.4997C60.4927 70.6817 61.3092 69.6653 60.2392 68.3373L51.8366 57.8368C51.2499 57.0733 50.867 55.9897 50.981 55.0336C51.0951 54.0776 51.5787 53.204 52.3285 52.5999C53.0783 51.9958 54.0347 51.7091 54.9931 51.801C55.9516 51.893 56.7918 52.4457 57.413 53.1813L58.3502 54.2453C58.8429 54.8573 59.5062 55.3093 60.256 55.5441C61.0058 55.7789 61.8084 55.7859 62.5622 55.5643L70.6681 53.1813C72.8705 52.5339 75.23 52.6779 77.3373 53.5885C79.4445 54.4991 81.1664 56.1187 82.2042 58.1663L84.9682 63.6183C85.7915 65.2416 86.0322 67.0984 85.6499 68.8778C85.2676 70.6573 84.2855 72.2515 82.8682 73.3933L74.6611 80.0043C73.714 80.7677 72.604 81.3032 71.417 81.5695C70.23 81.8358 68.9977 81.8258 67.8151 81.5403L55.5115 77.3318C54.5988 77.1105 53.8043 76.5504 53.2892 75.765C52.7742 74.9797 52.577 74.0279 52.7378 73.1025C52.8986 72.1772 53.4052 71.3476 54.155 70.782C54.9048 70.2165 55.8416 69.9572 56.7755 70.0567L58.7957 70.4997Z"
];

const defaultStrokeDashoffsets = [0, 0.55, 0.9];

export default function ClickAnimation() {
const [scope, animate] = useAnimate();
const handPathProgress = useMotionValue(0);
const handPath = useTransform(handPathProgress, [0, 1], handPaths);

const handleMouseEnter = () => {
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.53,
      times: [0, 0.2, 0.6, 1],
      ease: easeOut,
      delay: 0.2,
    }
  );

  animate(
    "[data-animate='hand']",
    {
      transform: [
        "translateX(0px) translateY(0px) rotate(0deg)",
        "translateX(-4px) translateY(3px) rotate(25deg)",
      ],
    },
    {
      duration: 0.53,
      times: [0, 0.4],
      ease: easeInOut,
    }
  );

  animate(handPathProgress, [0, 1, 0], {
    duration: 0.53,
    times: [0.4, 0.6, 0.9],
    ease: easeOut,
  });

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = line.getAttribute("data-index");
    animate(line, {
      strokeDashoffset: index === "1" ? [1.05, 0] : [1.05, 0.4],
    }, {
      delay: index === "1" ? 0 : 0.04,
      duration: 0.53,
      times: [0.7, 0.9],
    });
  });
};

const handleMouseLeave = () => {
  animate("[data-animate='hand']", {
    transform: "translateX(0px) translateY(0px) rotate(0deg)",
  });

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = line.getAttribute("data-index");
    animate(line, {
      strokeDashoffset: defaultStrokeDashoffsets[Number(index)],
    });
  });
};

return (
  <svg
    width="125"
    height="119"
    viewBox="0 0 125 119"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    className="overflow-visible!"
  >
    <g
      ref={scope}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      <motion.g
        data-animate="background"
        initial={{ transform: "scale(1)" }}
        filter="url(#filter0_i_269_329)"
      >
        <path
          d="M14.9066 10.1626C18.9956 19.8776 23.4146 30.4306 25.5696 35.7296C26.3866 37.7366 25.6336 39.9006 23.7896 41.0376L12.7336 47.8526C12.1283 48.2259 11.6112 48.7261 11.218 49.3187C10.8249 49.9113 10.5651 50.5822 10.4565 51.285C10.3479 51.9879 10.3932 52.7059 10.5891 53.3895C10.7851 54.0732 11.1272 54.7061 11.5916 55.2446L19.3536 64.2426C20.1213 65.1328 20.5445 66.2687 20.5464 67.4443C20.5482 68.6198 20.1286 69.757 19.3636 70.6496L1.34161 91.6766C-1.69439 95.2186 1.45161 100.601 6.02761 99.6936L31.5196 94.6386C32.7448 94.3961 34.0163 94.6291 35.0758 95.2904C36.1353 95.9516 36.9033 96.9915 37.2236 98.1986L41.1356 112.939C42.0306 116.315 46.0646 117.704 48.8496 115.596L60.7136 106.617C61.5752 105.964 62.628 105.614 63.7089 105.62C64.7897 105.627 65.8382 105.99 66.6916 106.654L81.3666 118.048C84.2466 120.285 88.4726 118.716 89.1956 115.143L92.5696 98.4746C92.7053 97.8046 92.9793 97.1702 93.3741 96.6121C93.7689 96.054 94.2759 95.5844 94.8625 95.2333C95.4491 94.8823 96.1026 94.6575 96.781 94.5733C97.4595 94.4892 98.148 94.5475 98.8026 94.7446L115.49 99.7726C119.957 101.119 123.61 96.0636 120.929 92.2446L106.344 71.4686C105.633 70.4557 105.331 69.2121 105.497 67.986C105.663 66.7599 106.286 65.6417 107.241 64.8546L123.32 51.6046C126.177 49.2496 125.503 44.7016 122.085 43.2766L109.166 37.8936C108.082 37.4416 107.195 36.6163 106.667 35.5672C106.139 34.5182 106.004 33.3146 106.287 32.1746L111.616 10.7026C112.746 6.15163 107.466 2.75564 103.793 5.67064L84.2026 21.2176C81.6436 23.2486 77.8526 22.2626 76.6066 19.2426L70.0536 3.36063C68.5736 -0.224368 63.7166 -0.762367 61.4886 2.41363L51.8826 16.1066C51.1775 17.1118 50.1215 17.8164 48.9227 18.0819C47.7239 18.3473 46.4692 18.1542 45.4056 17.5406L21.8996 3.98863C17.8176 1.63563 13.0796 5.82063 14.9066 10.1626Z"
          fill="#F8F8F8"
        />
      </motion.g>

      <motion.path
        d={handPath}
        data-animate="hand"
        initial={{
          transform: "translateX(0%) translateY(0%) rotate(0deg)",
        }}
        fill="#989898"
      />

      <motion.line
        data-animate="line"
        data-index="2"
        initial={{ strokeDashoffset: defaultStrokeDashoffsets[2] }}
        x1="62.8541"
        y1="39.4586"
        x2="64.5595"
        y2="34.929"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
      <motion.line
        data-animate="line"
        data-index="1"
        initial={{ strokeDashoffset: defaultStrokeDashoffsets[1] }}
        x1="53.0633"
        y1="38.313"
        x2="52.2947"
        y2="33.5344"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
      <motion.line
        data-animate="line"
        data-index="0"
        initial={{ strokeDashoffset: defaultStrokeDashoffsets[0] }}
        x1="44.047"
        y1="42.3621"
        x2="41.0059"
        y2="38.5917"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
    </g>

    <defs>
      <filter
        id="filter0_i_269_329"
        x="0"
        y="0"
        width="124.969"
        height="118.764"
        filterUnits="userSpaceOnUse"
        colorInterpolationFilters="sRGB"
      >
        <feFlood floodOpacity="0" result="BackgroundImageFix" />
        <feBlend
          mode="normal"
          in="SourceGraphic"
          in2="BackgroundImageFix"
          result="shape"
        />
        <feColorMatrix
          in="SourceAlpha"
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
          result="hardAlpha"
        />
        <feOffset />
        <feGaussianBlur stdDeviation="6.551" />
        <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
        <feColorMatrix
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"
        />
        <feBlend mode="normal" in2="shape" result="effect1_innerShadow_269_329" />
      </filter>
    </defs>
  </svg>
);
}


id: hero-illustration/click-animation/making-it-clickable
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: Making it clickable
url_path: /lessons/hero-illustration/click-animation/#making-it-clickable
markdown_path: /md/sections/hero-illustration/click-animation/making-it-clickable.md
excerpt:
Let’s make this actually clickable. After the hover animation completes, clicking the hand should trigger the animations immediately without the gradual buildup.

body:
Let’s make this actually clickable. After the hover animation completes, clicking the hand should trigger the animations immediately without the gradual buildup.

The click handler is similar to hover, but with different timing:

const handleClick = () => {
  // Background: no delay on click (instant feedback)
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.53,
      times: [0.1, 0.3, 0.65, 1],
      ease: easeOut,
    }
  );

  // Hand: already in position from hover, just maintain it
  animate("[data-animate='hand']", {
    transform: "translateX(-4px) translateY(3px) rotate(25deg)",
  });

  // Path morph
  animate(handPathProgress, [0, 1, 0], {
    duration: 0.53,
    times: [0.1, 0.3, 0.6],
    ease: easeOut,
  });

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = Number(line.getAttribute("data-index"));
    animate(
      line,
      {
        strokeDashoffset: index === 1 ? [1.05, 0] : [1.05, 0.4],
      },
      {
        delay: index === 1 ? 0 : 0.04,
        duration: 0.53,
        times: [0.4, 0.6],
      }
    );
  });
};

const handleClick = () => {
  // Background: no delay on click (instant feedback)
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.53,
      times: [0.1, 0.3, 0.65, 1],
      ease: easeOut,
    }
  );

  // Hand: already in position from hover, just maintain it
  animate("[data-animate='hand']", {
    transform: "translateX(-4px) translateY(3px) rotate(25deg)",
  });

  // Path morph
  animate(handPathProgress, [0, 1, 0], {
    duration: 0.53,
    times: [0.1, 0.3, 0.6],
    ease: easeOut,
  });

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = Number(line.getAttribute("data-index"));
    animate(
      line,
      {
        strokeDashoffset: index === 1 ? [1.05, 0] : [1.05, 0.4],
      },
      {
        delay: index === 1 ? 0 : 0.04,
        duration: 0.53,
        times: [0.4, 0.6],
      }
    );
  });
};

Notice the differences from hover.

There’s no delay on the background - the click should feel instant and responsive.

For the hand transform, we pass a single value instead of keyframes. Since the hand is already in position from the hover, we just keep it there.

The path morph uses different times - starting at 0.1 instead of 0.4 to make the finger curl happen sooner. The proportions within the animation stay the same (20% to curl, 30% to uncurl), just happening earlier in the sequence.

Now, to make sure clicks only work after the hover animation finished, we can use a ref to track when it completes. This way we don’t trigger any re-renders as we’re not accessing the value inside JSX, so no need to reach for useState with this setup.

const hasAnimationCompletedRef = useRef(false);

const handleMouseEnter = async () => {
  // ...other animations

  await animate(handPathProgress, [0, 1, 0], {
    duration: 0.53,
    times: [0.4, 0.6, 0.9],
    ease: easeOut,
  });

  hasAnimationCompletedRef.current = true;
};

const handleMouseLeave = () => {
  hasAnimationCompletedRef.current = false;

  // ...animations
};

const handleClick = () => {
  if (!hasAnimationCompletedRef.current) return;

  // ...animations
};

const hasAnimationCompletedRef = useRef(false);

const handleMouseEnter = async () => {
  // ...other animations

  await animate(handPathProgress, [0, 1, 0], {
    duration: 0.53,
    times: [0.4, 0.6, 0.9],
    ease: easeOut,
  });

  hasAnimationCompletedRef.current = true;
};

const handleMouseLeave = () => {
  hasAnimationCompletedRef.current = false;

  // ...animations
};

const handleClick = () => {
  if (!hasAnimationCompletedRef.current) return;

  // ...animations
};

We make handleMouseEnter async and await one of the animations. Since they all have the same duration, any one works. Once it completes, we set the ref to true.

We reset it to false in handleMouseLeave, and guard against early clicks in handleClick.

Alternative approaches include await Promise.all([...animations]) if durations differ, or using .then() notation. Pick what fits your style or animation setup.

"use client";
import { useRef } from "react";
import {
motion,
useAnimate,
useMotionValue,
useTransform,
easeOut,
easeInOut,
} from "motion/react";

const handPaths = [
"M58.5431 71.4419C60.2401 71.6239 61.3091 69.6649 60.2391 68.3369L49.2671 54.7189C48.6804 53.9554 48.4158 52.9927 48.5299 52.0366C48.6439 51.0805 49.1275 50.207 49.8773 49.6028C50.6271 48.9987 51.5835 48.712 52.542 48.804C53.5004 48.8959 54.3849 49.3593 55.0061 50.0949L58.3501 54.2449C58.8429 54.8569 59.5061 55.3089 60.2559 55.5437C61.0057 55.7785 61.8083 55.7855 62.5621 55.5639L70.6681 53.1809C72.8705 52.5335 75.23 52.6775 77.3372 53.5881C79.4444 54.4987 81.1663 56.1184 82.2041 58.1659L84.9681 63.6179C85.7915 65.2412 86.0321 67.098 85.6498 68.8775C85.2675 70.657 84.2855 72.2511 82.8681 73.3929L74.6611 80.0039C73.7139 80.7673 72.604 81.3028 71.417 81.5691C70.23 81.8355 68.9976 81.8255 67.8151 81.5399L55.1911 78.4919C54.2783 78.2707 53.4839 77.7106 52.9688 76.9252C52.4537 76.1399 52.2566 75.188 52.4174 74.2627C52.5781 73.3374 53.0848 72.5078 53.8346 71.9422C54.5844 71.3766 55.5212 71.1173 56.4551 71.2169L58.5431 71.4419Z",
"M58.7957 70.4997C60.4927 70.6817 61.3092 69.6653 60.2392 68.3373L51.8366 57.8368C51.2499 57.0733 50.867 55.9897 50.981 55.0336C51.0951 54.0776 51.5787 53.204 52.3285 52.5999C53.0783 51.9958 54.0347 51.7091 54.9931 51.801C55.9516 51.893 56.7918 52.4457 57.413 53.1813L58.3502 54.2453C58.8429 54.8573 59.5062 55.3093 60.256 55.5441C61.0058 55.7789 61.8084 55.7859 62.5622 55.5643L70.6681 53.1813C72.8705 52.5339 75.23 52.6779 77.3373 53.5885C79.4445 54.4991 81.1664 56.1187 82.2042 58.1663L84.9682 63.6183C85.7915 65.2416 86.0322 67.0984 85.6499 68.8778C85.2676 70.6573 84.2855 72.2515 82.8682 73.3933L74.6611 80.0043C73.714 80.7677 72.604 81.3032 71.417 81.5695C70.23 81.8358 68.9977 81.8258 67.8151 81.5403L55.5115 77.3318C54.5988 77.1105 53.8043 76.5504 53.2892 75.765C52.7742 74.9797 52.577 74.0279 52.7378 73.1025C52.8986 72.1772 53.4052 71.3476 54.155 70.782C54.9048 70.2165 55.8416 69.9572 56.7755 70.0567L58.7957 70.4997Z"
];

const defaultStrokeDashoffsets = [0, 0.55, 0.9];

export default function ClickAnimation() {
const [scope, animate] = useAnimate();
const handPathProgress = useMotionValue(0);
const handPath = useTransform(handPathProgress, [0, 1], handPaths);
const hasAnimationCompletedRef = useRef(false);

const handleMouseEnter = async () => {
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.53,
      times: [0, 0.2, 0.6, 1],
      ease: easeOut,
      delay: 0.2,
    }
  );

  animate(
    "[data-animate='hand']",
    {
      transform: [
        "translateX(0px) translateY(0px) rotate(0deg)",
        "translateX(-4px) translateY(3px) rotate(25deg)",
      ],
    },
    {
      duration: 0.53,
      times: [0, 0.4],
      ease: easeInOut,
    }
  );

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = line.getAttribute("data-index");
    animate(line, {
      strokeDashoffset: index === "1" ? [1.05, 0] : [1.05, 0.4],
    }, {
      delay: index === "1" ? 0 : 0.04,
      duration: 0.53,
      times: [0.7, 0.9],
    });
  });

  await animate(handPathProgress, [0, 1, 0], {
    duration: 0.53,
    times: [0.4, 0.6, 0.9],
    ease: easeOut,
  });

  hasAnimationCompletedRef.current = true;
};

const handleMouseLeave = async () => {
  hasAnimationCompletedRef.current = false;

  animate("[data-animate='hand']", {
    transform: "translateX(0px) translateY(0px) rotate(0deg)",
  });

  await Promise.all(
    Array.from(scope.current.querySelectorAll("[data-animate='line']")).map((line) => {
      const index = line.getAttribute("data-index");
      return animate(line, {
        strokeDashoffset: defaultStrokeDashoffsets[Number(index)],
      });
    })
  );
};

const handleClick = () => {
  if (!hasAnimationCompletedRef.current) return;

  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.53,
      times: [0.1, 0.3, 0.65, 1],
      ease: easeOut,
    }
  );

  animate("[data-animate='hand']", {
    transform: "translateX(-4px) translateY(3px) rotate(25deg)",
  });

  animate(handPathProgress, [0, 1, 0], {
    duration: 0.53,
    times: [0.1, 0.3, 0.6],
    ease: easeOut,
  });

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = Number(line.getAttribute("data-index"));
    animate(
      line,
      {
        strokeDashoffset: index === 1 ? [1.05, 0] : [1.05, 0.4],
      },
      {
        delay: index === 1 ? 0 : 0.04,
        duration: 0.53,
        times: [0.4, 0.6],
      },
    );
  });
};

return (
  <svg
    width="125"
    height="119"
    viewBox="0 0 125 119"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    className="overflow-visible!"
  >
    <g
      ref={scope}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      onClick={handleClick}
    >
      <motion.g
        data-animate="background"
        initial={{ transform: "scale(1)" }}
        filter="url(#filter0_i_269_329)"
      >
        <path
          d="M14.9066 10.1626C18.9956 19.8776 23.4146 30.4306 25.5696 35.7296C26.3866 37.7366 25.6336 39.9006 23.7896 41.0376L12.7336 47.8526C12.1283 48.2259 11.6112 48.7261 11.218 49.3187C10.8249 49.9113 10.5651 50.5822 10.4565 51.285C10.3479 51.9879 10.3932 52.7059 10.5891 53.3895C10.7851 54.0732 11.1272 54.7061 11.5916 55.2446L19.3536 64.2426C20.1213 65.1328 20.5445 66.2687 20.5464 67.4443C20.5482 68.6198 20.1286 69.757 19.3636 70.6496L1.34161 91.6766C-1.69439 95.2186 1.45161 100.601 6.02761 99.6936L31.5196 94.6386C32.7448 94.3961 34.0163 94.6291 35.0758 95.2904C36.1353 95.9516 36.9033 96.9915 37.2236 98.1986L41.1356 112.939C42.0306 116.315 46.0646 117.704 48.8496 115.596L60.7136 106.617C61.5752 105.964 62.628 105.614 63.7089 105.62C64.7897 105.627 65.8382 105.99 66.6916 106.654L81.3666 118.048C84.2466 120.285 88.4726 118.716 89.1956 115.143L92.5696 98.4746C92.7053 97.8046 92.9793 97.1702 93.3741 96.6121C93.7689 96.054 94.2759 95.5844 94.8625 95.2333C95.4491 94.8823 96.1026 94.6575 96.781 94.5733C97.4595 94.4892 98.148 94.5475 98.8026 94.7446L115.49 99.7726C119.957 101.119 123.61 96.0636 120.929 92.2446L106.344 71.4686C105.633 70.4557 105.331 69.2121 105.497 67.986C105.663 66.7599 106.286 65.6417 107.241 64.8546L123.32 51.6046C126.177 49.2496 125.503 44.7016 122.085 43.2766L109.166 37.8936C108.082 37.4416 107.195 36.6163 106.667 35.5672C106.139 34.5182 106.004 33.3146 106.287 32.1746L111.616 10.7026C112.746 6.15163 107.466 2.75564 103.793 5.67064L84.2026 21.2176C81.6436 23.2486 77.8526 22.2626 76.6066 19.2426L70.0536 3.36063C68.5736 -0.224368 63.7166 -0.762367 61.4886 2.41363L51.8826 16.1066C51.1775 17.1118 50.1215 17.8164 48.9227 18.0819C47.7239 18.3473 46.4692 18.1542 45.4056 17.5406L21.8996 3.98863C17.8176 1.63563 13.0796 5.82063 14.9066 10.1626Z"
          fill="#F8F8F8"
        />
      </motion.g>

      <motion.path
        d={handPath}
        data-animate="hand"
        initial={{
          transform: "translateX(0%) translateY(0%) rotate(0deg)",
        }}
        fill="#989898"
      />

      <motion.line
        data-animate="line"
        data-index="2"
        initial={{ strokeDashoffset: defaultStrokeDashoffsets[2] }}
        x1="62.8541"
        y1="39.4586"
        x2="64.5595"
        y2="34.929"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
      <motion.line
        data-animate="line"
        data-index="1"
        initial={{ strokeDashoffset: defaultStrokeDashoffsets[1] }}
        x1="53.0633"
        y1="38.313"
        x2="52.2947"
        y2="33.5344"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
      <motion.line
        data-animate="line"
        data-index="0"
        initial={{ strokeDashoffset: defaultStrokeDashoffsets[0] }}
        x1="44.047"
        y1="42.3621"
        x2="41.0059"
        y2="38.5917"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
    </g>

    <defs>
      <filter
        id="filter0_i_269_329"
        x="0"
        y="0"
        width="124.969"
        height="118.764"
        filterUnits="userSpaceOnUse"
        colorInterpolationFilters="sRGB"
      >
        <feFlood floodOpacity="0" result="BackgroundImageFix" />
        <feBlend
          mode="normal"
          in="SourceGraphic"
          in2="BackgroundImageFix"
          result="shape"
        />
        <feColorMatrix
          in="SourceAlpha"
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
          result="hardAlpha"
        />
        <feOffset />
        <feGaussianBlur stdDeviation="6.551" />
        <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
        <feColorMatrix
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"
        />
        <feBlend mode="normal" in2="shape" result="effect1_innerShadow_269_329" />
      </filter>
    </defs>
  </svg>
);
}

When users aren’t interacting with the animation, we want it to subtly loop to catch their attention. This means starting an infinite animation on mount, then restarting it when the user’s mouse leaves.

Here’s how we set up a repeating idle animation:

const startIdleAnimations = () => {
  // Background pulse
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.63,
      times: [0.2, 0.5, 0.85, 1],
      ease: easeOut,
      repeat: Infinity,
      repeatType: "loop",
      repeatDelay: 2,
      delay: 2, // Initial delay before first loop
    }
  );

  // Hand path morphing
  animate(handPathProgress, [0, 1, 0], {
    duration: 0.63,
    times: [0.1, 0.4, 0.8],
    ease: easeOut,
    repeat: Infinity,
    repeatType: "loop",
    repeatDelay: 2,
    delay: 2,
  });

  // Motion lines: hidden then back to default state
  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = Number(line.getAttribute("data-index"));
    animate(
      line,
      {
        strokeDashoffset: [
          defaultStrokeDashoffsets[index], // Start at default position
          1.05, // Hidden
          defaultStrokeDashoffsets[index], // Back to default
        ],
      },
      {
        duration: 0.63,
        // First 2 keyframes have the same time so that the line starts
        // at it’s default position and is hidden instantly,
        // then animates back to default position by 0.8
        times: [0.5, 0.5, 0.8],
        repeat: Infinity,
        repeatType: "loop",
        repeatDelay: 2,
        delay: 2,
      }
    );
  });
};

const startIdleAnimations = () => {
  // Background pulse
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.63,
      times: [0.2, 0.5, 0.85, 1],
      ease: easeOut,
      repeat: Infinity,
      repeatType: "loop",
      repeatDelay: 2,
      delay: 2, // Initial delay before first loop
    }
  );

  // Hand path morphing
  animate(handPathProgress, [0, 1, 0], {
    duration: 0.63,
    times: [0.1, 0.4, 0.8],
    ease: easeOut,
    repeat: Infinity,
    repeatType: "loop",
    repeatDelay: 2,
    delay: 2,
  });

  // Motion lines: hidden then back to default state
  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = Number(line.getAttribute("data-index"));
    animate(
      line,
      {
        strokeDashoffset: [
          defaultStrokeDashoffsets[index], // Start at default position
          1.05, // Hidden
          defaultStrokeDashoffsets[index], // Back to default
        ],
      },
      {
        duration: 0.63,
        // First 2 keyframes have the same time so that the line starts
        // at it’s default position and is hidden instantly,
        // then animates back to default position by 0.8
        times: [0.5, 0.5, 0.8],
        repeat: Infinity,
        repeatType: "loop",
        repeatDelay: 2,
        delay: 2,
      }
    );
  });
};

The key properties here are repeat: Infinity to loop forever, and repeatDelay: 2 to wait 2 seconds between loops.

We also use delay: 2 for the initial delay. This gives users time to discover the hover interaction naturally before the idle animation starts drawing attention. (Note that I made the delay and repeatDelay short here so you don’t have to wait too long to see it happen. In the hero animation we have repeatDelay: 6, delay: 2.5 on the first idle animation and delay: 6 for all subsequent idle animations.)

Now we need to start these animations when the component mounts:

useEffect(() => {
  startIdleAnimations();
}, []);

useEffect(() => {
  startIdleAnimations();
}, []);

Finally, we need to restart the idle animations when the user’s mouse leaves. But there’s a coordination challenge - if we start the idle loop while the hand and lines are still resetting to their initial positions, the animations will conflict.

The solution is to wait for the reset animations to complete before starting the idle loop:

const handleMouseLeave = async () => {
  hasAnimationCompletedRef.current = false;

  animate("[data-animate='hand']", {
    transform: "translateX(0px) translateY(0px) rotate(0deg)",
  });

  await Promise.all(
    Array.from(scope.current.querySelectorAll("[data-animate='line']")).map(
      (line) => {
        const index = line.getAttribute("data-index");
        return animate(line, {
          strokeDashoffset: defaultStrokeDashoffsets[Number(index)],
        });
      }
    )
  );

  startIdleAnimations();
};

const handleMouseLeave = async () => {
  hasAnimationCompletedRef.current = false;

  animate("[data-animate='hand']", {
    transform: "translateX(0px) translateY(0px) rotate(0deg)",
  });

  await Promise.all(
    Array.from(scope.current.querySelectorAll("[data-animate='line']")).map(
      (line) => {
        const index = line.getAttribute("data-index");
        return animate(line, {
          strokeDashoffset: defaultStrokeDashoffsets[Number(index)],
        });
      }
    )
  );

  startIdleAnimations();
};

We use Promise.all with .map() to wait for all line animations to complete before calling startIdleAnimations(). This ensures a smooth transition from the interactive state back to the idle loop.

"use client";

import { useRef, useEffect } from "react";
import {
motion,
useAnimate,
useMotionValue,
useTransform,
easeOut,
easeInOut,
} from "motion/react";

const handPaths = [
"M58.5431 71.4419C60.2401 71.6239 61.3091 69.6649 60.2391 68.3369L49.2671 54.7189C48.6804 53.9554 48.4158 52.9927 48.5299 52.0366C48.6439 51.0805 49.1275 50.207 49.8773 49.6028C50.6271 48.9987 51.5835 48.712 52.542 48.804C53.5004 48.8959 54.3849 49.3593 55.0061 50.0949L58.3501 54.2449C58.8429 54.8569 59.5061 55.3089 60.2559 55.5437C61.0057 55.7785 61.8083 55.7855 62.5621 55.5639L70.6681 53.1809C72.8705 52.5335 75.23 52.6775 77.3372 53.5881C79.4444 54.4987 81.1663 56.1184 82.2041 58.1659L84.9681 63.6179C85.7915 65.2412 86.0321 67.098 85.6498 68.8775C85.2675 70.657 84.2855 72.2511 82.8681 73.3929L74.6611 80.0039C73.7139 80.7673 72.604 81.3028 71.417 81.5691C70.23 81.8355 68.9976 81.8255 67.8151 81.5399L55.1911 78.4919C54.2783 78.2707 53.4839 77.7106 52.9688 76.9252C52.4537 76.1399 52.2566 75.188 52.4174 74.2627C52.5781 73.3374 53.0848 72.5078 53.8346 71.9422C54.5844 71.3766 55.5212 71.1173 56.4551 71.2169L58.5431 71.4419Z",
"M58.7957 70.4997C60.4927 70.6817 61.3092 69.6653 60.2392 68.3373L51.8366 57.8368C51.2499 57.0733 50.867 55.9897 50.981 55.0336C51.0951 54.0776 51.5787 53.204 52.3285 52.5999C53.0783 51.9958 54.0347 51.7091 54.9931 51.801C55.9516 51.893 56.7918 52.4457 57.413 53.1813L58.3502 54.2453C58.8429 54.8573 59.5062 55.3093 60.256 55.5441C61.0058 55.7789 61.8084 55.7859 62.5622 55.5643L70.6681 53.1813C72.8705 52.5339 75.23 52.6779 77.3373 53.5885C79.4445 54.4991 81.1664 56.1187 82.2042 58.1663L84.9682 63.6183C85.7915 65.2416 86.0322 67.0984 85.6499 68.8778C85.2676 70.6573 84.2855 72.2515 82.8682 73.3933L74.6611 80.0043C73.714 80.7677 72.604 81.3032 71.417 81.5695C70.23 81.8358 68.9977 81.8258 67.8151 81.5403L55.5115 77.3318C54.5988 77.1105 53.8043 76.5504 53.2892 75.765C52.7742 74.9797 52.577 74.0279 52.7378 73.1025C52.8986 72.1772 53.4052 71.3476 54.155 70.782C54.9048 70.2165 55.8416 69.9572 56.7755 70.0567L58.7957 70.4997Z"
];

const defaultStrokeDashoffsets = [0, 0.55, 0.9];

export default function ClickAnimation() {
const [scope, animate] = useAnimate();
const handPathProgress = useMotionValue(0);
const handPath = useTransform(handPathProgress, [0, 1], handPaths);
const hasAnimationCompletedRef = useRef(false);

const startIdleAnimations = () => {
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.63,
      times: [0.2, 0.5, 0.85, 1],
      ease: easeOut,
      repeat: Infinity,
      repeatType: "loop",
      repeatDelay: 2,
      delay: 2,
    },
  );

  animate(handPathProgress, [0, 1, 0], {
    duration: 0.63,
    times: [0.1, 0.4, 0.8],
    ease: easeOut,
    repeat: Infinity,
    repeatType: "loop",
    repeatDelay: 2,
    delay: 2,
  });

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = Number(line.getAttribute("data-index"));
    animate(
      line,
      {
        strokeDashoffset: [
          defaultStrokeDashoffsets[index],
          1.05,
          defaultStrokeDashoffsets[index],
        ],
      },
      {
        duration: 0.63,
        times: [0.5, 0.5, 0.8],
        repeat: Infinity,
        repeatType: "loop",
        repeatDelay: 2,
        delay: 2,
      },
    );
  });
};

useEffect(() => {
  startIdleAnimations();
}, []);

const handleMouseEnter = async () => {
  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.53,
      times: [0, 0.2, 0.6, 1],
      ease: easeOut,
      delay: 0.2,
    }
  );

  animate(
    "[data-animate='hand']",
    {
      transform: [
        "translateX(0px) translateY(0px) rotate(0deg)",
        "translateX(-4px) translateY(3px) rotate(25deg)",
      ],
    },
    {
      duration: 0.53,
      times: [0, 0.4],
      ease: easeInOut,
    }
  );

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = line.getAttribute("data-index");
    animate(line, {
      strokeDashoffset: index === "1" ? [1.05, 0] : [1.05, 0.4],
    }, {
      delay: index === "1" ? 0 : 0.04,
      duration: 0.53,
      times: [0.7, 0.9],
    });
  });

  await animate(handPathProgress, [0, 1, 0], {
    duration: 0.53,
    times: [0.4, 0.6, 0.9],
    ease: easeOut,
  });

  hasAnimationCompletedRef.current = true;
};

const handleMouseLeave = async () => {
  hasAnimationCompletedRef.current = false;

  animate("[data-animate='hand']", {
    transform: "translateX(0px) translateY(0px) rotate(0deg)",
  });

  await Promise.all(
    Array.from(scope.current.querySelectorAll("[data-animate='line']")).map((line) => {
      const index = line.getAttribute("data-index");
      return animate(line, {
        strokeDashoffset: defaultStrokeDashoffsets[Number(index)],
      });
    })
  );

  startIdleAnimations();
};

const handleClick = () => {
  if (!hasAnimationCompletedRef.current) return;

  animate(
    "[data-animate='background']",
    { transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] },
    {
      duration: 0.53,
      times: [0.1, 0.3, 0.65, 1],
      ease: easeOut,
    }
  );

  animate("[data-animate='hand']", {
    transform: "translateX(-4px) translateY(3px) rotate(25deg)",
  });

  animate(handPathProgress, [0, 1, 0], {
    duration: 0.53,
    times: [0.1, 0.3, 0.6],
    ease: easeOut,
  });

  scope.current.querySelectorAll("[data-animate='line']").forEach((line) => {
    const index = Number(line.getAttribute("data-index"));
    animate(
      line,
      {
        strokeDashoffset: index === 1 ? [1.05, 0] : [1.05, 0.4],
      },
      {
        delay: index === 1 ? 0 : 0.04,
        duration: 0.53,
        times: [0.4, 0.6],
      },
    );
  });
};

return (
  <svg
    width="125"
    height="119"
    viewBox="0 0 125 119"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    className="overflow-visible!"
  >
    <g
      ref={scope}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      onClick={handleClick}
    >
      <motion.g
        data-animate="background"
        initial={{ transform: "scale(1)" }}
        filter="url(#filter0_i_269_329)"
      >
        <path
          d="M14.9066 10.1626C18.9956 19.8776 23.4146 30.4306 25.5696 35.7296C26.3866 37.7366 25.6336 39.9006 23.7896 41.0376L12.7336 47.8526C12.1283 48.2259 11.6112 48.7261 11.218 49.3187C10.8249 49.9113 10.5651 50.5822 10.4565 51.285C10.3479 51.9879 10.3932 52.7059 10.5891 53.3895C10.7851 54.0732 11.1272 54.7061 11.5916 55.2446L19.3536 64.2426C20.1213 65.1328 20.5445 66.2687 20.5464 67.4443C20.5482 68.6198 20.1286 69.757 19.3636 70.6496L1.34161 91.6766C-1.69439 95.2186 1.45161 100.601 6.02761 99.6936L31.5196 94.6386C32.7448 94.3961 34.0163 94.6291 35.0758 95.2904C36.1353 95.9516 36.9033 96.9915 37.2236 98.1986L41.1356 112.939C42.0306 116.315 46.0646 117.704 48.8496 115.596L60.7136 106.617C61.5752 105.964 62.628 105.614 63.7089 105.62C64.7897 105.627 65.8382 105.99 66.6916 106.654L81.3666 118.048C84.2466 120.285 88.4726 118.716 89.1956 115.143L92.5696 98.4746C92.7053 97.8046 92.9793 97.1702 93.3741 96.6121C93.7689 96.054 94.2759 95.5844 94.8625 95.2333C95.4491 94.8823 96.1026 94.6575 96.781 94.5733C97.4595 94.4892 98.148 94.5475 98.8026 94.7446L115.49 99.7726C119.957 101.119 123.61 96.0636 120.929 92.2446L106.344 71.4686C105.633 70.4557 105.331 69.2121 105.497 67.986C105.663 66.7599 106.286 65.6417 107.241 64.8546L123.32 51.6046C126.177 49.2496 125.503 44.7016 122.085 43.2766L109.166 37.8936C108.082 37.4416 107.195 36.6163 106.667 35.5672C106.139 34.5182 106.004 33.3146 106.287 32.1746L111.616 10.7026C112.746 6.15163 107.466 2.75564 103.793 5.67064L84.2026 21.2176C81.6436 23.2486 77.8526 22.2626 76.6066 19.2426L70.0536 3.36063C68.5736 -0.224368 63.7166 -0.762367 61.4886 2.41363L51.8826 16.1066C51.1775 17.1118 50.1215 17.8164 48.9227 18.0819C47.7239 18.3473 46.4692 18.1542 45.4056 17.5406L21.8996 3.98863C17.8176 1.63563 13.0796 5.82063 14.9066 10.1626Z"
          fill="#F8F8F8"
        />
      </motion.g>

      <motion.path
        d={handPath}
        data-animate="hand"
        initial={{
          transform: "translateX(0%) translateY(0%) rotate(0deg)",
        }}
        fill="#989898"
      />

      <motion.line
        data-animate="line"
        data-index="2"
        initial={{ strokeDashoffset: defaultStrokeDashoffsets[2] }}
        x1="62.8541"
        y1="39.4586"
        x2="64.5595"
        y2="34.929"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
      <motion.line
        data-animate="line"
        data-index="1"
        initial={{ strokeDashoffset: defaultStrokeDashoffsets[1] }}
        x1="53.0633"
        y1="38.313"
        x2="52.2947"
        y2="33.5344"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
      <motion.line
        data-animate="line"
        data-index="0"
        initial={{ strokeDashoffset: defaultStrokeDashoffsets[0] }}
        x1="44.047"
        y1="42.3621"
        x2="41.0059"
        y2="38.5917"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
    </g>

    <defs>
      <filter
        id="filter0_i_269_329"
        x="0"
        y="0"
        width="124.969"
        height="118.764"
        filterUnits="userSpaceOnUse"
        colorInterpolationFilters="sRGB"
      >
        <feFlood floodOpacity="0" result="BackgroundImageFix" />
        <feBlend
          mode="normal"
          in="SourceGraphic"
          in2="BackgroundImageFix"
          result="shape"
        />
        <feColorMatrix
          in="SourceAlpha"
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
          result="hardAlpha"
        />
        <feOffset />
        <feGaussianBlur stdDeviation="6.551" />
        <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
        <feColorMatrix
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"
        />
        <feBlend mode="normal" in2="shape" result="effect1_innerShadow_269_329" />
      </filter>
    </defs>
  </svg>
);
}


id: hero-illustration/click-animation/refactoring-to-variants
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: Refactoring to variants
url_path: /lessons/hero-illustration/click-animation/#refactoring-to-variants
markdown_path: /md/sections/hero-illustration/click-animation/refactoring-to-variants.md
excerpt:
At this point, our animation works, but the code is getting messy. We have timing values scattered across event handlers, duplicated animation calls, and it’s hard to see at a glance how the hover animation differs from the click animation.

body:
At this point, our animation works, but the code is getting messy. We have timing values scattered across event handlers, duplicated animation calls, and it’s hard to see at a glance how the hover animation differs from the click animation.


id: hero-illustration/click-animation/setting-up-the-variants
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: Setting up the variants
url_path: /lessons/hero-illustration/click-animation/#setting-up-the-variants
markdown_path: /md/sections/hero-illustration/click-animation/setting-up-the-variants.md
excerpt:
Let’s clean this up using a variant pattern - defining all our animation states upfront in a centralized place.

body:
Let’s clean this up using a variant pattern - defining all our animation states upfront in a centralized place.

Here’s what the background variants look like:

// variants.js

const DURATION = 0.53;

export const backgroundVariants = {
  initial: {
    transform: "scale(1)",
  },
  hover: {
    transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"],
    transition: {
      duration: DURATION,
      times: [0, 0.2, 0.6, 1],
      ease: easeOut,
      delay: 0.2,
    },
  },
  idle: {
    transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"],
    transition: {
      // ...
    },
  },
  click: {
    transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"],
    transition: {
      // ...
    },
  },
};

// ...other variants

// variants.js

const DURATION = 0.53;

export const backgroundVariants = {
  initial: {
    transform: "scale(1)",
  },
  hover: {
    transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"],
    transition: {
      duration: DURATION,
      times: [0, 0.2, 0.6, 1],
      ease: easeOut,
      delay: 0.2,
    },
  },
  idle: {
    transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"],
    transition: {
      // ...
    },
  },
  click: {
    transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"],
    transition: {
      // ...
    },
  },
};

// ...other variants

Each variant contains both the animation values and transition config. Now when we want to understand how the idle animation differs from hover, we can see it at a glance.

For elements that need per-item customization, we use functions:

export const lineVariants = {
  initial: (i) => ({
    strokeDashoffset: defaultStrokeDashoffsets[i],
  }),
  hover: (i) => ({
    strokeDashoffset: i === 1 ? [1.05, 0] : [1.05, 0.4],
    transition: {
      delay: i === 1 ? 0 : 0.04,
      duration: DURATION,
      times: [0.7, 0.9],
    },
  }),
  // ... other states
};

export const lineVariants = {
  initial: (i) => ({
    strokeDashoffset: defaultStrokeDashoffsets[i],
  }),
  hover: (i) => ({
    strokeDashoffset: i === 1 ? [1.05, 0] : [1.05, 0.4],
    transition: {
      delay: i === 1 ? 0 : 0.04,
      duration: DURATION,
      times: [0.7, 0.9],
    },
  }),
  // ... other states
};

This keeps the per-line differences organized and easy to modify.


id: hero-illustration/click-animation/a-reusable-pattern
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: A reusable pattern
excerpt: We can also create a hook that wraps useAnimate and extracts transition config:
url_path: /lessons/hero-illustration/click-animation/#a-reusable-pattern
markdown_path: /md/sections/hero-illustration/click-animation/a-reusable-pattern.md
body:
We can also create a hook that wraps useAnimate and extracts transition config:

// use-animate-variant.js

export function useAnimateVariant() {
  const [scope, animate] = useAnimate();

  const animateVariant = useCallback(
    (selector, variant) => {
      if (!variant) return;
      const { transition, ...values } = variant;
      return animate(
        selector,
        values,
        transition ?? {
          type: "spring",
          stiffness: 800,
          damping: 80,
          mass: 4,
        }
      );
    },
    [animate]
  );

  return [scope, animateVariant, animate];
}

// use-animate-variant.js

export function useAnimateVariant() {
  const [scope, animate] = useAnimate();

  const animateVariant = useCallback(
    (selector, variant) => {
      if (!variant) return;
      const { transition, ...values } = variant;
      return animate(
        selector,
        values,
        transition ?? {
          type: "spring",
          stiffness: 800,
          damping: 80,
          mass: 4,
        }
      );
    },
    [animate]
  );

  return [scope, animateVariant, animate];
}

This separates transition config from values and provides a fallback transition for any animations without explicit timing. The return values are [scope, animateVariant, animate], so we keep the same motion convention, but prioritize our use-case, making it simpler to reuse the logic in other components.

So we can now do this:

const [scope, animateVariant, animate] = useAnimateVariant();

const playAnimationState = (variant) => {
  // Simple usage
  animateVariant('[data-animate="background"]', backgroundVariants[variant]);

  // ...other animations
};

const [scope, animateVariant, animate] = useAnimateVariant();

const playAnimationState = (variant) => {
  // Simple usage
  animateVariant('[data-animate="background"]', backgroundVariants[variant]);

  // ...other animations
};

For our click animation, though, we need to coordinate multiple elements and handle path morphing. Let’s build an orchestrator for that.


id: hero-illustration/click-animation/building-the-orchestrator
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: Building the orchestrator
excerpt: Now let’s build an orchestrator that plays all element animations for a given state:
url_path: /lessons/hero-illustration/click-animation/#building-the-orchestrator
markdown_path: /md/sections/hero-illustration/click-animation/building-the-orchestrator.md
body:
Now let’s build an orchestrator that plays all element animations for a given state:

const [scope, animateVariant, animate] = useAnimateVariant();
const handPathAnimationRef = useRef(null);

const playAnimationState = async (variant, pathConfig) => {
  // Cleanup: stop any in-flight animations to prevent conflicts
  handPathAnimationRef.current?.stop();
  if (!pathConfig?.keyframes) {
    await animate(handPathProgress, 0);
  }

  // Collect all animations to coordinate with Promise.all
  const animations = [];

  // Animate background
  animations.push(
    animateVariant('[data-animate="background"]', backgroundVariants[variant])
  );

  // Animate hand position (skip for idle state)
  if (variant !== "idle") {
    animations.push(
      animateVariant('[data-animate="hand"]', handVariants[variant])
    );
  }

  // Animate all three lines with per-line variants
  animations.push(
    ...Array.from({ length: 3 }, (_, i) => {
      return animateVariant(
        `[data-animate="line"][data-index='${i}']`,
        lineVariants[variant](i)
      );
    })
  );

  // Handle path morphing separately (pathConfig for granular control)
  if (pathConfig) {
    const pathAnimation = animate(handPathProgress, pathConfig.keyframes, {
      duration: pathConfig.repeat ? IDLE_DURATION : DURATION,
      times: pathConfig.times,
      ease: easeOut,
      ...(pathConfig.repeat && {
        repeat: Infinity,
        repeatType: "loop",
        repeatDelay: REPEAT_DELAY,
        delay: REPEAT_DELAY,
      }),
    });
    handPathAnimationRef.current = pathAnimation;
  }

  // Return all animations so caller can await completion
  return Promise.all(animations);
};

const [scope, animateVariant, animate] = useAnimateVariant();
const handPathAnimationRef = useRef(null);

const playAnimationState = async (variant, pathConfig) => {
  // Cleanup: stop any in-flight animations to prevent conflicts
  handPathAnimationRef.current?.stop();
  if (!pathConfig?.keyframes) {
    await animate(handPathProgress, 0);
  }

  // Collect all animations to coordinate with Promise.all
  const animations = [];

  // Animate background
  animations.push(
    animateVariant('[data-animate="background"]', backgroundVariants[variant])
  );

  // Animate hand position (skip for idle state)
  if (variant !== "idle") {
    animations.push(
      animateVariant('[data-animate="hand"]', handVariants[variant])
    );
  }

  // Animate all three lines with per-line variants
  animations.push(
    ...Array.from({ length: 3 }, (_, i) => {
      return animateVariant(
        `[data-animate="line"][data-index='${i}']`,
        lineVariants[variant](i)
      );
    })
  );

  // Handle path morphing separately (pathConfig for granular control)
  if (pathConfig) {
    const pathAnimation = animate(handPathProgress, pathConfig.keyframes, {
      duration: pathConfig.repeat ? IDLE_DURATION : DURATION,
      times: pathConfig.times,
      ease: easeOut,
      ...(pathConfig.repeat && {
        repeat: Infinity,
        repeatType: "loop",
        repeatDelay: REPEAT_DELAY,
        delay: REPEAT_DELAY,
      }),
    });
    handPathAnimationRef.current = pathAnimation;
  }

  // Return all animations so caller can await completion
  return Promise.all(animations);
};

This function stops any in-flight animations to prevent conflicts, resets the hand path when needed, collects all element animations into an array, and handles path morphing timing. We return the animations array inside a Promise.all so we can await the animation completion if we need to.


id: hero-illustration/click-animation/the-payoff
lesson_id: hero-illustration/click-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: click-animation
lesson_title: Click animation
heading: The payoff
excerpt: Now our event handlers become much simpler and easier to read:
url_path: /lessons/hero-illustration/click-animation/#the-payoff
markdown_path: /md/sections/hero-illustration/click-animation/the-payoff.md
body:
Now our event handlers become much simpler and easier to read:

const startIdleAnimations = async () => {
  await playAnimationState("idle", {
    keyframes: [0, 1, 0],
    times: [0.1, 0.4, 0.8],
    repeat: true,
  });
};

const handleMouseEnter = async () => {
  await playAnimationState("hover", {
    keyframes: [0, 1, 0],
    times: [0.4, 0.6, 0.9],
  });
  hasAnimationCompletedRef.current = true;
};

const handleMouseLeave = async () => {
  hasAnimationCompletedRef.current = false;
  await playAnimationState("initial");
  startIdleAnimations();
};

const handleClick = () => {
  if (!hasAnimationCompletedRef.current) return;
  playAnimationState("click", {
    keyframes: [0, 1, 0],
    times: [0.1, 0.3, 0.6],
  });
};

const startIdleAnimations = async () => {
  await playAnimationState("idle", {
    keyframes: [0, 1, 0],
    times: [0.1, 0.4, 0.8],
    repeat: true,
  });
};

const handleMouseEnter = async () => {
  await playAnimationState("hover", {
    keyframes: [0, 1, 0],
    times: [0.4, 0.6, 0.9],
  });
  hasAnimationCompletedRef.current = true;
};

const handleMouseLeave = async () => {
  hasAnimationCompletedRef.current = false;
  await playAnimationState("initial");
  startIdleAnimations();
};

const handleClick = () => {
  if (!hasAnimationCompletedRef.current) return;
  playAnimationState("click", {
    keyframes: [0, 1, 0],
    times: [0.1, 0.3, 0.6],
  });
};

Just a single function call per interaction. The intent is crystal clear.

The benefits of this refactor:

All timing values live in one file - want to make hover snappier? Edit one variant object. Each animation state is self-contained, so you can see exactly what happens during "hover" without tracing through multiple functions. The orchestrator handles common patterns once instead of repeating them in every handler. And as animations grow more complex, this pattern scales much better.

"use client";

import {
motion,
useAnimate,
useMotionValue,
useTransform,
easeOut,
easeInOut,
} from "motion/react";
import { useEffect, useState, useRef } from "react";
import {
backgroundVariants,
DURATION,
handVariants,
IDLE_DURATION,
lineVariants,
defaultStrokeDashoffsets,
REPEAT_DELAY,
} from "./variants";
import { useAnimateVariant } from "./use-animate-variant";

const handPaths = [
"M58.5431 71.4419C60.2401 71.6239 61.3091 69.6649 60.2391 68.3369L49.2671 54.7189C48.6804 53.9554 48.4158 52.9927 48.5299 52.0366C48.6439 51.0805 49.1275 50.207 49.8773 49.6028C50.6271 48.9987 51.5835 48.712 52.542 48.804C53.5004 48.8959 54.3849 49.3593 55.0061 50.0949L58.3501 54.2449C58.8429 54.8569 59.5061 55.3089 60.2559 55.5437C61.0057 55.7785 61.8083 55.7855 62.5621 55.5639L70.6681 53.1809C72.8705 52.5335 75.23 52.6775 77.3372 53.5881C79.4444 54.4987 81.1663 56.1184 82.2041 58.1659L84.9681 63.6179C85.7915 65.2412 86.0321 67.098 85.6498 68.8775C85.2675 70.657 84.2855 72.2511 82.8681 73.3929L74.6611 80.0039C73.7139 80.7673 72.604 81.3028 71.417 81.5691C70.23 81.8355 68.9976 81.8255 67.8151 81.5399L55.1911 78.4919C54.2783 78.2707 53.4839 77.7106 52.9688 76.9252C52.4537 76.1399 52.2566 75.188 52.4174 74.2627C52.5781 73.3374 53.0848 72.5078 53.8346 71.9422C54.5844 71.3766 55.5212 71.1173 56.4551 71.2169L58.5431 71.4419Z",
"M58.7957 70.4997C60.4927 70.6817 61.3092 69.6653 60.2392 68.3373L51.8366 57.8368C51.2499 57.0733 50.867 55.9897 50.981 55.0336C51.0951 54.0776 51.5787 53.204 52.3285 52.5999C53.0783 51.9958 54.0347 51.7091 54.9931 51.801C55.9516 51.893 56.7918 52.4457 57.413 53.1813L58.3502 54.2453C58.8429 54.8573 59.5062 55.3093 60.256 55.5441C61.0058 55.7789 61.8084 55.7859 62.5622 55.5643L70.6681 53.1813C72.8705 52.5339 75.23 52.6779 77.3373 53.5885C79.4445 54.4991 81.1664 56.1187 82.2042 58.1663L84.9682 63.6183C85.7915 65.2416 86.0322 67.0984 85.6499 68.8778C85.2676 70.6573 84.2855 72.2515 82.8682 73.3933L74.6611 80.0043C73.714 80.7677 72.604 81.3032 71.417 81.5695C70.23 81.8358 68.9977 81.8258 67.8151 81.5403L55.5115 77.3318C54.5988 77.1105 53.8043 76.5504 53.2892 75.765C52.7742 74.9797 52.577 74.0279 52.7378 73.1025C52.8986 72.1772 53.4052 71.3476 54.155 70.782C54.9048 70.2165 55.8416 69.9572 56.7755 70.0567L58.7957 70.4997Z"
];

export default function ClickAnimation() {
const [scope, animateVariant, animate] = useAnimateVariant();
const handPathProgress = useMotionValue(0);
const handPath = useTransform(handPathProgress, [0, 1], handPaths);
const handPathAnimationRef = useRef(null);
const hasAnimationCompletedRef = useRef(false);

// Consolidated animation orchestration
const playAnimationState = async (
  // "initial" | "hover" | "idle" | "click"
  variant,
  // optional path morphing config: { keyframes: number[]; times: number[]; repeat?: boolean; }
  pathConfig
) => {
  // Stop any in-flight animations
  handPathAnimationRef.current?.stop();
  if (!pathConfig?.keyframes) {
    await animate(handPathProgress, 0);
  }

  const animations = [];

  // Background
  animations.push(
    animateVariant('[data-animate="background"]', backgroundVariants[variant])
  );

  // Hand (no idle variant)
  if (variant !== "idle") {
    animations.push(
      animateVariant('[data-animate="hand"]', handVariants[variant])
    );
  }

  // Lines (indexed)
  animations.push(
    ...Array.from({ length: 3 }, (_, i) => {
      return animateVariant(
        `[data-animate="line"][data-index='${i}']`,
        lineVariants[variant](i),
      );
    })
  );

  // Animate path morphing if config provided
  if (pathConfig) {
    const pathAnimation = animate(handPathProgress, pathConfig.keyframes, {
      duration: pathConfig.repeat ? IDLE_DURATION : DURATION,
      times: pathConfig.times,
      ease: easeOut,
      ...(pathConfig.repeat && {
        repeat: Infinity,
        repeatType: "loop",
        repeatDelay: REPEAT_DELAY,
        delay: REPEAT_DELAY,
      }),
    });
    handPathAnimationRef.current = pathAnimation;
  }

  return Promise.all(animations);
};

const startIdleAnimations = async () => {
  await playAnimationState("idle", {
    keyframes: [0, 1, 0],
    times: [0.1, 0.4, 0.8],
    repeat: true,
  });
};

useEffect(() => {
  startIdleAnimations();

  return () => {
    handPathAnimationRef.current?.stop();
  };
}, [startIdleAnimations]);

const handleMouseEnter = async () => {
  await playAnimationState("hover", {
    keyframes: [0, 1, 0],
    times: [0.4, 0.6, 0.9],
  });
  hasAnimationCompletedRef.current = true;
};

const handleMouseLeave = async () => {
  hasAnimationCompletedRef.current = false;
  await playAnimationState("initial");
  startIdleAnimations();
};

const handleClick = () => {
  if (!hasAnimationCompletedRef.current) return;
  playAnimationState("click", {
    keyframes: [0, 1, 0],
    times: [0.1, 0.3, 0.6],
  });
};

return (
  <svg
    width="125"
    height="119"
    viewBox="0 0 125 119"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    className="overflow-visible!"
  >
    <g
      ref={scope}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      onClick={handleClick}
    >
      <motion.g
        data-animate="background"
        initial={backgroundVariants.initial}
        filter="url(#filter0_i_269_329)"
      >
        <path
          d="M14.9066 10.1626C18.9956 19.8776 23.4146 30.4306 25.5696 35.7296C26.3866 37.7366 25.6336 39.9006 23.7896 41.0376L12.7336 47.8526C12.1283 48.2259 11.6112 48.7261 11.218 49.3187C10.8249 49.9113 10.5651 50.5822 10.4565 51.285C10.3479 51.9879 10.3932 52.7059 10.5891 53.3895C10.7851 54.0732 11.1272 54.7061 11.5916 55.2446L19.3536 64.2426C20.1213 65.1328 20.5445 66.2687 20.5464 67.4443C20.5482 68.6198 20.1286 69.757 19.3636 70.6496L1.34161 91.6766C-1.69439 95.2186 1.45161 100.601 6.02761 99.6936L31.5196 94.6386C32.7448 94.3961 34.0163 94.6291 35.0758 95.2904C36.1353 95.9516 36.9033 96.9915 37.2236 98.1986L41.1356 112.939C42.0306 116.315 46.0646 117.704 48.8496 115.596L60.7136 106.617C61.5752 105.964 62.628 105.614 63.7089 105.62C64.7897 105.627 65.8382 105.99 66.6916 106.654L81.3666 118.048C84.2466 120.285 88.4726 118.716 89.1956 115.143L92.5696 98.4746C92.7053 97.8046 92.9793 97.1702 93.3741 96.6121C93.7689 96.054 94.2759 95.5844 94.8625 95.2333C95.4491 94.8823 96.1026 94.6575 96.781 94.5733C97.4595 94.4892 98.148 94.5475 98.8026 94.7446L115.49 99.7726C119.957 101.119 123.61 96.0636 120.929 92.2446L106.344 71.4686C105.633 70.4557 105.331 69.2121 105.497 67.986C105.663 66.7599 106.286 65.6417 107.241 64.8546L123.32 51.6046C126.177 49.2496 125.503 44.7016 122.085 43.2766L109.166 37.8936C108.082 37.4416 107.195 36.6163 106.667 35.5672C106.139 34.5182 106.004 33.3146 106.287 32.1746L111.616 10.7026C112.746 6.15163 107.466 2.75564 103.793 5.67064L84.2026 21.2176C81.6436 23.2486 77.8526 22.2626 76.6066 19.2426L70.0536 3.36063C68.5736 -0.224368 63.7166 -0.762367 61.4886 2.41363L51.8826 16.1066C51.1775 17.1118 50.1215 17.8164 48.9227 18.0819C47.7239 18.3473 46.4692 18.1542 45.4056 17.5406L21.8996 3.98863C17.8176 1.63563 13.0796 5.82063 14.9066 10.1626Z"
          fill="#F8F8F8"
        />
      </motion.g>

      <motion.path
        d={handPath}
        data-animate="hand"
        initial={handVariants.initial}
        fill="#989898"
      />

      <motion.line
        data-animate="line"
        data-index="2"
        initial={lineVariants.initial(2)}
        x1="62.8541"
        y1="39.4586"
        x2="64.5595"
        y2="34.929"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
      <motion.line
        data-animate="line"
        data-index="1"
        initial={lineVariants.initial(1)}
        x1="53.0633"
        y1="38.313"
        x2="52.2947"
        y2="33.5344"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
      <motion.line
        data-animate="line"
        data-index="0"
        initial={lineVariants.initial(0)}
        x1="44.047"
        y1="42.3621"
        x2="41.0059"
        y2="38.5917"
        strokeOpacity="0.5"
        strokeWidth="3.7"
        strokeLinecap="round"
        strokeDasharray="1px 1.1px"
        pathLength="1"
        stroke="#989898"
      />
    </g>

    <defs>
      <filter
        id="filter0_i_269_329"
        x="0"
        y="0"
        width="124.969"
        height="118.764"
        filterUnits="userSpaceOnUse"
        colorInterpolationFilters="sRGB"
      >
        <feFlood floodOpacity="0" result="BackgroundImageFix" />
        <feBlend
          mode="normal"
          in="SourceGraphic"
          in2="BackgroundImageFix"
          result="shape"
        />
        <feColorMatrix
          in="SourceAlpha"
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
          result="hardAlpha"
        />
        <feOffset />
        <feGaussianBlur stdDeviation="6.551" />
        <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
        <feColorMatrix
          type="matrix"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"
        />
        <feBlend mode="normal" in2="shape" result="effect1_innerShadow_269_329" />
      </filter>
    </defs>
  </svg>
);
}

We’ve covered a lot - coordinated timing, path morphing, stroke animations, and organizing it all with variants. The patterns you’ve learned here will serve you well as you build more complex animations.

Try experimenting with the timing values or adding your own touches. Small adjustments can completely change the feel of an animation.

In the next lesson, we’ll apply these techniques to build the clock animation with an interesting easter egg. You’ll see how the same coordination patterns work for different types of motion.


id: hero-illustration/clock-animation/overview
lesson_id: hero-illustration/clock-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: clock-animation
lesson_title: Clock animation
heading: Overview
url_path: /lessons/hero-illustration/clock-animation/#overview
markdown_path: /md/sections/hero-illustration/clock-animation/overview.md
excerpt:
This clock has a fun easter egg - click it and the hands will spin to show you the actual time. We’ll build this animation step by step: bells swinging sideways in idle state, alarm going off on hover, current time animation on click and feedback on subsequent clicks.

body:
This clock has a fun easter egg - click it and the hands will spin to show you the actual time. We’ll build this animation step by step: bells swinging sideways in idle state, alarm going off on hover, current time animation on click and feedback on subsequent clicks.

Let’s start with the SVG structure.


id: hero-illustration/clock-animation/the-svg-structure
lesson_id: hero-illustration/clock-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: clock-animation
lesson_title: Clock animation
heading: The SVG structure
excerpt: The SVG has three main elements:
url_path: /lessons/hero-illustration/clock-animation/#the-svg-structure
markdown_path: /md/sections/hero-illustration/clock-animation/the-svg-structure.md
body:
The SVG has three main elements:

Background: This will scale and rotate based on state. Clock: Containing the clock face and the hour and minute hands. Bells: Two bells on top of the clock.

<svg width="155" height="143" viewBox="0 0 155 143">
  <g ref={scope}>
    <g data-animate="background">
      <path d="..." />
    </g>

    <g data-animate="clock-and-bells">
      <g data-animate="clock">
        <circle ... /> {/* clock face */}
        <line data-animate="minute-hand" ... />
        <line data-animate="hour-hand" ... />
      </g>

      <g data-animate="bells">
        <path data-animate="bell" data-index="0" d="..." />
        <path data-animate="bell" data-index="1" d="..." />
      </g>
    </g>
  </g>
</svg>

<svg width="155" height="143" viewBox="0 0 155 143">
  <g ref={scope}>
    <g data-animate="background">
      <path d="..." />
    </g>

    <g data-animate="clock-and-bells">
      <g data-animate="clock">
        <circle ... /> {/* clock face */}
        <line data-animate="minute-hand" ... />
        <line data-animate="hour-hand" ... />
      </g>

      <g data-animate="bells">
        <path data-animate="bell" data-index="0" d="..." />
        <path data-animate="bell" data-index="1" d="..." />
      </g>
    </g>
  </g>
</svg>

The bells are grouped with the clock inside clock-and-bells so we can rotate and scale them together during certain animations while still animating them independently during others. Both bells can also be animated individually using their data-index attributes.


id: hero-illustration/clock-animation/idle-the-bells-swing
lesson_id: hero-illustration/clock-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: clock-animation
lesson_title: Clock animation
heading: Idle: The bells swing
url_path: /lessons/hero-illustration/clock-animation/#idle-the-bells-swing
markdown_path: /md/sections/hero-illustration/clock-animation/idle-the-bells-swing.md
excerpt:
Let’s start by creating the idle animation for the bells. We want to swing the bells group from side to side. If you try to do that with transformBox: fill-box it becomes tricky - rotating the bells around their own center just tilts them, so you’d need to add translateX and translateY to balance the movement.

body:
Let’s start by creating the idle animation for the bells. We want to swing the bells group from side to side. If you try to do that with transformBox: fill-box it becomes tricky - rotating the bells around their own center just tilts them, so you’d need to add translateX and translateY to balance the movement.

A simpler solution is to rotate the bells around the clock center point. This way we can just use rotate without complicating the transform:

<motion.g
  data-animate="bells"
  initial={{
    y: 0,
    rotate: 0,
    transformOrigin: "76.3174px 69.5132px", // Clock center coordinates
  }}
  style={{ transformBox: "view-box" }} // Use viewBox-relative coordinates
>
  {/* bell elements */}
</motion.g>

<motion.g
  data-animate="bells"
  initial={{
    y: 0,
    rotate: 0,
    transformOrigin: "76.3174px 69.5132px", // Clock center coordinates
  }}
  style={{ transformBox: "view-box" }} // Use viewBox-relative coordinates
>
  {/* bell elements */}
</motion.g>

We use explicit pixel coordinates for transformOrigin. The transformBox: "view-box" style makes these coordinates relative to the SVG’s viewBox.

Notice we set transformOrigin in initial rather than style. This is important because when you set it in style, Motion will override it with its default "50% 50%" origin for SVG elements.

Now the swing animation:

const bellsVariants = {
  initial: {
    rotate: 0,
    transformOrigin: CLOCK_CENTER,
  },
  idle: {
    rotate: [0, -10, 10, 0],
    transition: {
      duration: 1,
      ease: easeInOut,
      repeat: Infinity,
      repeatType: "loop",
      repeatDelay: 2,
      delay: 2,
    },
  },
};

const bellsVariants = {
  initial: {
    rotate: 0,
    transformOrigin: CLOCK_CENTER,
  },
  idle: {
    rotate: [0, -10, 10, 0],
    transition: {
      duration: 1,
      ease: easeInOut,
      repeat: Infinity,
      repeatType: "loop",
      repeatDelay: 2,
      delay: 2,
    },
  },
};

The bells rotate left, then right, then back to center, repeating every 2 seconds. We can use animateVariant directly to start this animation on mount since it’s the only idle animation in this component:

const [scope, animateVariant] = useAnimateVariant();

useEffect(() => {
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
}, []);

const [scope, animateVariant] = useAnimateVariant();

useEffect(() => {
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
}, []);

"use client";

import { useRef, useEffect } from "react";
import {
motion,
useAnimate,
useMotionValue,
useTransform,
easeOut,
easeInOut,
} from "motion/react";
import { useAnimateVariant } from "./use-animate-variant";
import { bellsVariants } from "./clock-variants";

const INITIAL_HOUR_ROTATION = 120;

export default function ClockAnimation() {
const [scope, animateVariant, animate] = useAnimateVariant();

useEffect(() => {
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
}, [animateVariant]);

return (
  <svg
    width="155"
    height="143"
    viewBox="0 0 155 143"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    className="overflow-visible!"
  >
    <g ref={scope}>
      <g
        data-animate="background"
        filter="url(#filter7_clock_359_1453)"
      >
        <path
          d="M86.0337 0.868621C128.43 6.67762 158.875 40.0266 154.034 75.3566C152.498 86.5686 147.592 96.6336 140.254 104.964C134.113 111.937 132.037 122.368 137.526 129.865L139.354 132.361C139.858 133.052 140.226 133.835 140.436 134.665C141.863 140.348 135.764 144.958 130.686 142.033L109.869 130.044C107.05 128.419 103.811 127.672 100.565 127.897L98.1097 128.067C88.7577 129.878 78.7507 130.213 68.5047 128.809C26.1097 123 -4.33427 89.6506 0.505733 54.3216C5.34673 18.9916 43.6387 -4.93938 86.0337 0.868621Z"
          fill="#F8F8F8"
        />
      </g>

      <g data-animate="clock-and-bells">
        <g data-animate="clock">
          <circle
            cx="76.6995"
            cy="69.0189"
            r="22.93"
            fill="#989898"
          />
          <line
            data-animate="minute-hand"
            x1="76.3175"
            y1="69.5132"
            x2="78.0647"
            y2="58.2433"
            strokeWidth="4.9"
            strokeLinecap="round"
            stroke="#F8F8F8"
          />
          <line
            data-animate="hour-hand"
            x1="76.3174"
            y1="69.5132"
            x2="77.5886"
            y2="61.3112"
            strokeWidth="4.9"
            strokeLinecap="round"
            stroke="#F8F8F8"
            style={{
              transformBox: "fill-box",
              transformOrigin: "0% 100%",
              transform: `rotate(${INITIAL_HOUR_ROTATION}deg)`,
            }}
          />
        </g>

        <motion.g
          data-animate="bells"
          initial={bellsVariants.initial}
          style={{ transformBox: "view-box" }}
        >
          <path
            data-animate="bell"
            data-index="0"
            d="M85.8858 33.91C86.5606 34.0022 87.1988 34.2719 87.7353 34.6915C88.2717 35.1111 88.6872 35.6655 88.9393 36.2982C89.1914 36.9309 89.271 37.6192 89.1701 38.2927C89.0691 38.9663 88.7913 39.6009 88.3648 40.132L86.3708 42.614C85.9358 43.155 85.2088 43.358 84.5448 43.158C81.8043 42.3293 78.9503 41.9381 76.0879 41.999C75.7485 42.0101 75.4136 41.9194 75.1263 41.7385C74.839 41.5576 74.6125 41.2948 74.4759 40.984L73.2248 38.058C72.9498 37.417 72.8549 36.712 72.9499 36.021C73.0184 35.5204 73.185 35.0383 73.4399 34.6021C73.6948 34.1659 74.0332 33.7842 74.4356 33.4788C74.8381 33.1734 75.2967 32.9502 75.7854 32.8221C76.2741 32.694 76.7833 32.6633 77.2838 32.732L85.8858 33.91Z"
            opacity="0.4"
            fill="#989898"
          />
          <path
            data-animate="bell"
            data-index="1"
            d="M102.985 49.4718C103.398 50.0153 103.656 50.6608 103.731 51.3395C103.806 52.0182 103.695 52.7045 103.411 53.3251C103.126 53.9458 102.678 54.4775 102.115 54.8635C101.552 55.2495 100.894 55.4753 100.213 55.5167C100.183 55.5184 100.154 55.5119 100.128 55.4979C100.102 55.484 100.081 55.4632 100.066 55.4378C99.5088 54.4819 98.8937 53.5611 98.2237 52.6807C97.556 51.7986 96.8346 50.9584 96.0637 50.1647C96.0437 50.1437 96.0297 50.1176 96.0234 50.0892C96.017 50.0609 96.0186 50.0313 96.0278 50.0037C96.2511 49.3587 96.6454 48.7865 97.1685 48.348C97.6915 47.9094 98.3238 47.6211 98.9979 47.5137C99.672 47.4064 100.363 47.484 100.996 47.7383C101.629 47.9926 102.182 48.4141 102.595 48.9577L102.985 49.4718Z"
            opacity="0.45"
            fill="#989898"
          />
        </motion.g>
      </g>
    </g>

    <defs>
      <filter
        id="filter7_clock_359_1453"
        x="0"
        y="0"
        width="154.539"
        height="142.938"
        colorInterpolationFilters="sRGB"
        filterUnits="userSpaceOnUse"
      >
        <feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
        <feBlend
          in="SourceGraphic"
          in2="BackgroundImageFix"
          result="shape"
        ></feBlend>
        <feColorMatrix
          in="SourceAlpha"
          result="hardAlpha"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
        ></feColorMatrix>
        <feOffset></feOffset>
        <feGaussianBlur stdDeviation="6.551"></feGaussianBlur>
        <feComposite
          in2="hardAlpha"
          k2="-1"
          k3="1"
          operator="arithmetic"
        ></feComposite>
        <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"></feColorMatrix>
        <feBlend
          in2="shape"
          result="effect1_innerShadow_359_1453"
        ></feBlend>
      </filter>
    </defs>
  </svg>
);
}


id: hero-illustration/clock-animation/hover-the-alarm-goes-off
lesson_id: hero-illustration/clock-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: clock-animation
lesson_title: Clock animation
heading: Hover: The alarm goes off
url_path: /lessons/hero-illustration/clock-animation/#hover-the-alarm-goes-off
markdown_path: /md/sections/hero-illustration/clock-animation/hover-the-alarm-goes-off.md
excerpt:
When you hover, the background tilts and compresses, the clock shakes horizontally, the bells group moves up, and each individual bell shakes independently.

body:
When you hover, the background tilts and compresses, the clock shakes horizontally, the bells group moves up, and each individual bell shakes independently.

The background uses a simple rotate and scale sequence:

const backgroundVariants = {
  initial: {
    transform: "rotate(0deg) scale(1)",
  },
  hover: {
    transform: [
      "rotate(0deg) scale(1)",
      "rotate(-4deg) scale(0.99)",
      "rotate(-3deg) scale(1)",
    ],
    transition: {
      duration: 0.3,
      ease: easeOut,
    },
  },
};

const backgroundVariants = {
  initial: {
    transform: "rotate(0deg) scale(1)",
  },
  hover: {
    transform: [
      "rotate(0deg) scale(1)",
      "rotate(-4deg) scale(0.99)",
      "rotate(-3deg) scale(1)",
    ],
    transition: {
      duration: 0.3,
      ease: easeOut,
    },
  },
};

The clock-and-bells group translates up to create a jump effect:

const clockAndBellsVariants = {
  initial: {
    transform: "translateY(0px) rotate(0deg) scale(1)",
  },
  hover: {
    transform: [
      "translateY(0px) rotate(0deg) scale(1)",
      "translateY(-3px) rotate(0deg) scale(1)",
    ],
    transition: {
      duration: 0.3,
      ease: easeOut,
    },
  },
};

const clockAndBellsVariants = {
  initial: {
    transform: "translateY(0px) rotate(0deg) scale(1)",
  },
  hover: {
    transform: [
      "translateY(0px) rotate(0deg) scale(1)",
      "translateY(-3px) rotate(0deg) scale(1)",
    ],
    transition: {
      duration: 0.3,
      ease: easeOut,
    },
  },
};

The clock face (including the clock hands) shakes horizontally with a continuous x-axis oscillation:

const clockVariants = {
  initial: { x: "0px" },
  hover: {
    x: ["0px", "-1.5px", "1.75px", "-1.75px", "1.75px", "-1.5px", "0px"],
    transition: {
      x: {
        duration: 0.25,
        repeat: Infinity,
        ease: linear,
      },
    },
  },
};

const clockVariants = {
  initial: { x: "0px" },
  hover: {
    x: ["0px", "-1.5px", "1.75px", "-1.75px", "1.75px", "-1.5px", "0px"],
    transition: {
      x: {
        duration: 0.25,
        repeat: Infinity,
        ease: linear,
      },
    },
  },
};

The bells group animates up slowly over 3 seconds:

const bellsVariants = {
  // ...initial and idle
  hover: {
    y: [0, -8],
    rotate: 0,
    transition: {
      y: {
        duration: 3,
        ease: easeOut,
      },
    },
  },
};

const bellsVariants = {
  // ...initial and idle
  hover: {
    y: [0, -8],
    rotate: 0,
    transition: {
      y: {
        duration: 3,
        ease: easeOut,
      },
    },
  },
};

Each bell shakes independently. The second bell uses a smaller oscillation since it’s smaller, and rotates as the bells group moves up to stay facing the clock:

const bellVariants = {
  initial: {
    x: "0px",
    rotate: "0deg",
  },
  hover: (i: number) => ({
    x:
      i === 0
        ? ["0px", "-2px", "2px", "-2px", "2px", "-2px", "0%"]
        : ["0px", "1.5px", "-1.5px", "1.5px", "-1.5px", "1.5px", "0%"],
    rotate: i === 0 ? "0deg" : ["0deg", "-8deg"],
    transition: {
      x: {
        duration: 0.25,
        repeat: Infinity,
        ease: linear,
      },
      rotate: {
        duration: 3,
        ease: easeOut,
      },
    },
  }),
};

const bellVariants = {
  initial: {
    x: "0px",
    rotate: "0deg",
  },
  hover: (i: number) => ({
    x:
      i === 0
        ? ["0px", "-2px", "2px", "-2px", "2px", "-2px", "0%"]
        : ["0px", "1.5px", "-1.5px", "1.5px", "-1.5px", "1.5px", "0%"],
    rotate: i === 0 ? "0deg" : ["0deg", "-8deg"],
    transition: {
      x: {
        duration: 0.25,
        repeat: Infinity,
        ease: linear,
      },
      rotate: {
        duration: 3,
        ease: easeOut,
      },
    },
  }),
};

To animate these, we need an orchestrator that can target specific elements and coordinate all the animations:

const animateClockVariant = (variant) => {
  const animations = [
    animateVariant("[data-animate='clock']", clockVariants[variant]),
    animateVariant(
      "[data-animate='clock-and-bells']",
      clockAndBellsVariants[variant]
    ),
    animateVariant('[data-animate="bells"]', bellsVariants[variant]),
    ...Array.from({ length: 2 }, (_, i) =>
      animateVariant(
        `[data-animate="bell"][data-index='${i}']`,
        typeof bellVariants[variant] === "function"
          ? bellVariants[variant](i)
          : bellVariants[variant]
      )
    ),
    animateVariant('[data-animate="background"]', backgroundVariants[variant]),
  ].filter(Boolean);

  return Promise.all(animations);
};

const handleMouseEnter = () => {
  animateClockVariant("hover");
};

const handleMouseLeave = () => {
  animateClockVariant("initial");
};

const animateClockVariant = (variant) => {
  const animations = [
    animateVariant("[data-animate='clock']", clockVariants[variant]),
    animateVariant(
      "[data-animate='clock-and-bells']",
      clockAndBellsVariants[variant]
    ),
    animateVariant('[data-animate="bells"]', bellsVariants[variant]),
    ...Array.from({ length: 2 }, (_, i) =>
      animateVariant(
        `[data-animate="bell"][data-index='${i}']`,
        typeof bellVariants[variant] === "function"
          ? bellVariants[variant](i)
          : bellVariants[variant]
      )
    ),
    animateVariant('[data-animate="background"]', backgroundVariants[variant]),
  ].filter(Boolean);

  return Promise.all(animations);
};

const handleMouseEnter = () => {
  animateClockVariant("hover");
};

const handleMouseLeave = () => {
  animateClockVariant("initial");
};

"use client";


import { useRef, useEffect } from "react";
import {
motion,
useAnimate,
useMotionValue,
useTransform,
easeOut,
easeInOut,
} from "motion/react";
import { useAnimateVariant } from "./use-animate-variant";
import { bellsVariants, backgroundVariants, clockVariants, clockAndBellsVariants, bellVariants } from "./clock-variants";

const INITIAL_HOUR_ROTATION = 120;

export default function ClockAnimation() {
const [scope, animateVariant, animate] = useAnimateVariant();

useEffect(() => {
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
}, [animateVariant]);

const animateClockVariant = (variant) => {
  const animations = [
    animateVariant("[data-animate='clock']", clockVariants[variant]),
    animateVariant("[data-animate='clock-and-bells']", clockAndBellsVariants[variant]),
    animateVariant('[data-animate="bells"]', bellsVariants[variant]),
    ...Array.from({ length: 2 }, (_, i) =>
      animateVariant(
        `[data-animate="bell"][data-index='${i}']`,
        typeof bellVariants[variant] === "function"
            ? bellVariants[variant](i)
            : bellVariants[variant]
      )
    ),
    animateVariant('[data-animate="background"]', backgroundVariants[variant]),
  ].filter(Boolean);

  return Promise.all(animations);
}

const handleMouseEnter = () => {
  animateClockVariant("hover");
}

const handleMouseLeave = () => {
  animateClockVariant("initial");
}

return (
  <svg
    width="155"
    height="143"
    viewBox="0 0 155 143"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    className="overflow-visible!"
  >
    <g
      ref={scope}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      <g
        data-animate="background"
        filter="url(#filter7_clock_359_1453)"
      >
        <path
          d="M86.0337 0.868621C128.43 6.67762 158.875 40.0266 154.034 75.3566C152.498 86.5686 147.592 96.6336 140.254 104.964C134.113 111.937 132.037 122.368 137.526 129.865L139.354 132.361C139.858 133.052 140.226 133.835 140.436 134.665C141.863 140.348 135.764 144.958 130.686 142.033L109.869 130.044C107.05 128.419 103.811 127.672 100.565 127.897L98.1097 128.067C88.7577 129.878 78.7507 130.213 68.5047 128.809C26.1097 123 -4.33427 89.6506 0.505733 54.3216C5.34673 18.9916 43.6387 -4.93938 86.0337 0.868621Z"
          fill="#F8F8F8"
        />
      </g>

      <g data-animate="clock-and-bells">
        <g data-animate="clock">
          <circle
            cx="76.6995"
            cy="69.0189"
            r="22.93"
            fill="#989898"
          />
          <line
            data-animate="minute-hand"
            x1="76.3175"
            y1="69.5132"
            x2="78.0647"
            y2="58.2433"
            strokeWidth="4.9"
            strokeLinecap="round"
            stroke="#F8F8F8"
          />
          <line
            data-animate="hour-hand"
            x1="76.3174"
            y1="69.5132"
            x2="77.5886"
            y2="61.3112"
            strokeWidth="4.9"
            strokeLinecap="round"
            stroke="#F8F8F8"
            style={{
              transformBox: "fill-box",
              transformOrigin: "0% 100%",
              transform: `rotate(${INITIAL_HOUR_ROTATION}deg)`,
            }}
          />
        </g>

        <motion.g
          data-animate="bells"
          initial={bellsVariants.initial}
          style={{ transformBox: "view-box" }}
        >
          <path
            data-animate="bell"
            data-index="0"
            d="M85.8858 33.91C86.5606 34.0022 87.1988 34.2719 87.7353 34.6915C88.2717 35.1111 88.6872 35.6655 88.9393 36.2982C89.1914 36.9309 89.271 37.6192 89.1701 38.2927C89.0691 38.9663 88.7913 39.6009 88.3648 40.132L86.3708 42.614C85.9358 43.155 85.2088 43.358 84.5448 43.158C81.8043 42.3293 78.9503 41.9381 76.0879 41.999C75.7485 42.0101 75.4136 41.9194 75.1263 41.7385C74.839 41.5576 74.6125 41.2948 74.4759 40.984L73.2248 38.058C72.9498 37.417 72.8549 36.712 72.9499 36.021C73.0184 35.5204 73.185 35.0383 73.4399 34.6021C73.6948 34.1659 74.0332 33.7842 74.4356 33.4788C74.8381 33.1734 75.2967 32.9502 75.7854 32.8221C76.2741 32.694 76.7833 32.6633 77.2838 32.732L85.8858 33.91Z"
            opacity="0.4"
            fill="#989898"
          />
          <path
            data-animate="bell"
            data-index="1"
            d="M102.985 49.4718C103.398 50.0153 103.656 50.6608 103.731 51.3395C103.806 52.0182 103.695 52.7045 103.411 53.3251C103.126 53.9458 102.678 54.4775 102.115 54.8635C101.552 55.2495 100.894 55.4753 100.213 55.5167C100.183 55.5184 100.154 55.5119 100.128 55.4979C100.102 55.484 100.081 55.4632 100.066 55.4378C99.5088 54.4819 98.8937 53.5611 98.2237 52.6807C97.556 51.7986 96.8346 50.9584 96.0637 50.1647C96.0437 50.1437 96.0297 50.1176 96.0234 50.0892C96.017 50.0609 96.0186 50.0313 96.0278 50.0037C96.2511 49.3587 96.6454 48.7865 97.1685 48.348C97.6915 47.9094 98.3238 47.6211 98.9979 47.5137C99.672 47.4064 100.363 47.484 100.996 47.7383C101.629 47.9926 102.182 48.4141 102.595 48.9577L102.985 49.4718Z"
            opacity="0.45"
            fill="#989898"
          />
        </motion.g>
      </g>
    </g>

    <defs>
      <filter
        id="filter7_clock_359_1453"
        x="0"
        y="0"
        width="154.539"
        height="142.938"
        colorInterpolationFilters="sRGB"
        filterUnits="userSpaceOnUse"
      >
        <feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
        <feBlend
          in="SourceGraphic"
          in2="BackgroundImageFix"
          result="shape"
        ></feBlend>
        <feColorMatrix
          in="SourceAlpha"
          result="hardAlpha"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
        ></feColorMatrix>
        <feOffset></feOffset>
        <feGaussianBlur stdDeviation="6.551"></feGaussianBlur>
        <feComposite
          in2="hardAlpha"
          k2="-1"
          k3="1"
          operator="arithmetic"
        ></feComposite>
        <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"></feColorMatrix>
        <feBlend
          in2="shape"
          result="effect1_innerShadow_359_1453"
        ></feBlend>
      </filter>
    </defs>
  </svg>
);
}


id: hero-illustration/clock-animation/click-show-the-time
lesson_id: hero-illustration/clock-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: clock-animation
lesson_title: Clock animation
heading: Click: Show the time
excerpt: On the first click, the bells stop ringing and the clock hands spin to show the current time.
url_path: /lessons/hero-illustration/clock-animation/#click-show-the-time
markdown_path: /md/sections/hero-illustration/clock-animation/click-show-the-time.md
body:
On the first click, the bells stop ringing and the clock hands spin to show the current time.

The clock hands start pointing at 12:00 in their SVG coordinates. We rotate the hour hand to 120 degrees for the default display position.

After clicking, the clock hands need to rotate around their base (the clock center), similar to how the bells rotate around the clock center, but here we use transformBox: "fill-box" since they are already positioned in the center of the clock face.

We set this up with transform origins using Motion’s initial prop:

<motion.line
  data-animate="hour-hand"
  initial={{
    transformOrigin: "0% 100%",
    transform: `rotate(${INITIAL_HOUR_ROTATION}deg)`,
  }}
  x1="76.3174"
  y1="69.5132"
  x2="77.5886"
  y2="61.3112"
  strokeWidth="4.9"
  strokeLinecap="round"
  stroke="#F8F8F8"
/>

<motion.line
  data-animate="hour-hand"
  initial={{
    transformOrigin: "0% 100%",
    transform: `rotate(${INITIAL_HOUR_ROTATION}deg)`,
  }}
  x1="76.3174"
  y1="69.5132"
  x2="77.5886"
  y2="61.3112"
  strokeWidth="4.9"
  strokeLinecap="round"
  stroke="#F8F8F8"
/>

The transformOrigin: "0% 100%" sets the rotation point to the bottom-left corner of the line’s bounding box, which is where the hand connects to the clock center. Motion automatically applies transformBox: "fill-box" to make those percentages relative to the element’s shape.

Now we can calculate where the hands should point:

const now = new Date();
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
const seconds = now.getSeconds();

// 30 degrees per hour (360° / 12 hours)
const hourRotation = hours * 30 + minutes * 0.5;

// 6 degrees per minute (360° / 60 minutes)
const minuteRotation = minutes * 6 + seconds * 0.1;

const now = new Date();
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
const seconds = now.getSeconds();

// 30 degrees per hour (360° / 12 hours)
const hourRotation = hours * 30 + minutes * 0.5;

// 6 degrees per minute (360° / 60 minutes)
const minuteRotation = minutes * 6 + seconds * 0.1;

The clock face has 360 degrees. Divide by 12 hours gives us 30 degrees per hour. Same logic for minutes.

To make the hands spin before landing, we add full rotations:

const [scope, animateVariant, animate] = useAnimateVariant();

const SPRING_CONFIG = {
  type: "spring",
  stiffness: 250,
  damping: 25,
  mass: 1.2,
};

const animateClockHands = (hourRotation, minuteRotation) => {
  animate(
    "[data-animate='hour-hand']",
    { transform: `rotate(${hourRotation}deg)` },
    SPRING_CONFIG
  );
  animate(
    "[data-animate='minute-hand']",
    { transform: `rotate(${minuteRotation}deg)` },
    SPRING_CONFIG
  );
};

const hourSpins = 1;
const minuteSpins = 2;

animateClockHands(
  360 * hourSpins + hourRotation,
  360 * minuteSpins + minuteRotation
);

const [scope, animateVariant, animate] = useAnimateVariant();

const SPRING_CONFIG = {
  type: "spring",
  stiffness: 250,
  damping: 25,
  mass: 1.2,
};

const animateClockHands = (hourRotation, minuteRotation) => {
  animate(
    "[data-animate='hour-hand']",
    { transform: `rotate(${hourRotation}deg)` },
    SPRING_CONFIG
  );
  animate(
    "[data-animate='minute-hand']",
    { transform: `rotate(${minuteRotation}deg)` },
    SPRING_CONFIG
  );
};

const hourSpins = 1;
const minuteSpins = 2;

animateClockHands(
  360 * hourSpins + hourRotation,
  360 * minuteSpins + minuteRotation
);

The hour hand does one full spin, the minute hand does two. Spring transitions create a natural, bouncy spin effect.

While the hands spin, the clock-and-bells group animates into an upright position to create a settling effect:

const clockAndBellsVariants = {
  // ...initial and hover
  click: {
    transform: [
      "translateY(0px) rotate(0deg) scale(1)",
      "translateY(0px) rotate(-10deg) scale(0.95)",
      "translateY(0px) rotate(-8deg) scale(1.03)",
      "translateY(0px) rotate(-8deg) scale(1)",
    ],
    transition: {
      duration: 0.4,
      times: [0, 0.25, 0.6, 1],
      ease: easeOut,
    },
  },
};

const clockAndBellsVariants = {
  // ...initial and hover
  click: {
    transform: [
      "translateY(0px) rotate(0deg) scale(1)",
      "translateY(0px) rotate(-10deg) scale(0.95)",
      "translateY(0px) rotate(-8deg) scale(1.03)",
      "translateY(0px) rotate(-8deg) scale(1)",
    ],
    transition: {
      duration: 0.4,
      times: [0, 0.25, 0.6, 1],
      ease: easeOut,
    },
  },
};

The click handler brings everything together:

const handleClick = () => {
  animateClockVariant("click");

  const now = new Date();
  const hours = now.getHours() % 12;
  const minutes = now.getMinutes();
  const seconds = now.getSeconds();

  const newHourRotation = hours * 30 + minutes * 0.5;
  const newMinuteRotation = minutes * 6 + seconds * 0.1;

  const hourWithSpins = 360 * 1 + newHourRotation;
  const minuteWithSpins = 360 * 2 + newMinuteRotation;

  animateClockHands(hourWithSpins, minuteWithSpins);
};

const handleClick = () => {
  animateClockVariant("click");

  const now = new Date();
  const hours = now.getHours() % 12;
  const minutes = now.getMinutes();
  const seconds = now.getSeconds();

  const newHourRotation = hours * 30 + minutes * 0.5;
  const newMinuteRotation = minutes * 6 + seconds * 0.1;

  const hourWithSpins = 360 * 1 + newHourRotation;
  const minuteWithSpins = 360 * 2 + newMinuteRotation;

  animateClockHands(hourWithSpins, minuteWithSpins);
};

On mouse leave, we reset everything back to the initial state and restart the idle animation:

const handleMouseLeave = async () => {
  animateClockHands(INITIAL_HOUR_ROTATION, 0);
  await animateClockVariant("initial");
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
};

const handleMouseLeave = async () => {
  animateClockHands(INITIAL_HOUR_ROTATION, 0);
  await animateClockVariant("initial");
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
};

"use client";

import { useRef, useEffect } from "react";
import {
motion,
useAnimate,
useMotionValue,
useTransform,
easeOut,
easeInOut,
} from "motion/react";
import { useAnimateVariant } from "./use-animate-variant";
import { bellsVariants, backgroundVariants, clockVariants, clockAndBellsVariants, bellVariants } from "./clock-variants";

const INITIAL_HOUR_ROTATION = 120;
const SPRING_CONFIG = {
type: "spring",
stiffness: 250,
damping: 25,
mass: 1.2,
}

export default function ClockAnimation() {
const [scope, animateVariant, animate] = useAnimateVariant();

useEffect(() => {
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
}, [animateVariant]);

const animateClockVariant = (variant) => {
  const animations = [
    animateVariant("[data-animate='clock']", clockVariants[variant] ?? clockVariants.initial),
    animateVariant("[data-animate='clock-and-bells']", clockAndBellsVariants[variant] ?? clockAndBellsVariants.initial),
    animateVariant('[data-animate="bells"]', bellsVariants[variant] ?? bellsVariants.initial),
    ...Array.from({ length: 2 }, (_, i) =>
      animateVariant(
        `[data-animate="bell"][data-index='${i}']`,
        typeof bellVariants[variant] === "function"
            ? bellVariants[variant](i)
            : bellVariants[variant] ?? bellVariants.initial
      )
    ),
    animateVariant('[data-animate="background"]', backgroundVariants[variant] ?? backgroundVariants.initial),
  ].filter(Boolean);

  return Promise.all(animations);
}

const animateClockHands = (hourRotation, minuteRotation) => {
  animate(
    "[data-animate='hour-hand']",
    {
      transform: `rotate(${hourRotation}deg)`,
    },
    SPRING_CONFIG
  );
  animate(
    "[data-animate='minute-hand']",
    {
      transform: `rotate(${minuteRotation}deg)`,
    },
    SPRING_CONFIG
  );
}

const handleMouseEnter = () => {
  animateClockVariant("hover");
}

const handleMouseLeave = async () => {
  animateClockHands(INITIAL_HOUR_ROTATION, 0);
  await animateClockVariant("initial");
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
}

const handleClick = () => {
  animateVariant("[data-animate='background']", backgroundVariants.click);
  animateClockVariant("click");

  const now = new Date();
  const hours = now.getHours() % 12;
  const minutes = now.getMinutes();
  const seconds = now.getSeconds();

  // Calculate rotations (0 degrees is at 12 o'clock position)
  // Hour hand: moves 30 degrees per hour (360/12) plus 0.5 degrees per minute
  const newHourRotation = hours * 30 + minutes * 0.5;

  // Minute hand: moves 6 degrees per minute (360/60)
  const newMinuteRotation = minutes * 6 + seconds * 0.1;

  const hourSpins = 1;
  const minuteSpins = 2;
  const hourWithSpins = 360 * hourSpins + newHourRotation;
  const minuteWithSpins = 360 * minuteSpins + newMinuteRotation;

  animateClockHands(hourWithSpins, minuteWithSpins);
}

return (
  <svg
    width="155"
    height="143"
    viewBox="0 0 155 143"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    style={{ overflow: "visible" }}
  >
    <g
      ref={scope}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      onClick={handleClick}
    >
      <g
        data-animate="background"
        filter="url(#filter7_clock_359_1453)"
      >
        <path
          d="M86.0337 0.868621C128.43 6.67762 158.875 40.0266 154.034 75.3566C152.498 86.5686 147.592 96.6336 140.254 104.964C134.113 111.937 132.037 122.368 137.526 129.865L139.354 132.361C139.858 133.052 140.226 133.835 140.436 134.665C141.863 140.348 135.764 144.958 130.686 142.033L109.869 130.044C107.05 128.419 103.811 127.672 100.565 127.897L98.1097 128.067C88.7577 129.878 78.7507 130.213 68.5047 128.809C26.1097 123 -4.33427 89.6506 0.505733 54.3216C5.34673 18.9916 43.6387 -4.93938 86.0337 0.868621Z"
          fill="#F8F8F8"
        />
      </g>

      <g data-animate="clock-and-bells">
        <g data-animate="clock">
          <circle
            cx="76.6995"
            cy="69.0189"
            r="22.93"
            fill="#989898"
          />
          <motion.line
            data-animate="minute-hand"
            initial={{
              transform: `rotate(0deg)`,
              transformOrigin: "0% 100%",
            }}
            x1="76.3175"
            y1="69.5132"
            x2="78.0647"
            y2="58.2433"
            strokeWidth="4.9"
            strokeLinecap="round"
            stroke="#F8F8F8"
          />
          <motion.line
            data-animate="hour-hand"
            initial={{
              transformOrigin: "0% 100%",
              transform: `rotate(${INITIAL_HOUR_ROTATION}deg)`,
            }}
            x1="76.3174"
            y1="69.5132"
            x2="77.5886"
            y2="61.3112"
            strokeWidth="4.9"
            strokeLinecap="round"
            stroke="#F8F8F8"
          />
        </g>

        <motion.g
          data-animate="bells"
          initial={bellsVariants.initial}
          style={{ transformBox: "view-box" }}
        >
          <path
            data-animate="bell"
            data-index="0"
            d="M85.8858 33.91C86.5606 34.0022 87.1988 34.2719 87.7353 34.6915C88.2717 35.1111 88.6872 35.6655 88.9393 36.2982C89.1914 36.9309 89.271 37.6192 89.1701 38.2927C89.0691 38.9663 88.7913 39.6009 88.3648 40.132L86.3708 42.614C85.9358 43.155 85.2088 43.358 84.5448 43.158C81.8043 42.3293 78.9503 41.9381 76.0879 41.999C75.7485 42.0101 75.4136 41.9194 75.1263 41.7385C74.839 41.5576 74.6125 41.2948 74.4759 40.984L73.2248 38.058C72.9498 37.417 72.8549 36.712 72.9499 36.021C73.0184 35.5204 73.185 35.0383 73.4399 34.6021C73.6948 34.1659 74.0332 33.7842 74.4356 33.4788C74.8381 33.1734 75.2967 32.9502 75.7854 32.8221C76.2741 32.694 76.7833 32.6633 77.2838 32.732L85.8858 33.91Z"
            opacity="0.4"
            fill="#989898"
          />
          <path
            data-animate="bell"
            data-index="1"
            d="M102.985 49.4718C103.398 50.0153 103.656 50.6608 103.731 51.3395C103.806 52.0182 103.695 52.7045 103.411 53.3251C103.126 53.9458 102.678 54.4775 102.115 54.8635C101.552 55.2495 100.894 55.4753 100.213 55.5167C100.183 55.5184 100.154 55.5119 100.128 55.4979C100.102 55.484 100.081 55.4632 100.066 55.4378C99.5088 54.4819 98.8937 53.5611 98.2237 52.6807C97.556 51.7986 96.8346 50.9584 96.0637 50.1647C96.0437 50.1437 96.0297 50.1176 96.0234 50.0892C96.017 50.0609 96.0186 50.0313 96.0278 50.0037C96.2511 49.3587 96.6454 48.7865 97.1685 48.348C97.6915 47.9094 98.3238 47.6211 98.9979 47.5137C99.672 47.4064 100.363 47.484 100.996 47.7383C101.629 47.9926 102.182 48.4141 102.595 48.9577L102.985 49.4718Z"
            opacity="0.45"
            fill="#989898"
          />
        </motion.g>
      </g>
    </g>

    <defs>
      <filter
        id="filter7_clock_359_1453"
        x="0"
        y="0"
        width="154.539"
        height="142.938"
        colorInterpolationFilters="sRGB"
        filterUnits="userSpaceOnUse"
      >
        <feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
        <feBlend
          in="SourceGraphic"
          in2="BackgroundImageFix"
          result="shape"
        ></feBlend>
        <feColorMatrix
          in="SourceAlpha"
          result="hardAlpha"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
        ></feColorMatrix>
        <feOffset></feOffset>
        <feGaussianBlur stdDeviation="6.551"></feGaussianBlur>
        <feComposite
          in2="hardAlpha"
          k2="-1"
          k3="1"
          operator="arithmetic"
        ></feComposite>
        <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"></feColorMatrix>
        <feBlend
          in2="shape"
          result="effect1_innerShadow_359_1453"
        ></feBlend>
      </filter>
    </defs>
  </svg>
);
}


id: hero-illustration/clock-animation/click-again-just-a-pulse
lesson_id: hero-illustration/clock-animation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: clock-animation
lesson_title: Clock animation
heading: Click again: Just a pulse
url_path: /lessons/hero-illustration/clock-animation/#click-again-just-a-pulse
markdown_path: /md/sections/hero-illustration/clock-animation/click-again-just-a-pulse.md
excerpt:
After the first click, the clock settles into its upright position. Clicking again would replay the same animation, tilting it back upright - we don’t want that.

body:
After the first click, the clock settles into its upright position. Clicking again would replay the same animation, tilting it back upright - we don’t want that.

On subsequent clicks, we can ignore the hands as they’re already showing the time. We just want feedback to acknowledge the click:

const clockAndBellsVariants = {
  // ...other variants
  "scale-click": {
    transform: [
      "translateY(0px) rotate(-8deg) scale(1)",
      "translateY(0px) rotate(-8deg) scale(0.95)",
      "translateY(0px) rotate(-8deg) scale(1.03)",
      "translateY(0px) rotate(-8deg) scale(1)",
    ],
    transition: {
      duration: 0.4,
      times: [0, 0.25, 0.6, 1],
      ease: "easeOut",
    },
  },
};

const clockAndBellsVariants = {
  // ...other variants
  "scale-click": {
    transform: [
      "translateY(0px) rotate(-8deg) scale(1)",
      "translateY(0px) rotate(-8deg) scale(0.95)",
      "translateY(0px) rotate(-8deg) scale(1.03)",
      "translateY(0px) rotate(-8deg) scale(1)",
    ],
    transition: {
      duration: 0.4,
      times: [0, 0.25, 0.6, 1],
      ease: "easeOut",
    },
  },
};

This variant maintains the rotation from the first click but just animates the scale. The handler uses a ref to track whether this is the first click:

const hasClickedRef = useRef(false);

const handleClick = () => {
  if (!hasClickedRef.current) {
    hasClickedRef.current = true;
    // First click: animate to show time...
    animateClockVariant("click");
    // Calculate time and spin hands...
  } else {
    // Subsequent clicks: just pulse
    animateClockVariant("scale-click");
  }
};

const hasClickedRef = useRef(false);

const handleClick = () => {
  if (!hasClickedRef.current) {
    hasClickedRef.current = true;
    // First click: animate to show time...
    animateClockVariant("click");
    // Calculate time and spin hands...
  } else {
    // Subsequent clicks: just pulse
    animateClockVariant("scale-click");
  }
};

When the user leaves the clock, we reset the ref so it starts fresh next time:

const handleMouseLeave = async () => {
  hasClickedRef.current = false;
  animateClockHands(INITIAL_HOUR_ROTATION, 0);
  await animateClockVariant("initial");
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
};

const handleMouseLeave = async () => {
  hasClickedRef.current = false;
  animateClockHands(INITIAL_HOUR_ROTATION, 0);
  await animateClockVariant("initial");
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
};

"use client";

import { useRef, useEffect } from "react";
import {
motion,
useAnimate,
useMotionValue,
useTransform,
easeOut,
easeInOut,
} from "motion/react";
import { useAnimateVariant } from "./use-animate-variant";
import { bellsVariants, backgroundVariants, clockVariants, clockAndBellsVariants, bellVariants } from "./clock-variants";

const INITIAL_HOUR_ROTATION = 120;
const SPRING_CONFIG = {
type: "spring",
stiffness: 250,
damping: 25,
mass: 1.2,
}

export default function ClockAnimation() {
const [scope, animateVariant, animate] = useAnimateVariant();
const hasClickedRef = useRef(false);

useEffect(() => {
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
}, [animateVariant]);

const animateClockVariant = (variant) => {
  const animations = [
    animateVariant("[data-animate='clock']", clockVariants[variant] ?? clockVariants.initial),
    animateVariant("[data-animate='clock-and-bells']", clockAndBellsVariants[variant] ?? clockAndBellsVariants.initial),
    animateVariant('[data-animate="bells"]', bellsVariants[variant] ?? bellsVariants.initial),
    ...Array.from({ length: 2 }, (_, i) =>
      animateVariant(
        `[data-animate="bell"][data-index='${i}']`,
        typeof bellVariants[variant] === "function"
            ? bellVariants[variant](i)
            : bellVariants[variant] ?? bellVariants.initial
      )
    ),
    animateVariant('[data-animate="background"]', backgroundVariants[variant] ?? backgroundVariants.initial),
  ].filter(Boolean);

  return Promise.all(animations);
}

const animateClockHands = (hourRotation, minuteRotation) => {
  animate(
    "[data-animate='hour-hand']",
    {
      transform: `rotate(${hourRotation}deg)`,
    },
    SPRING_CONFIG
  );
  animate(
    "[data-animate='minute-hand']",
    {
      transform: `rotate(${minuteRotation}deg)`,
    },
    SPRING_CONFIG
  );
}

const handleMouseEnter = () => {
  animateClockVariant("hover");
}

const handleMouseLeave = async () => {
  hasClickedRef.current = false;
  animateClockHands(INITIAL_HOUR_ROTATION, 0);
  await animateClockVariant("initial");
  animateVariant('[data-animate="bells"]', bellsVariants.idle);
}

const handleClick = () => {
  if (!hasClickedRef.current) {
    hasClickedRef.current = true;
    animateClockVariant("click");

    const now = new Date();
    const hours = now.getHours() % 12;
    const minutes = now.getMinutes();
    const seconds = now.getSeconds();

    // Calculate rotations (0 degrees is at 12 o'clock position)
    // Hour hand: moves 30 degrees per hour (360/12) plus 0.5 degrees per minute
    const newHourRotation = hours * 30 + minutes * 0.5;

    // Minute hand: moves 6 degrees per minute (360/60)
    const newMinuteRotation = minutes * 6 + seconds * 0.1;

    const hourSpins = 1;
    const minuteSpins = 2;
    const hourWithSpins = 360 * hourSpins + newHourRotation;
    const minuteWithSpins = 360 * minuteSpins + newMinuteRotation;

    animateClockHands(hourWithSpins, minuteWithSpins);
  } else {
    animateClockVariant("scale-click");
  }
}

return (
  <svg
    width="155"
    height="143"
    viewBox="0 0 155 143"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    className="overflow-visible!"
  >
    <g
      ref={scope}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      onClick={handleClick}
    >
      <g
        data-animate="background"
        initial={backgroundVariants.initial}
        filter="url(#filter7_clock_359_1453)"
      >
        <path
          d="M86.0337 0.868621C128.43 6.67762 158.875 40.0266 154.034 75.3566C152.498 86.5686 147.592 96.6336 140.254 104.964C134.113 111.937 132.037 122.368 137.526 129.865L139.354 132.361C139.858 133.052 140.226 133.835 140.436 134.665C141.863 140.348 135.764 144.958 130.686 142.033L109.869 130.044C107.05 128.419 103.811 127.672 100.565 127.897L98.1097 128.067C88.7577 129.878 78.7507 130.213 68.5047 128.809C26.1097 123 -4.33427 89.6506 0.505733 54.3216C5.34673 18.9916 43.6387 -4.93938 86.0337 0.868621Z"
          fill="#F8F8F8"
        />
      </g>

      <g data-animate="clock-and-bells">
        <g data-animate="clock">
          <circle
            cx="76.6995"
            cy="69.0189"
            r="22.93"
            fill="#989898"
          />
          <motion.line
            data-animate="minute-hand"
            initial={{
              transform: `rotate(0deg)`,
              transformOrigin: "0% 100%",
            }}
            x1="76.3175"
            y1="69.5132"
            x2="78.0647"
            y2="58.2433"
            strokeWidth="4.9"
            strokeLinecap="round"
            stroke="#F8F8F8"
          />
          <motion.line
            data-animate="hour-hand"
            initial={{
              transformOrigin: "0% 100%",
              transform: `rotate(${INITIAL_HOUR_ROTATION}deg)`,
            }}
            x1="76.3174"
            y1="69.5132"
            x2="77.5886"
            y2="61.3112"
            strokeWidth="4.9"
            strokeLinecap="round"
            stroke="#F8F8F8"
          />
        </g>

        <motion.g
          data-animate="bells"
          initial={bellsVariants.initial}
          style={{ transformBox: "view-box" }}
        >
          <path
            data-animate="bell"
            data-index="0"
            d="M85.8858 33.91C86.5606 34.0022 87.1988 34.2719 87.7353 34.6915C88.2717 35.1111 88.6872 35.6655 88.9393 36.2982C89.1914 36.9309 89.271 37.6192 89.1701 38.2927C89.0691 38.9663 88.7913 39.6009 88.3648 40.132L86.3708 42.614C85.9358 43.155 85.2088 43.358 84.5448 43.158C81.8043 42.3293 78.9503 41.9381 76.0879 41.999C75.7485 42.0101 75.4136 41.9194 75.1263 41.7385C74.839 41.5576 74.6125 41.2948 74.4759 40.984L73.2248 38.058C72.9498 37.417 72.8549 36.712 72.9499 36.021C73.0184 35.5204 73.185 35.0383 73.4399 34.6021C73.6948 34.1659 74.0332 33.7842 74.4356 33.4788C74.8381 33.1734 75.2967 32.9502 75.7854 32.8221C76.2741 32.694 76.7833 32.6633 77.2838 32.732L85.8858 33.91Z"
            opacity="0.4"
            fill="#989898"
          />
          <path
            data-animate="bell"
            data-index="1"
            d="M102.985 49.4718C103.398 50.0153 103.656 50.6608 103.731 51.3395C103.806 52.0182 103.695 52.7045 103.411 53.3251C103.126 53.9458 102.678 54.4775 102.115 54.8635C101.552 55.2495 100.894 55.4753 100.213 55.5167C100.183 55.5184 100.154 55.5119 100.128 55.4979C100.102 55.484 100.081 55.4632 100.066 55.4378C99.5088 54.4819 98.8937 53.5611 98.2237 52.6807C97.556 51.7986 96.8346 50.9584 96.0637 50.1647C96.0437 50.1437 96.0297 50.1176 96.0234 50.0892C96.017 50.0609 96.0186 50.0313 96.0278 50.0037C96.2511 49.3587 96.6454 48.7865 97.1685 48.348C97.6915 47.9094 98.3238 47.6211 98.9979 47.5137C99.672 47.4064 100.363 47.484 100.996 47.7383C101.629 47.9926 102.182 48.4141 102.595 48.9577L102.985 49.4718Z"
            opacity="0.45"
            fill="#989898"
          />
        </motion.g>
      </g>
    </g>

    <defs>
      <filter
        id="filter7_clock_359_1453"
        x="0"
        y="0"
        width="154.539"
        height="142.938"
        colorInterpolationFilters="sRGB"
        filterUnits="userSpaceOnUse"
      >
        <feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
        <feBlend
          in="SourceGraphic"
          in2="BackgroundImageFix"
          result="shape"
        ></feBlend>
        <feColorMatrix
          in="SourceAlpha"
          result="hardAlpha"
          values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
        ></feColorMatrix>
        <feOffset></feOffset>
        <feGaussianBlur stdDeviation="6.551"></feGaussianBlur>
        <feComposite
          in2="hardAlpha"
          k2="-1"
          k3="1"
          operator="arithmetic"
        ></feComposite>
        <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"></feColorMatrix>
        <feBlend
          in2="shape"
          result="effect1_innerShadow_359_1453"
        ></feBlend>
      </filter>
    </defs>
  </svg>
);
}

In the next lesson, we’ll polish this into a production-ready component with mobile support, accessibility features, and performance optimizations.


id: hero-illustration/lines-and-dashes/overview
lesson_id: hero-illustration/lines-and-dashes
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: lines-and-dashes
lesson_title: Lines and dashes
heading: Overview
url_path: /lessons/hero-illustration/lines-and-dashes/#overview
markdown_path: /md/sections/hero-illustration/lines-and-dashes/overview.md
excerpt:
You’ve probably seen this effect before, a path that looks like it’s drawing itself. Loading spinners, signature animations, icon reveals. It’s everywhere. In this case, it’s the hand’s rays.

body:
You’ve probably seen this effect before, a path that looks like it’s drawing itself. Loading spinners, signature animations, icon reveals. It’s everywhere. In this case, it’s the hand’s rays.

The technique is surprisingly simple once you know the trick.


id: hero-illustration/lines-and-dashes/dash-patterns
lesson_id: hero-illustration/lines-and-dashes
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: lines-and-dashes
lesson_title: Lines and dashes
heading: Dash patterns
url_path: /lessons/hero-illustration/lines-and-dashes/#dash-patterns
markdown_path: /md/sections/hero-illustration/lines-and-dashes/dash-patterns.md
excerpt:
SVG paths can have dashed strokes using strokeDasharray. This property takes two values: the dash length and the gap length.

body:
SVG paths can have dashed strokes using strokeDasharray. This property takes two values: the dash length and the gap length.

<line strokeDasharray="20 10" /> // 20 units dash, 10 units gap

<line strokeDasharray="20 10" /> // 20 units dash, 10 units gap

Notice how in the demo below, the pattern automatically repeats along the entire path. This works on any SVG element with a stroke - lines, circles, rectangles, complex paths - the pattern tiles across its full length.

<svg viewBox="0 0 100 100">
  <path
    d="M 10 50 L 90 50"
    strokeDasharray="20 10"
    stroke="hsl(47, 94%, 66%)"
    strokeWidth="1.5"
  />
</svg>


id: hero-illustration/lines-and-dashes/shifting-with-dash-offset
lesson_id: hero-illustration/lines-and-dashes
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: lines-and-dashes
lesson_title: Lines and dashes
heading: Shifting with dash offset
url_path: /lessons/hero-illustration/lines-and-dashes/#shifting-with-dash-offset
markdown_path: /md/sections/hero-illustration/lines-and-dashes/shifting-with-dash-offset.md
excerpt:
Now that we have a dash pattern, we can shift it along the path using strokeDashoffset. Positive values shift the pattern backward, negative values shift it forward.

body:
Now that we have a dash pattern, we can shift it along the path using strokeDashoffset. Positive values shift the pattern backward, negative values shift it forward.

<line strokeDashoffset="30" /> // Shift pattern 30 units backward

<line strokeDashoffset="30" /> // Shift pattern 30 units backward

This shifting is what makes drawing animations possible - by animating the offset, we can control which part of the pattern is visible.

<svg viewBox="0 0 100 100">
  <path
    d="M 10 50 L 90 50"
    strokeDasharray="20 10"
    strokeDashoffset="0"
    stroke="hsl(47, 94%, 66%)"
    strokeWidth="1.5"
  />
</svg>


id: hero-illustration/lines-and-dashes/bringing-it-together
lesson_id: hero-illustration/lines-and-dashes
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: lines-and-dashes
lesson_title: Lines and dashes
heading: Bringing it together
excerpt: By combining these two properties in a specific way, we can create the line drawing effect:
url_path: /lessons/hero-illustration/lines-and-dashes/#bringing-it-together
markdown_path: /md/sections/hero-illustration/lines-and-dashes/bringing-it-together.md
body:
By combining these two properties in a specific way, we can create the line drawing effect:

Set the dash length to the path’s full length (one continuous dash) Set a large gap so only one dash is visible Offset the dash by the path length to hide it completely Animate the offset back to 0 to reveal the line

Here’s what that looks like in action:

<svg viewBox="0 0 100 100">
    <path
      d="M 10 50 L 90 50"
      strokeDasharray="100 100"
      strokeDashoffset="100"
      pathLength="100"
      stroke="hsl(47, 94%, 66%)"
      strokeWidth="1.5"
    />
  </svg>

The path starts hidden because the dash is offset by its full length. As strokeDashoffset animates from 100 to 0, the line appears to draw itself.

This is the exact technique we’ll use to animate the hand rays in the next lesson.


id: hero-illustration/lines-and-dashes/a-note-on-pathlength
lesson_id: hero-illustration/lines-and-dashes
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: lines-and-dashes
lesson_title: Lines and dashes
heading: A note on pathLength
url_path: /lessons/hero-illustration/lines-and-dashes/#a-note-on-pathlength
markdown_path: /md/sections/hero-illustration/lines-and-dashes/a-note-on-pathlength.md
excerpt:
In the animation above, the path uses pathLength="100". This property overrides the path’s actual pixel length - the browser uses your custom value for all stroke calculations.

body:
In the animation above, the path uses pathLength="100". This property overrides the path’s actual pixel length - the browser uses your custom value for all stroke calculations.

This is useful for normalizing paths to round numbers. Set it to 100 to work in percentages, or use the same value across multiple paths so they can share animation values regardless of their actual sizes.


id: hero-illustration/lines-and-dashes/exercise-animate-a-checkbox
lesson_id: hero-illustration/lines-and-dashes
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: lines-and-dashes
lesson_title: Lines and dashes
heading: Exercise: Animate a checkbox
url_path: /lessons/hero-illustration/lines-and-dashes/#exercise-animate-a-checkbox
markdown_path: /md/sections/hero-illustration/lines-and-dashes/exercise-animate-a-checkbox.md
excerpt:
Let’s practice the line drawing technique. Below is a checkbox with a square and checkmark - both have pathLength="100" already set.

body:
Let’s practice the line drawing technique. Below is a checkbox with a square and checkmark - both have pathLength="100" already set.

Your task: Make both elements draw themselves when you click the button, with the square completing before the checkmark starts.

Success criteria:

Both shapes start hidden Square draws itself first Checkmark draws after the square finishes

import "./styles.css";
import { useState } from "react";

export default function CheckboxAnimation() {
const [reset, setReset] = useState(0);

return (
  <div className="container">
    <svg key={reset} viewBox="0 0 100 100">
      {/* Square */}
      <path
        d="M 50,30 L 70,30 L 70,70 L 30,70 L 30,30 Z"
        fill="none"
        stroke="#000"
        strokeWidth="2"
        className="square"
      />

      {/* Checkmark */}
      <path
        d="M 40,51 L 48,60 L 61,41"
        fill="none"
        stroke="#000"
        strokeWidth="2"
        className="checkmark"
      />
    </svg>

    <button className="button" onClick={() => setReset(reset + 1)}>
      Complete task
    </button>
  </div>
);
}


id: hero-illustration/polish/overview
lesson_id: hero-illustration/polish
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: polish
lesson_title: Polish
heading: Overview
url_path: /lessons/hero-illustration/polish/#overview
markdown_path: /md/sections/hero-illustration/polish/overview.md
excerpt:
The clock works. It responds to hover, animates the hands to the current time, handles all the interactions. But something’s missing. That subtle aliveness you see in UIs that truly stand out. Let’s improve it!

body:
The clock works. It responds to hover, animates the hands to the current time, handles all the interactions. But something’s missing. That subtle aliveness you see in UIs that truly stand out. Let’s improve it!

We’ll work on subtle movement, better interaction feel, mobile support, accessibility, and performance optimization for complex scenes.


id: hero-illustration/polish/subtle-life
lesson_id: hero-illustration/polish
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: polish
lesson_title: Polish
heading: Subtle life
url_path: /lessons/hero-illustration/polish/#subtle-life
markdown_path: /md/sections/hero-illustration/polish/subtle-life.md
excerpt:
When idle, everything is completely still. It works, but it doesn’t feel alive. We need some barely-noticeable movement to make the scene feel more organic.

body:
When idle, everything is completely still. It works, but it doesn’t feel alive. We need some barely-noticeable movement to make the scene feel more organic.

We’ll add gentle floating to the illustrations and subtle rotation to the backgrounds. Instead of writing animation logic in useEffect hooks, we’ll create helper functions that return animation props we can spread directly onto motion.g elements.

export const createFloatingAnimation = ({ to = 2, duration = 2.5 }) => {
  return {
    initial: { transform: "translateY(0px)" },
    animate: {
      transform: ["translateY(0px)", `translateY(${to}px)`],
    },
    transition: {
      duration,
      ease: "easeInOut",
      repeat: Infinity,
      repeatType: "reverse",
    },
  };
};

export const createRotationAnimation = ({ to = 2, duration = 5 }) => {
  return {
    initial: { transform: "rotate(0deg)" },
    animate: {
      transform: ["rotate(0deg)", `rotate(${to}deg)`],
    },
    transition: {
      duration,
      ease: "easeInOut",
      repeat: Infinity,
      repeatType: "reverse",
    },
  };
};

export const createFloatingAnimation = ({ to = 2, duration = 2.5 }) => {
  return {
    initial: { transform: "translateY(0px)" },
    animate: {
      transform: ["translateY(0px)", `translateY(${to}px)`],
    },
    transition: {
      duration,
      ease: "easeInOut",
      repeat: Infinity,
      repeatType: "reverse",
    },
  };
};

export const createRotationAnimation = ({ to = 2, duration = 5 }) => {
  return {
    initial: { transform: "rotate(0deg)" },
    animate: {
      transform: ["rotate(0deg)", `rotate(${to}deg)`],
    },
    transition: {
      duration,
      ease: "easeInOut",
      repeat: Infinity,
      repeatType: "reverse",
    },
  };
};

Now we can apply them directly:

<motion.g
  {...createFloatingAnimation({
    to: 1.5,
    duration: 3,
  })}
>
  <motion.g
    {...createRotationAnimation({
      to: 1,
      duration: 4,
    })}
  >
    {/* background path */}
  </motion.g>
  {/* other elements */}
</motion.g>

<motion.g
  {...createFloatingAnimation({
    to: 1.5,
    duration: 3,
  })}
>
  <motion.g
    {...createRotationAnimation({
      to: 1,
      duration: 4,
    })}
  >
    {/* background path */}
  </motion.g>
  {/* other elements */}
</motion.g>

The outer group floats the entire illustration with translateY(1.5px) over 3 seconds. The inner group rotates just the background with rotate(2deg) over 4 seconds. Different timings mean they never sync up. That’s why it feels organic and not mechanical.

Grid lines were added to emphasize the subtle movement.

It’s very subtle, but once all elements in the scene are animated in this subtle way, it creates a sense of life and movement. Especially when all the elements have some special, unique movement to them too from time to time, like the lightbulb blinking.


id: hero-illustration/polish/preventing-accidental-triggers
lesson_id: hero-illustration/polish
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: polish
lesson_title: Polish
heading: Preventing accidental triggers
url_path: /lessons/hero-illustration/polish/#preventing-accidental-triggers
markdown_path: /md/sections/hero-illustration/polish/preventing-accidental-triggers.md
excerpt:
Try moving your mouse quickly across the clock below. The animation triggers way too quickly, it’s jittery and annoying. That’s why tooltips have slight delays to them for example, to prevent accidental triggers.

body:
Try moving your mouse quickly across the clock below. The animation triggers way too quickly, it’s jittery and annoying. That’s why tooltips have slight delays to them for example, to prevent accidental triggers.

The fix is to simply trigger the hover only if the mouse stays for a tiny bit.

There are a few ways to do this, but a simple approach is debouncing the hover with a useHoverTimeout hook. This ensures that the hover will be triggered only after you hovered for at least 100ms. You can see the full implementation of the hook here.

It accepts a delay prop, which in our case is 100ms and two callbacks, onHoverStart and onHoverEnd that fire when the hover starts (after the delay) and ends. We can then use this hook and apply it to each of the interactions.

const { handleMouseEnter, handleMouseLeave } = useHoverTimeout({
  delay: 100,
  onHoverStart: () => {
    // play hover animations
  },
  onHoverEnd: () => {
    // return to idle animations
  },
});

return (
  <g onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
    {/* illustration */}
  </g>
);

const { handleMouseEnter, handleMouseLeave } = useHoverTimeout({
  delay: 100,
  onHoverStart: () => {
    // play hover animations
  },
  onHoverEnd: () => {
    // return to idle animations
  },
});

return (
  <g onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
    {/* illustration */}
  </g>
);

100ms is short enough to feel responsive, long enough to prevent accidental triggers. Try rapid mouse movements below and see how it feels.


id: hero-illustration/polish/mobile-support
lesson_id: hero-illustration/polish
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: polish
lesson_title: Polish
heading: Mobile support
url_path: /lessons/hero-illustration/polish/#mobile-support
markdown_path: /md/sections/hero-illustration/polish/mobile-support.md
excerpt:
Our clock animation works great on dekstop, but on mobile, there’s a problem. Hover and click handlers fire at once on touch devices. The demo below emulates the behavior a mobile user will experience with our current implementation. Try interacting with it.

body:
Our clock animation works great on dekstop, but on mobile, there’s a problem. Hover and click handlers fire at once on touch devices. The demo below emulates the behavior a mobile user will experience with our current implementation. Try interacting with it.

Emulation of mobile interaction without handling hover state

There’s no hover feedback as there is no hover on mobile. And a single click triggers both the hover and click animations immediately – you can even see the background glitch slightly as it quickly transitions from the hover to the click animation. There’s also no mouse leave event – the hover state only resets when you tap outside the illustration.

To fix this, we will need a two-tap pattern on mobile devices: first tap to trigger the hover animation and second tap to trigger the click animation.

To build this, we need to detect touch devices and track tap state. Let’s start with a media query to detect touch devices:

import { useMediaQuery } from "usehooks-ts";

// inside component
const isMobile = useMediaQuery("(pointer: coarse)");

import { useMediaQuery } from "usehooks-ts";

// inside component
const isMobile = useMediaQuery("(pointer: coarse)");

The pointer: coarse media query detects if the primary input device has limited pointing accuracy – touch screens. A narrow desktop browser window still returns false because it has a precise pointer.

On mobile, we want: tap once to trigger the hover animation, and tap again to trigger the click animation. We can create a simple hook to help us implement this behaviour across many components:

import { useMediaQuery } from "usehooks-ts";
import { useEffect, useRef, useCallback } from "react";

export function useMobileTap() {
  const isMobile = useMediaQuery("(pointer: coarse)");
  const isReadyForClickRef = useRef(true);

  useEffect(() => {
    if (isMobile) {
      isReadyForClickRef.current = false;
    }
  }, [isMobile]);

  const markTapped = useCallback(() => {
    if (isMobile) {
      isReadyForClickRef.current = true;
    }
  }, [isMobile]);

  const resetTap = useCallback(() => {
    if (isMobile) {
      isReadyForClickRef.current = false;
    }
  }, [isMobile]);

  return {
    isReadyForClickRef,
    markTapped,
    resetTap,
  };
}

import { useMediaQuery } from "usehooks-ts";
import { useEffect, useRef, useCallback } from "react";

export function useMobileTap() {
  const isMobile = useMediaQuery("(pointer: coarse)");
  const isReadyForClickRef = useRef(true);

  useEffect(() => {
    if (isMobile) {
      isReadyForClickRef.current = false;
    }
  }, [isMobile]);

  const markTapped = useCallback(() => {
    if (isMobile) {
      isReadyForClickRef.current = true;
    }
  }, [isMobile]);

  const resetTap = useCallback(() => {
    if (isMobile) {
      isReadyForClickRef.current = false;
    }
  }, [isMobile]);

  return {
    isReadyForClickRef,
    markTapped,
    resetTap,
  };
}

On desktop, isReadyForClick is always true. On mobile, it starts as false. First tap calls markTapped() to enable the click, second tap proceeds with the click animation.

Now let’s connect this to our hover and click handlers. The trick is making handleClick return early on the first tap, which allows handleMouseEnter to fire and play the hover animation:

const isMobile = useMediaQuery("(pointer: coarse)");
const { isReadyForClickRef, markTapped, resetTap } = useMobileTap();

const handleClick = () => {
  if (!isReadyForClickRef.current) {
    markTapped(); // First tap on mobile
    return; // Return early to trigger the hover animation
  }
  // Only trigger click animation on second tap
};

const { handleMouseEnter, handleMouseLeave } = useHoverTimeout({
  delay: isMobile ? 0 : 100, // No delay on mobile
  onHoverStart: () => {
    // Hover animation plays because of early return in handleClick
    playWaveAnimation();
  },
  onHoverEnd: () => {
    resetTap();
    returnToIdle();
  },
});

return (
  <motion.div
    onMouseEnter={handleMouseEnter}
    onMouseLeave={handleMouseLeave}
    onClick={handleClick}
  >
    {/* clock */}
  </motion.div>
);

const isMobile = useMediaQuery("(pointer: coarse)");
const { isReadyForClickRef, markTapped, resetTap } = useMobileTap();

const handleClick = () => {
  if (!isReadyForClickRef.current) {
    markTapped(); // First tap on mobile
    return; // Return early to trigger the hover animation
  }
  // Only trigger click animation on second tap
};

const { handleMouseEnter, handleMouseLeave } = useHoverTimeout({
  delay: isMobile ? 0 : 100, // No delay on mobile
  onHoverStart: () => {
    // Hover animation plays because of early return in handleClick
    playWaveAnimation();
  },
  onHoverEnd: () => {
    resetTap();
    returnToIdle();
  },
});

return (
  <motion.div
    onMouseEnter={handleMouseEnter}
    onMouseLeave={handleMouseLeave}
    onClick={handleClick}
  >
    {/* clock */}
  </motion.div>
);

First tap: handleClick returns early, handleMouseEnter fires. Second tap: isReadyForClick is true, click animation runs. When the user taps outside, handleMouseLeave calls resetTap() to prepare for the next interaction.

We also set the delay to 0 in useHoverTimeout for mobile devices because the first tap should trigger the hover animation immediately as there’s no mouse movement to debounce.

Try it below. The first click triggers the hover animation, second one triggers the click animation and updates the time. Subsequent clicks give scale feedback.

Emulation of mobile interaction with hover state handled


id: hero-illustration/polish/respecting-motion-preferences
lesson_id: hero-illustration/polish
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: polish
lesson_title: Polish
heading: Respecting motion preferences
url_path: /lessons/hero-illustration/polish/#respecting-motion-preferences
markdown_path: /md/sections/hero-illustration/polish/respecting-motion-preferences.md
excerpt:
To prevent people from feeling sick or getting distracted, our animations need to be changed based on whether they have prefers-reduced-motion set in their OS. motion/react gives us a useReducedMotion hook to tell us exactly that.

body:
To prevent people from feeling sick or getting distracted, our animations need to be changed based on whether they have prefers-reduced-motion set in their OS. motion/react gives us a useReducedMotion hook to tell us exactly that.

import { useReducedMotion } from "motion/react";

const shouldReduceMotion = useReducedMotion();

import { useReducedMotion } from "motion/react";

const shouldReduceMotion = useReducedMotion();

When shouldReduceMotion is true, we disable all animations, floating, rotation, hover and click interactions. The asset becomes static.

You might think disabling all animations is too drastic, but to me with this type of decorative animations, it’s either all or nothing. We could’ve kept the floating animation, but that hints that the whole thing is interactive, which it wouldn’t be if reduced motion is enabled.

To disable the floating animation for example, we can pass shouldReduceMotion to the createFloatingAnimation and simply return the initial and animate keys to be exactly the same so no movement occurs.

export const createFloatingAnimation = ({
  to = 2,
  duration = 2.5,
  delay = 0,
  shouldReduceMotion,
}: {
  to?: number;
  duration?: number;
  delay?: number;
  shouldReduceMotion?: boolean | null;
} = {}): FloatingAnimationProps | undefined => {
  if (shouldReduceMotion) {
    return {
      initial: { transform: "translateY(0px)" },
      animate: { transform: "translateY(0px)" },
    };
  }

  // ... normal animation
};

export const createFloatingAnimation = ({
  to = 2,
  duration = 2.5,
  delay = 0,
  shouldReduceMotion,
}: {
  to?: number;
  duration?: number;
  delay?: number;
  shouldReduceMotion?: boolean | null;
} = {}): FloatingAnimationProps | undefined => {
  if (shouldReduceMotion) {
    return {
      initial: { transform: "translateY(0px)" },
      animate: { transform: "translateY(0px)" },
    };
  }

  // ... normal animation
};

For animations that rely on clicks or hovers, we can return early in those functions if shouldReduceMotion is true. This will prevent any animation from playing.

const handleClick = () => {
  if (shouldReduceMotion) return;

  // ...click handler logic
};

const handleClick = () => {
  if (shouldReduceMotion) return;

  // ...click handler logic
};

Below is the full implementation of the clock component with all motion disabled if the user has prefers-reduced-motion set.

You can test it by disabling reducing motion in your OS settings (macOS: System Settings → Accessibility → Motion → Reduce motion) or Chrome DevTools: open Command Palette (Cmd+Shift+P), search “Emulate CSS prefers-reduced-motion: reduce”.


id: hero-illustration/polish/optimizing-performance
lesson_id: hero-illustration/polish
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: polish
lesson_title: Polish
heading: Optimizing performance
url_path: /lessons/hero-illustration/polish/#optimizing-performance
markdown_path: /md/sections/hero-illustration/polish/optimizing-performance.md
excerpt:
We animate a lot of elements at once. The floating animation of all 6 illustrations alone is constantly updating the DOM. That’s a lot of work for the browser, so you might see dropped frames. This might happen especially if you’re using lots of SVG filters.

body:
We animate a lot of elements at once. The floating animation of all 6 illustrations alone is constantly updating the DOM. That’s a lot of work for the browser, so you might see dropped frames. This might happen especially if you’re using lots of SVG filters.

Notice how many elements are constantly being updated.

We hit this exact issue, the animation felt janky. It didn’t drop to 5fps, but it was noticable, especially when some hover interactions were happening while all other illustrations were floating. The video below shows the issue:

Notice slight frame drops as the hover animation plays.

Usually when you hit performance issues with SVGs there are a few things you can do. Here’s what worked in this case:

/* Performance optimizations for animations */
svg [data-animate] {
  will-change: transform, opacity, stroke-dashoffset;
  contain: layout style paint;
}

/* Optimize elements with expensive filters */
svg .filter-animated {
  will-change: transform;
  transform: translateZ(0);
}

/* Performance optimizations for animations */
svg [data-animate] {
  will-change: transform, opacity, stroke-dashoffset;
  contain: layout style paint;
}

/* Optimize elements with expensive filters */
svg .filter-animated {
  will-change: transform;
  transform: translateZ(0);
}

will-change tells the browser which properties we’ll animate. The browser promotes these elements to their own GPU layer, which gets animated separately without recalculating the entire page on every frame.

contain: layout style paint isolates the element’s rendering – changes here won’t trigger repaints of sibling elements.

transform: translateZ(0) forces GPU layer creation as a fallback, especially useful for filtered elements where blur calculations are expensive.

Don’t add these preemptively, GPU layers use memory. Too many layers hurt performance instead of helping. Build your animation, test it, and only add will-change when you see dropped frames. We’re targeting [data-animate] specifically, not every element.

For a deeper dive into performance, you can view the performance lesson.


id: hero-illustration/polish/wrapping-it-up
lesson_id: hero-illustration/polish
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: polish
lesson_title: Polish
heading: Wrapping it up
url_path: /lessons/hero-illustration/polish/#wrapping-it-up
markdown_path: /md/sections/hero-illustration/polish/wrapping-it-up.md
excerpt:
The clock animation is complete. We went from a basic interaction to something that feels more intentional and polished.

body:
The clock animation is complete. We went from a basic interaction to something that feels more intentional and polished.

The key additions:

Subtle life: Floating and rotation make idle states feel alive. Better interactions: Hover debouncing prevents accidental triggers. Mobile support: Two-tap pattern handles touch properly. Accessibility: Respects prefers-reduced-motion. Performance: CSS hints for smooth animation at scale.

We only covered two of the 6 interactive elements in this illustration to keep this walkthrough focused, but you can view the full implementation of it on GitHub


id: hero-illustration/rotation/overview
lesson_id: hero-illustration/rotation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: rotation
lesson_title: Rotation
heading: Overview
url_path: /lessons/hero-illustration/rotation/#overview
markdown_path: /md/sections/hero-illustration/rotation/overview.md
excerpt:
So, we know how to position shapes and animate lines. Now let’s rotate them. Click the clock below to see the hands rotate around the clock center.

body:
So, we know how to position shapes and animate lines. Now let’s rotate them. Click the clock below to see the hands rotate around the clock center.

Those hands use transform origins to rotate around a specific point. Let’s see how it works.


id: hero-illustration/rotation/the-problem-with-svg-transforms
lesson_id: hero-illustration/rotation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: rotation
lesson_title: Rotation
heading: The problem with SVG transforms
excerpt: Let’s rotate a rectangle:
url_path: /lessons/hero-illustration/rotation/#the-problem-with-svg-transforms
markdown_path: /md/sections/hero-illustration/rotation/the-problem-with-svg-transforms.md
body:
Let’s rotate a rectangle:

<svg viewBox="0 0 100 100">
  <path
    x="30"
    y="30"
    width="40"
    height="40"
    strokeWidth="1.5"
    style="transform: rotate(0deg);"
    fill="hsl(47, 94%, 66%)"
  />
</svg>

It rotates around the corner instead of its center. This is because SVG transforms use coordinate (0, 0) as the default origin - the top-left corner of the viewBox.

You might try to fix this with transform-origin: center, like you would in HTML:

rect {
  transform: rotate(45deg);
  transform-origin: center;
}

rect {
  transform: rotate(45deg);
  transform-origin: center;
}

But in SVG, center refers to the center of the viewBox, not the element. So this still doesn’t give us what we want.

The real fix is to set transform-origin to explicit coordinates. For a rectangle with x="20", y="20", width="40", and height="40", the center point is at (50, 50). To rotate around that center, you’d write:

<svg viewBox="0 0 100 100">
  <path
    x="30"
    y="30"
    width="40"
    height="40"
    strokeWidth="1.5"
    style="
          transform: rotate(0deg);
          transform-origin: 50px 50px;
    "
    fill="hsl(47, 94%, 66%)"
  />
</svg>

This is exactly what we need for clock hands. Both hands rotate around the same point - the center of the clock - regardless of their own positions or lengths.

Let’s put this into practice. Assemble the clock pieces below so the hands rotate correctly when you click “Animate hands”.

Success criteria:

Clock circle positioned at the center of the canvas. Both hands start at the center of the clock. Hands rotate around the clock’s center when animated.

import "./styles.css";
import { useState } from "react";

export default function ClockAssembly() {
const [reset, setReset] = useState(0);

return (
  <div className="container">
    <svg key={reset} viewBox="-9 -6 113 107">
      <g mask="url(#grid-mask)">
        {/* Grid background */}
        <rect width="100" height="100" fill="url(#grid)" />

        {/* Left edge */}
        <rect x="0" y="0" width="0.5" height="100" fill="url(#left-edge)" />

        {/* Top edge */}
        <rect x="0" y="0" width="100" height="0.5" fill="url(#top-edge)" />

        {/* Clock circle - currently at wrong position */}
        <circle
          cx="65"
          cy="35"
          r="25"
          fill="none"
          stroke="#000"
          strokeWidth="2.25"
          className="clock-circle"
        />

        {/* Hour hand - currently at wrong position */}
        <line
          x1="20"
          y1="60"
          x2="35"
          y2="60"
          stroke="#000"
          strokeWidth="2.25"
          className="hour-hand"
        />

        {/* Minute hand - currently at wrong position */}
        <line
          x1="80"
          y1="90"
          x2="80"
          y2="70"
          stroke="#000"
          strokeWidth="1.75"
          className="minute-hand"
        />
      </g>

      {Array.from({ length: 6 }, (_, i) => {
        const value = i * 20;
        return (
          <text
            key={`x-label-${i}`}
            x={value}
            y="-2.3"
            textAnchor="middle"
            fontSize="4"
            fill="rgb(141, 141, 134)"
            className="font-mono"
          >
            {value}
          </text>
        );
      })}

      {Array.from({ length: 6 }, (_, i) => {
        const value = i * 20;
        return (
          <text
            key={`y-label-${i}`}
            x={-2}
            y={value}
            textAnchor="end"
            dominantBaseline="middle"
            fontSize="4"
            fill="rgb(141, 141, 134)"
            className="font-mono"
          >
            {value}
          </text>
        );
      })}

      <defs>
        {/* Grid pattern - starts from (10, 10) to avoid overlap */}
        <pattern
          id="grid"
          patternUnits="userSpaceOnUse"
          width="10"
          height="10"
          x="10"
          y="10"
        >
          <path
            d="M 0 10 h 10 v -10"
            stroke="rgb(141, 141, 134)"
            fill="none"
            vectorEffect="non-scaling-stroke"
            strokeDasharray="5"
          />
        </pattern>

        {/* Left edge pattern */}
        <pattern
          id="left-edge"
          patternUnits="userSpaceOnUse"
          width="1"
          height="10"
        >
          <path
            d="M 0 0 v 10"
            stroke="rgb(141, 141, 134)"
            fill="none"
            vectorEffect="non-scaling-stroke"
            strokeDasharray="5"
          />
        </pattern>

        {/* Top edge pattern */}
        <pattern
          id="top-edge"
          patternUnits="userSpaceOnUse"
          width="10"
          height="1"
        >
          <path
            d="M 0 0 h 10"
            stroke="rgb(141, 141, 134)"
            fill="none"
            vectorEffect="non-scaling-stroke"
            strokeDasharray="5"
          />
        </pattern>

        {/* grid mask */}
        <mask id="grid-mask" maskUnits="userSpaceOnUse">
          <rect x="0" y="0" width="100" height="100" fill="white" />
        </mask>
      </defs>
    </svg>

    {/* Use this button to replay your animation */}
    <button className="button" onClick={() => setReset(reset + 1)}>
      Animate hands
    </button>
  </div>
);
}


id: hero-illustration/rotation/a-simpler-approach-with-transform-box
lesson_id: hero-illustration/rotation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: rotation
lesson_title: Rotation
heading: A simpler approach with transform-box
url_path: /lessons/hero-illustration/rotation/#a-simpler-approach-with-transform-box
markdown_path: /md/sections/hero-illustration/rotation/a-simpler-approach-with-transform-box.md
excerpt:
The explicit coordinate approach works, but it has a downside: if you move the clock or change its size, you need to recalculate the transform-origin coordinates. There’s a more flexible solution using transform-box: fill-box.

body:
The explicit coordinate approach works, but it has a downside: if you move the clock or change its size, you need to recalculate the transform-origin coordinates. There’s a more flexible solution using transform-box: fill-box.

This property changes the reference point for transform-origin. Instead of using viewBox coordinates, it uses the element’s bounding box - making transform-origin work like it does in HTML:

rect {
  transform: rotate(45deg);
  transform-origin: center;
  transform-box: fill-box;
}

rect {
  transform: rotate(45deg);
  transform-origin: center;
  transform-box: fill-box;
}

Now center refers to the center of the rectangle itself, not the viewBox. Try moving the rect shape around - the transform-origin moves with it:

<svg viewBox="0 0 100 100">
  <path
    x="30"
    y="30"
    width="40"
    height="40"
    strokeWidth="1.5"
    style="
          transform: rotate(0deg);
          transform-origin: center;
          transform-box: fill-box;
    "
    fill="hsl(47, 94%, 66%)"
  />
</svg>


id: hero-illustration/rotation/challenge
lesson_id: hero-illustration/rotation
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: rotation
lesson_title: Rotation
heading: Challenge
url_path: /lessons/hero-illustration/rotation/#challenge
markdown_path: /md/sections/hero-illustration/rotation/challenge.md
excerpt:
Refactor your clock from the previous exercise to use transform-box: fill-box instead of explicit coordinates. You can post your solution in the Discord server.

body:
Refactor your clock from the previous exercise to use transform-box: fill-box instead of explicit coordinates. You can post your solution in the Discord server.


id: hero-illustration/svg-introduction/overview
lesson_id: hero-illustration/svg-introduction
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: svg-introduction
lesson_title: SVG Introduction
heading: Overview
url_path: /lessons/hero-illustration/svg-introduction/#overview
markdown_path: /md/sections/hero-illustration/svg-introduction/overview.md
excerpt:
A while back me and dimi worked together on some animations of the illustrations on animations.dev. Hover on each of the boxes above to see the animations in action. This walkthrough will cover how we animated some of them.

body:
A while back me and dimi worked together on some animations of the illustrations on animations.dev. Hover on each of the boxes above to see the animations in action. This walkthrough will cover how we animated some of them.

Before we dive into the actual animations of that illustration, let’s cover some SVG fundamentals.

By the end of the three SVG fundamentals lessons, you’ll understand:

The SVG coordinate system and viewBox Basic SVG path syntax Stroke properties for line drawing animations How to use transform origins correctly for SVG transforms

Let’s dive into it!


id: hero-illustration/svg-introduction/coordinates-not-flow
lesson_id: hero-illustration/svg-introduction
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: svg-introduction
lesson_title: SVG Introduction
heading: Coordinates, not flow
url_path: /lessons/hero-illustration/svg-introduction/#coordinates-not-flow
markdown_path: /md/sections/hero-illustration/svg-introduction/coordinates-not-flow.md
excerpt:
SVG uses coordinate-based positioning. Every element is placed at specific coordinates using geometric attributes like x, y, cx, and cy.

body:
SVG uses coordinate-based positioning. Every element is placed at specific coordinates using geometric attributes like x, y, cx, and cy.

In HTML, elements are part of the document flow. Block elements stack vertically, inline elements flow horizontally. The browser automatically positions elements based on the content around them.

SVG has no concept of flow. Elements sit exactly where you tell them to. If you create multiple elements without specifying coordinates, they’ll all render at the origin point (0, 0), stacked on top of each other.

Try switching between shapes and moving them around. Notice the differences in the coordinate arguments for each shape and how they behave:

<svg viewBox="0 0 100 100">
  <rect
    x="30"
    y="30"
    width="40"
    height="40"
    fill="hsl(47, 94%, 66%)"
  />
</svg>

Shapes can also disappear. Try dragging the rectangle’s width or height to 0 in the demo above – it vanishes completely. The same happens with lines if the start and end coordinates match, or circles with a radius of 0.

// These shapes won't render
<rect x="30" y="30" width="0" height="40" />
<line x1="50" y1="50" x2="50" y2="50" />
<circle cx="50" cy="50" r="0" />

// These shapes won't render
<rect x="30" y="30" width="0" height="40" />
<line x1="50" y1="50" x2="50" y2="50" />
<circle cx="50" cy="50" r="0" />

The SVG spec calls these “degenerate shapes”. Shapes that collapse into a point or line aren’t considered valid and won’t render. This is different from opacity="0" or fill="transparent", where the shape still exists but isn’t visible.


id: hero-illustration/svg-introduction/making-svgs-responsive
lesson_id: hero-illustration/svg-introduction
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: svg-introduction
lesson_title: SVG Introduction
heading: Making SVGs responsive
url_path: /lessons/hero-illustration/svg-introduction/#making-svgs-responsive
markdown_path: /md/sections/hero-illustration/svg-introduction/making-svgs-responsive.md
excerpt:
Now here’s the interesting part. Those coordinates aren’t pixel values. They’re values in SVG’s internal coordinate system, defined by the viewBox attribute. This is what we use to make our SVGs responsive.

body:
Now here’s the interesting part. Those coordinates aren’t pixel values. They’re values in SVG’s internal coordinate system, defined by the viewBox attribute. This is what we use to make our SVGs responsive.

Think of the viewBox as a camera looking at an infinite SVG canvas. It determines which portion of the coordinate space is visible and at what zoom level.

Without a viewBox, SVGs don’t scale properly. Try resizing the SVG below without viewBox, the shapes get cropped because their coordinates are fixed.

<svg
  width="300"
  height="300"
>
  <rect
    x="90"
    y="90"
    width="120"
    height="120"
    fill="hsl(47, 94%, 66%)"
  />
</svg>

Now toggle viewBox on and resize again. The SVG scales to fit because the browser scales the entire coordinate system.

The viewBox attribute takes four values: x, y, width, and height.

The first two values set the x and y coordinates of the top-left corner of the visible area.

The last two values set the width and height of the visible area. They determine how many coordinate units fit horizontally and vertically.

<svg viewBox="0 0 300 300">{/* ... */}</svg>

<svg viewBox="0 0 300 300">{/* ... */}</svg>

The viewBox above starts at coordinate (0, 0) and shows a 300×300 unit area.

Try adjusting the values below. Changing x and y pans the view. Changing the width & height will adjust the zoom/visible area.

<svg
  width="300"
  height="300"
  viewBox="-70 -70 340 340"
>
  <rect
    x="50"
    y="50"
    width="200"
    height="200"
    fill="hsl(47, 94%, 66%)"
  />
  <rect
    x="-350"
    y="-350"
    width="150"
    height="150"
    fill="hsl(47, 94%, 66%)"
  />
  <rect
    x="500"
    y="-450"
    width="150"
    height="150"
    fill="hsl(47, 94%, 66%)"
  />
  <rect
    x="-400"
    y="300"
    width="150"
    height="150"
    fill="hsl(47, 94%, 66%)"
  />
</svg>

You can even animate these values to create smooth panning and zooming effects. Hit the “Animate” button above to see it in action.

In the hero animation, the SVG uses viewBox="0 0 622 319". The shapes work with this coordinate system, but the SVG displays at different sizes depending on the screen. This is why we can use consistent animation values at any size.


id: hero-illustration/svg-introduction/drawing-with-an-invisible-cursor
lesson_id: hero-illustration/svg-introduction
module_slug: hero-illustration
module_title: Hero Illustration
lesson_slug: svg-introduction
lesson_title: SVG Introduction
heading: Drawing with an invisible cursor
excerpt: SVG paths let you draw complex shapes using drawing commands in the d attribute.
url_path: /lessons/hero-illustration/svg-introduction/#drawing-with-an-invisible-cursor
markdown_path: /md/sections/hero-illustration/svg-introduction/drawing-with-an-invisible-cursor.md
body:
SVG paths let you draw complex shapes using drawing commands in the d attribute.

Think of paths like drawing with a pen. There’s an invisible cursor that starts at (0, 0). Commands move this cursor around or draw lines from its current position.

Use the arrow buttons below to step through each command and watch the path being drawn:

<svg viewBox="0 0 100 100">
  <path
    d=""
    stroke="hsl(47, 94%, 66%)"
    strokeWidth="1.5"
  />
</svg>

This draws a square using two commands:

M (move) - moves the cursor to a starting point without drawing anything. L (line) - draws a line from the cursor’s current position to a new point.

Path commands can be uppercase or lowercase, and this changes how coordinates work:

Uppercase L 40,60: Go to coordinate (40, 60) in the viewBox. Lowercase l 40,60: Move 40 units right and 60 units down from the current cursor position. It’s relative to the cursor.

For a path that starts at M 30,20:

L 40,60 draws to (40, 60). l 10,40 draws to (40, 60) as well, because 30+10=40 and 20+40=60.

Try adjusting the coordinates below. Toggle between L (absolute) and l (relative) to see the difference.

<svg viewBox="0 0 100 100">
  <path
    d="M 30,20 L 40,60"
    stroke="hsl(47, 94%, 66%)"
    strokeWidth="1.5"
  />
</svg>

Try it yourself: Draw a triangle.

Using the path command field in the demo above, try drawing a triangle. Start at coordinate (50, 30) and make it about 40 units tall.

Those of you with sharper eyes might’ve spotted something odd - both the triangle you just drew and the square from earlier have the same problem. There’s an awkward corner where the path ends. When a path’s start and end points meet without being properly closed, you get this visual defect.

Zoom in on the corner below to see it more clearly.

<svg viewBox="0 0 100 100">
  <path
    d="
      M 30,30
      L 30,70
      L 70,70
      L 70,30
      L 30,30
    "
    stroke="hsl(47, 94%, 66%)"
    strokeWidth="1.5"
  />
</svg>

The fix is the Z command which draws a straight line back to the starting point and properly closes the path. Click the “Toggle” button above to see the difference.

The Z command is handy because:

It’s shorter than typing out the end coordinates It ensures the path is properly closed It works with both absolute and relative commands (though z and Z do the same thing)

So the triangle from earlier could be simplified to:

M 50,30 L 30,70 L 70,70 Z

M 50,30 L 30,70 L 70,70 Z

Or with relative coordinates:

M 50,30 l -20,40 l 40,0 Z

M 50,30 l -20,40 l 40,0 Z

In practice, you rarely write paths by hand. Design tools like Figma export them for you. But understanding the basics helps when you need to animate or modify them.

For a deeper dive into path syntax, check out Nanda’s A Deep Dive Into SVG Path Commands guide.


id: interviews/dennis-brotzky/overview
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Overview
url_path: /lessons/interviews/dennis-brotzky/#overview
markdown_path: /md/sections/interviews/dennis-brotzky/overview.md
excerpt:
Brotzky is an engineer who loves design. He is also the co-founder of Fey, an app that helps you make better investments.

body:
Henry Heffernan Mariana Castilho Lochie Axon Dennis Brotzky

Brotzky is an engineer who loves design. He is also the co-founder of Fey, an app that helps you make better investments.


id: interviews/dennis-brotzky/emil-00-00
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (00:00)
url_path: /lessons/interviews/dennis-brotzky/#emil-00-00
markdown_path: /md/sections/interviews/dennis-brotzky/emil-00-00.md
excerpt:
Most people I guess know you from Twitter because you are in the design engineering-ish sort of category, I would say. But if you could tell a bit more about yourself and what you do right now, it would be great.

body:
Most people I guess know you from Twitter because you are in the design engineering-ish sort of category, I would say. But if you could tell a bit more about yourself and what you do right now, it would be great.


id: interviews/dennis-brotzky/dennis-brotzky-00-08
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (00:08)
excerpt: Mm-hmm. Yep.
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-00-08
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-00-08.md
body:
Mm-hmm. Yep.

Yeah, I’m Dennis Brotzky, co-founder of Fey. I run the engineering side. have two other co-founders, Thiago Costa, who does the design side. And then Thomas Russell, who’s a bit of a ghost, but might be making a Twitter account soon. He runs the engineering, product, admin, everything. He’s kind of a beast in our secret sauce.


id: interviews/dennis-brotzky/emil-00-41
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (00:41)
url_path: /lessons/interviews/dennis-brotzky/#emil-00-41
markdown_path: /md/sections/interviews/dennis-brotzky/emil-00-41.md
excerpt:
Okay, so is it only you, Thiago and Thomas that is building Fey like full time? Because I knew the team was small, but I didn’t think it was this small.

body:
Okay, so is it only you, Thiago and Thomas that is building Fey like full time? Because I knew the team was small, but I didn’t think it was this small.


id: interviews/dennis-brotzky/dennis-brotzky-00-52
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (00:52)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-00-52
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-00-52.md
excerpt:
Yeah, I think that’s what people are most surprised about when they think of Fey. It seems like we have a big team running behind it, but it really is only us three. know, I know we’ve had some discussions with like other companies and other people, and then they hear us when we say we’re only three people, they always assume we’re like offshoring some sort of talent or we have some secret people. But honestly, it’s just the three of us and we work

body:
Yeah, I think that’s what people are most surprised about when they think of Fey. It seems like we have a big team running behind it, but it really is only us three. know, I know we’ve had some discussions with like other companies and other people, and then they hear us when we say we’re only three people, they always assume we’re like offshoring some sort of talent or we have some secret people. But honestly, it’s just the three of us and we work

really well together and we all work incredibly hard. Just like the last month in March, we’ve had to take out a data provider and we’re like, okay, this has to get done. It’s like a company changing event in terms of significance. So we just work all day. Right now we’re working weekends. We love what we do. So it’s not really an issue, but

We just work hard, get it done. And that’s it. That’s kind of the secret. It’s just us three.


id: interviews/dennis-brotzky/emil-01-59
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (01:59)
url_path: /lessons/interviews/dennis-brotzky/#emil-01-59
markdown_path: /md/sections/interviews/dennis-brotzky/emil-01-59.md
excerpt:
Is it just because you are working so well together and you don’t want to disrupt that? Or what’s the reason to not expand the team to five, six? It doesn’t have to get huge, but more people, I guess, more things get done to a certain point.

body:
Is it just because you are working so well together and you don’t want to disrupt that? Or what’s the reason to not expand the team to five, six? It doesn’t have to get huge, but more people, I guess, more things get done to a certain point.


id: interviews/dennis-brotzky/dennis-brotzky-02-17
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (02:17)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-02-17
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-02-17.md
excerpt:
To a certain extent. mean, when we started Fey, we actually had six people. And slowly over time, we’ve kind of shedded people. each time that happened, we moved faster and faster.

body:
To a certain extent. mean, when we started Fey, we actually had six people. And slowly over time, we’ve kind of shedded people. each time that happened, we moved faster and faster.

The beautiful thing of owning a company and having it your way is you can make the rules. Right now we’re having so much fun just working between the three of us. I don’t know if you’ve ever worked with, like there’s certain people you work with in your career, in your job, and like you guys just work really well together and it’s so much fun. And you’re like, I can’t wait to build this thing with this person. Or it’s always like the first person that you go and get feedback from.

You run to them and you’re like, hey, look, look what I made. Do you have any feedback? Are you impressed? And like the three of us, we have that with each other and things are moving really well. And we just don’t want to, don’t want to ruin that. It’s just too much fun.


id: interviews/dennis-brotzky/emil-03-20
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (03:20)
url_path: /lessons/interviews/dennis-brotzky/#emil-03-20
markdown_path: /md/sections/interviews/dennis-brotzky/emil-03-20.md
excerpt:
But does that mean that because the team is so small, you also get to design a bit? Does Thiago get to like code a bit or are the roles pretty strict?

body:
But does that mean that because the team is so small, you also get to design a bit? Does Thiago get to like code a bit or are the roles pretty strict?


id: interviews/dennis-brotzky/dennis-brotzky-03-34
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (03:34)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-03-34
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-03-34.md
excerpt:
they’re pretty strict. would say in the beginning, maybe I would be in Figma a bit more often and Thiago wouldn’t touch code at all. but more recently actually with AI and cursor and all the new technology around that Thiago is actually coding more than I’m designing. to be honest, I don’t do too much design or I just give Thiago some feedback and he just makes it look good. but

body:
they’re pretty strict. would say in the beginning, maybe I would be in Figma a bit more often and Thiago wouldn’t touch code at all. but more recently actually with AI and cursor and all the new technology around that Thiago is actually coding more than I’m designing. to be honest, I don’t do too much design or I just give Thiago some feedback and he just makes it look good. but


id: interviews/dennis-brotzky/dennis-brotzky-04-03
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (04:03)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-04-03
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-04-03.md
excerpt:
We’ve gotten so good at what we do and figuring out who should do what, where it’s the most efficient thing is he does all the design. I do all the engineering. Tom as well does pretty much all the engineering and a bunch of other stuff. So over time, our roles have gotten more and more defined where it’s just most efficient for Thiago to work. And also Thiago is like the fastest designer, I think, in the world.

body:
We’ve gotten so good at what we do and figuring out who should do what, where it’s the most efficient thing is he does all the design. I do all the engineering. Tom as well does pretty much all the engineering and a bunch of other stuff. So over time, our roles have gotten more and more defined where it’s just most efficient for Thiago to work. And also Thiago is like the fastest designer, I think, in the world.

If I think there was like a design competition with Brett and maybe lovable or something like that. And they had a 45 minute time limit. I was like, Thiago, you got to go into this because like he can spit out a beautiful landing page in 30 minutes. It’s absolutely insane. and he’s never a blocker in the company. He’s always like, our designs are years ahead and our engineering is fast too. But,


id: interviews/dennis-brotzky/dennis-brotzky-05-04
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (05:04)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-05-04
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-05-04.md
excerpt:
We’ve figured out the most efficient way to do it is having a bit more separate roles, which might not be very traditional nowadays where design engineers kind of are sitting in Figma. make the prototype, they do the engineering and they have like full control over that aspect. But I think because we’ve been working together so long, we’re almost like a single unit now and it works well.

body:
We’ve figured out the most efficient way to do it is having a bit more separate roles, which might not be very traditional nowadays where design engineers kind of are sitting in Figma. make the prototype, they do the engineering and they have like full control over that aspect. But I think because we’ve been working together so long, we’re almost like a single unit now and it works well.


id: interviews/dennis-brotzky/emil-05-34
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (05:34)
url_path: /lessons/interviews/dennis-brotzky/#emil-05-34
markdown_path: /md/sections/interviews/dennis-brotzky/emil-05-34.md
excerpt:
Yeah, I think many people think that design engineers design and code, I like when I worked at Vercel and the design engineering team was pretty big, like we rarely spent time in Figma as well. We may need to code and maybe the animation side was the motion part was a bit where we had more or we did more work than the designers, but we rarely designed in Figma as well so I don’t think it’s that rare.

body:
Yeah, I think many people think that design engineers design and code, I like when I worked at Vercel and the design engineering team was pretty big, like we rarely spent time in Figma as well. We may need to code and maybe the animation side was the motion part was a bit where we had more or we did more work than the designers, but we rarely designed in Figma as well so I don’t think it’s that rare.


id: interviews/dennis-brotzky/dennis-brotzky-06-04
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (06:04)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-06-04
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-06-04.md
excerpt:
That’s yeah, that’s a very good point on the motion side. I think that me personally, I might have more say in the way it feels and it’s implemented. Thiago usually uses like a After Effects or just like Figma to roughly show or he’ll even just put still images in Figma of like, this is this beginning, middle, end. And then it’s very much up to me to make it feel good and make it smooth and make it possible in that sense.

body:
That’s yeah, that’s a very good point on the motion side. I think that me personally, I might have more say in the way it feels and it’s implemented. Thiago usually uses like a After Effects or just like Figma to roughly show or he’ll even just put still images in Figma of like, this is this beginning, middle, end. And then it’s very much up to me to make it feel good and make it smooth and make it possible in that sense.


id: interviews/dennis-brotzky/emil-06-42
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (06:42)
url_path: /lessons/interviews/dennis-brotzky/#emil-06-42
markdown_path: /md/sections/interviews/dennis-brotzky/emil-06-42.md
excerpt:
To go back a bit, how did you end up at Fey? What did you do prior to that? How did you become an engineer in the first place?

body:
To go back a bit, how did you end up at Fey? What did you do prior to that? How did you become an engineer in the first place?


id: interviews/dennis-brotzky/dennis-brotzky-06-52
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (06:52)
excerpt: Man...
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-06-52
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-06-52.md
body:
Man...

Yeah. So I don’t have a traditional engineering background. didn’t do comp sci. I really didn’t touch a computer until I was, or I use a computer, but I didn’t code or do anything like that until maybe I was 21, which I think might be different than most people in the industry, especially if you were doing comp sci or engineering or something related in university. But I have a BA in psych, bachelor of arts.

and a minor in music technology. And there was one specific class in my fourth year of university where it was called music and the internet. And we had this kind of out there prof. It was a really small class of eight people. And it was supposed to just kind of be this, this fun course where I knew the prof, everyone’s having a good time. Uh, but randomly he showed us CSS and how to do a for loop in JavaScript.

It was just like the most random day in class. And he’s like, okay, guys, look at this website. And I remember vividly, he showed us a Coke bottle made in pure CSS on like some website. And he’s like, look how cool this is. And I was like, this doesn’t make any sense. Like what’s CSS, what’s so special about this Coke bottle? And he’s like, look, you can zoom in as far as you want. And like, it’s super crisp. It’s not an image.

And he kind of taught us the very basics of CSS and JavaScript. And then we had this one assignment where we had to make two websites for our favorite artist. So he’s like, pick your two favorite musicians and make them a website. We’re going to get 1 % bonus. And the class is going to vote on whose website is the best. So that was on a Monday. And I remember for the next week, I just dive.

dove so far into it where like my whole day was building this website. I remember the first time I dragged like indexed that HTML into my browser and I could see, oh wow, like this is how the internet works. It’s not like this huge black box. It’s just this file that you put into your browser. And there’s this stuff called HTML and there’s stuff called CSS. And if you want to make it interactive, you add JavaScript and uh,

I spent the entire week making these two awesome websites. And, in the end class voted mine was so much better than everyone. Like I was just in love with it. And this was my second last semester of university. And then I had like one more. So was like two halves of a year. And then I did comp sci one-on-one for my last kind of

course in university. And I remember it so bad. It was like all Java and I had no idea what was going on. I was in fourth year with a bunch of first year students that like already knew how to program and they’re doing all these assignments and I was just struggling so much and hating it to be honest. But I knew I was like, I have a BA in psych. And back then it was like,

I knew I wasn’t going to be a psychologist or something. And it’ll be very hard to find a job. And I decided, Hey, like web development, software engineering. It’s like very much in demand. So I kind of stuck through it, finished my comp sci one-on-one class just barely passed. And then I remember I got a part-time job working two days a week and it was awful. And I would spend all my other time reading books on JavaScript, making websites, recreating websites. I would like go to all my favorite websites and try and recreate them with HTML and CSS. And I like show all my friends and do all these things. And then eventually I was like, okay, I should make myself a portfolio website. And I spent so much time on it and I was so proud of it. And I sent it to a bunch of companies and then

first one I sent it to was Lightspeed, which is like a big Canadian company now, but at the time they were quite small. And the person interviewing me, he’s like, you’re very raw, but you look like you’re very passionate about this. And I can see like you just started a few months ago and you’re already doing like impressive stuff for that amount of time. So he hired me.


id: interviews/dennis-brotzky/dennis-brotzky-11-38
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (11:38)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-11-38
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-11-38.md
excerpt:
And that really changed my life. Like that’s where I met Thiago. He was the designer at this company and we hit it off right away. Cause it was, I think the first time he worked with someone that was so excited about everything and like he would design something and I would be so hungry to like see his Photoshop files back then. And he had like exclusively sent it to me. Cause he knew if he sent it to me, I would like make it right away and I would make it exactly how he had it in his head.

body:
And that really changed my life. Like that’s where I met Thiago. He was the designer at this company and we hit it off right away. Cause it was, I think the first time he worked with someone that was so excited about everything and like he would design something and I would be so hungry to like see his Photoshop files back then. And he had like exclusively sent it to me. Cause he knew if he sent it to me, I would like make it right away and I would make it exactly how he had it in his head.

And he was, and he’d never worked with someone like that. I think before all the engineers he worked with would kind of butcher everything and make it. Alignments were wrong. The colors were wrong. Interactions were wrong. And then I would always be pushing him. So he was like, give me a design. would be like, what if we did it like that? That would be even cooler. And we kind of just like kept spiraling in this thing. And that’s how I met Thiago and like we’ve been best friends ever since. But then I moved back to Vancouver, uh, from there and I had to leave Lightspeed and I went to go work for a startup in Vancouver in finance, which is kind of where Fague comes in a bit. Uh, was a startup essentially, I was the first front end engineer to join the company and help them grow. But I would always be in contact with Thiago and,

We’d always send messages, DMs being like, I don’t like how this is done at my company. I’m like, yeah, like the designer here isn’t very good. He’s like, the engineers here aren’t very good. And eventually we just said, it, let’s make our own thing. So we started a narrative with two other people, Mac and Thomas, who’s our other co-founder of Fain now. And that’s how it all kind of started.


id: interviews/dennis-brotzky/emil-13-34
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (13:34)
url_path: /lessons/interviews/dennis-brotzky/#emil-13-34
markdown_path: /md/sections/interviews/dennis-brotzky/emil-13-34.md
excerpt:
Is Lightspeed, this is kind of random but I’m not sure if I remember it correctly, is Lightspeed like an e-commerce brand or no?

body:
Is Lightspeed, this is kind of random but I’m not sure if I remember it correctly, is Lightspeed like an e-commerce brand or no?


id: interviews/dennis-brotzky/dennis-brotzky-13-40
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (13:40)
excerpt: Yeah, it’s a point of sale system.
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-13-40
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-13-40.md
body:
Yeah, it’s a point of sale system.

Yeah. So they do. I remember when I was at Lightspeed, our biggest, I wouldn’t say our biggest competitor, but we’d always look at Shopify. So Shopify back then was also pretty early. Another Canadian darling, the best tech company in Canada. And then Lightspeed also was in that bubble at that time.


id: interviews/dennis-brotzky/emil-13-42
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (13:42)
url_path: /lessons/interviews/dennis-brotzky/#emil-13-42
markdown_path: /md/sections/interviews/dennis-brotzky/emil-13-42.md
excerpt:
Right. But how did you start Fey or narrative like, and then Fey, did you like raise money or like how, cause I’m pretty sure it’s not like a VC backed company.

body:
Right. But how did you start Fey or narrative like, and then Fey, did you like raise money or like how, cause I’m pretty sure it’s not like a VC backed company.


id: interviews/dennis-brotzky/dennis-brotzky-14-25
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (14:25)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-14-25
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-14-25.md
excerpt:
The way narrative started was a bit of an interesting story where we were all working full-time jobs and we didn’t really have the cash in our savings to just like drop everything and risk it all on this new venture. So Thiago had the great idea of, he was very in demand back then, and I’m sure he would be now, but he would get recruiters talking to him all the time saying, Hey, you should come interview at this company. They’re very interested in your design skills.

body:
The way narrative started was a bit of an interesting story where we were all working full-time jobs and we didn’t really have the cash in our savings to just like drop everything and risk it all on this new venture. So Thiago had the great idea of, he was very in demand back then, and I’m sure he would be now, but he would get recruiters talking to him all the time saying, Hey, you should come interview at this company. They’re very interested in your design skills.

And I was like, Hey, why don’t I go to these interviews? And instead of them hiring me, they can hire narrative. And that’s what he did. He kind of went through a few interviews and as soon as the first person was like, we’re going to actually hire narrative instead of Thiago to do this design work. I think we all quit our jobs. We’re like, okay, let’s do this. Let’s dive in.

We have our first contract. Let’s, let’s go for it. And that was, I don’t know if anyone knows Hopper. was a long time ago. We did hopper.com as our first client. And we just kept the ball rolling from there and there and there. And then from there, we always knew we wanted to start a product. We knew consulting, you kind of get this like never ending cycle of trying to find your next customer. You always have to.

There’s no recurring revenue to put it simply. And that’s when we made enough, excuse me, enough cash from narrative’s consultancy side to start pay.


id: interviews/dennis-brotzky/emil-16-06
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (16:06)
url_path: /lessons/interviews/dennis-brotzky/#emil-16-06
markdown_path: /md/sections/interviews/dennis-brotzky/emil-16-06.md
excerpt:
Do you now just work on Fey exclusively or do you do some contracting type of stuff sometimes as well?

body:
Do you now just work on Fey exclusively or do you do some contracting type of stuff sometimes as well?


id: interviews/dennis-brotzky/dennis-brotzky-16-12
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (16:12)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-16-12
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-16-12.md
excerpt:
No, definitely not. 100%, seven days a week all the time. We wake up, think of how can we make fate even better? That’s all we do.

body:
No, definitely not. 100%, seven days a week all the time. We wake up, think of how can we make fate even better? That’s all we do.


id: interviews/dennis-brotzky/emil-16-23
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (16:23)
url_path: /lessons/interviews/dennis-brotzky/#emil-16-23
markdown_path: /md/sections/interviews/dennis-brotzky/emil-16-23.md
excerpt:
Yeah, I think that’s also nicer about working on a product that you can... I feel like when you’re working for someone, you make something and then it ships and then that’s it. And with the product, you can keep thinking about how you can improve it and it’s a never-ending story and it just keeps getting better and better.

body:
Yeah, I think that’s also nicer about working on a product that you can... I feel like when you’re working for someone, you make something and then it ships and then that’s it. And with the product, you can keep thinking about how you can improve it and it’s a never-ending story and it just keeps getting better and better.


id: interviews/dennis-brotzky/dennis-brotzky-16-43
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (16:43)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-16-43
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-16-43.md
excerpt:
Oh yeah, hundred percent, we have this internal joke every time we’re like, Oh yeah, phase complete now. And then we’re like, we’re not going to have anything to build after these one or two features. And then lo and behold, every single time the scope just gets bigger and bigger and bigger. And we have more stuff to build and more stuff to build. So yeah, it never ends.

body:
Oh yeah, hundred percent, we have this internal joke every time we’re like, Oh yeah, phase complete now. And then we’re like, we’re not going to have anything to build after these one or two features. And then lo and behold, every single time the scope just gets bigger and bigger and bigger. And we have more stuff to build and more stuff to build. So yeah, it never ends.


id: interviews/dennis-brotzky/emil-16-49
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (16:49)
url_path: /lessons/interviews/dennis-brotzky/#emil-16-49
markdown_path: /md/sections/interviews/dennis-brotzky/emil-16-49.md
excerpt:
What makes people... I at least when I browse Twitter I see sometimes a post, a random tweet about Fey like praising something about Fey. And of course you... I kind of have an assumption why that is but you build it with just you know to other people like why do you think people like Fey and not some other tool? What makes Fey special.

body:
What makes people... I at least when I browse Twitter I see sometimes a post, a random tweet about Fey like praising something about Fey. And of course you... I kind of have an assumption why that is but you build it with just you know to other people like why do you think people like Fey and not some other tool? What makes Fey special.


id: interviews/dennis-brotzky/dennis-brotzky-17-40
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (17:40)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-17-40
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-17-40.md
excerpt:
I think in investing, it’s often overly complicated and very technical. I see it a bit like math. People are kind of afraid of it. And I think Fey is the best app in the world where it simplifies everything for you, making it like super easy to track your portfolio, do research and get all the analysis you need. And there aren’t many finance or I don’t think there are any finance tools that look and feel like Fey. Like it’s very approachable. It’s beautiful. Honestly, it looks very simple to most people, which is good. We do our very best to do that. And to be frank, two thirds of our team, like we don’t have finance backgrounds. We’re engineers, designers. Thomas has a finance background, but he’s like this weird amalgamation of finance, engineering and taste, which you don’t find too much in the finance world. And we really just build it for ourselves. Like we all invest, we all have ambitions to grow our investments. We want to have some cash growing. So we just build what we find useful and it seems to resonate with other people.

body:
I think in investing, it’s often overly complicated and very technical. I see it a bit like math. People are kind of afraid of it. And I think Fey is the best app in the world where it simplifies everything for you, making it like super easy to track your portfolio, do research and get all the analysis you need. And there aren’t many finance or I don’t think there are any finance tools that look and feel like Fey. Like it’s very approachable. It’s beautiful. Honestly, it looks very simple to most people, which is good. We do our very best to do that. And to be frank, two thirds of our team, like we don’t have finance backgrounds. We’re engineers, designers. Thomas has a finance background, but he’s like this weird amalgamation of finance, engineering and taste, which you don’t find too much in the finance world. And we really just build it for ourselves. Like we all invest, we all have ambitions to grow our investments. We want to have some cash growing. So we just build what we find useful and it seems to resonate with other people.


id: interviews/dennis-brotzky/emil-18-52
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (18:52)
url_path: /lessons/interviews/dennis-brotzky/#emil-18-52
markdown_path: /md/sections/interviews/dennis-brotzky/emil-18-52.md
excerpt:
Something that like I’m kind of interested in, you’ve been working at for on on Fey for a long time. Don’t you get, you see that the same app every day when you work on it, right? Don’t you get bored of it, tired of it at some point

body:
Something that like I’m kind of interested in, you’ve been working at for on on Fey for a long time. Don’t you get, you see that the same app every day when you work on it, right? Don’t you get bored of it, tired of it at some point


id: interviews/dennis-brotzky/dennis-brotzky-19-26
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (19:26)
excerpt: Yeah, but if you do, you can just change it.
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-19-26
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-19-26.md
body:
Yeah, but if you do, you can just change it.

That might be where being three people and kind of having full control over the product becomes a benefit where we launched Fade 2.0 January 1st, 2025, somewhere around there. And it’s completely different to the Fade that was before it. So it really feels fresh, at least in my mind, everything I work on. I think I also have the benefit of working with an amazing designer. So.

I am seeing what he’s building and like, I get hungry to implement it and make it a reality. And everything he’s doing is very exciting and beautiful, which makes my life even easier.


id: interviews/dennis-brotzky/emil-20-15
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (20:15)
url_path: /lessons/interviews/dennis-brotzky/#emil-20-15
markdown_path: /md/sections/interviews/dennis-brotzky/emil-20-15.md
excerpt:
How do you guys keep the app fresh? Do you just like perform the design and build things based on your own intuition or do you listen or get users feedback? Cause the team is pretty small. Usually, you know, companies have even whole teams to do like do interviews with customers and whatever. Like, is it mostly you guys?

body:
How do you guys keep the app fresh? Do you just like perform the design and build things based on your own intuition or do you listen or get users feedback? Cause the team is pretty small. Usually, you know, companies have even whole teams to do like do interviews with customers and whatever. Like, is it mostly you guys?

and your intuition or how does building a feature or updating a feature at Fey look like?


id: interviews/dennis-brotzky/dennis-brotzky-20-45
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (20:45)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-20-45
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-20-45.md
excerpt:
It’s a mixture of everything. I think the main driving factor is what we want to build for ourselves. I think if you’re building a product for yourself, you’re always going to find it very exciting because it’s what you want. But of course we get a ton of user feedback and we kind of have a high overview of what customers want. And we take that into account. It’s very important. We definitely listen to all our feedback, we have a feedback channel in our Slack, we have a ton of DMs with our customers where we’re always kind of bouncing off ideas. But ultimately, if we’re not excited about the feature, we’re not going to build it. It’s just going to be way too hard. It’s going to be like implementing something you’re not excited about is like the worst thing. And we do make sure.

body:
It’s a mixture of everything. I think the main driving factor is what we want to build for ourselves. I think if you’re building a product for yourself, you’re always going to find it very exciting because it’s what you want. But of course we get a ton of user feedback and we kind of have a high overview of what customers want. And we take that into account. It’s very important. We definitely listen to all our feedback, we have a feedback channel in our Slack, we have a ton of DMs with our customers where we’re always kind of bouncing off ideas. But ultimately, if we’re not excited about the feature, we’re not going to build it. It’s just going to be way too hard. It’s going to be like implementing something you’re not excited about is like the worst thing. And we do make sure.


id: interviews/dennis-brotzky/dennis-brotzky-21-44
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (21:44)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-21-44
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-21-44.md
excerpt:
We work on stuff that’s Even like we’ll be bouncing around ideas and being like, like what’s the priority? Should we implement A or should we implement B or C? And then our answer is almost what’s, what makes you the most happy? Build that. And that’s kind of how we keep it fresh. If you’re excited to build the feature, you want it for yourself, you’re to go into the nitty gritty. You’re going to figure out all the details.

body:
We work on stuff that’s Even like we’ll be bouncing around ideas and being like, like what’s the priority? Should we implement A or should we implement B or C? And then our answer is almost what’s, what makes you the most happy? Build that. And that’s kind of how we keep it fresh. If you’re excited to build the feature, you want it for yourself, you’re to go into the nitty gritty. You’re going to figure out all the details.

You’re going to make it fast. You’re going to make it beautiful. You’re going to make it useful. And it makes it much easier.


id: interviews/dennis-brotzky/emil-22-16
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (22:16)
url_path: /lessons/interviews/dennis-brotzky/#emil-22-16
markdown_path: /md/sections/interviews/dennis-brotzky/emil-22-16.md
excerpt:
Yeah, that’s true. When you are excited about something, then like you said, you go into the, to the, to the details and everything and it gets much better. yeah, that’s why, why I like, like building open source stuff as well. Cause I, I get to like, you know, come up with how, with everything and I only will build something if I’m excited about it because it’s my free time and I could be doing anything else. but yeah, but that’s, yeah.

body:
Yeah, that’s true. When you are excited about something, then like you said, you go into the, to the, to the details and everything and it gets much better. yeah, that’s why, why I like, like building open source stuff as well. Cause I, I get to like, you know, come up with how, with everything and I only will build something if I’m excited about it because it’s my free time and I could be doing anything else. but yeah, but that’s, yeah.


id: interviews/dennis-brotzky/dennis-brotzky-22-48
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (22:48)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-22-48
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-22-48.md
excerpt:
You could be doing anything. Exactly. That’s where the magic happens though. Like when you have that excitement, you have this idea and you kind of have like a vague idea of how to implement it. You’re not a hundred percent sure, but you can kind of see all the parts in your head and you just go for it. And that’s where our best features have been shipped. That’s how we stay happy. And that’s how we’ve been working on FACE so long.

body:
You could be doing anything. Exactly. That’s where the magic happens though. Like when you have that excitement, you have this idea and you kind of have like a vague idea of how to implement it. You’re not a hundred percent sure, but you can kind of see all the parts in your head and you just go for it. And that’s where our best features have been shipped. That’s how we stay happy. And that’s how we’ve been working on FACE so long.


id: interviews/dennis-brotzky/emil-23-16
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (23:16)
url_path: /lessons/interviews/dennis-brotzky/#emil-23-16
markdown_path: /md/sections/interviews/dennis-brotzky/emil-23-16.md
excerpt:
Yeah, I think you’re in a very unique position. know, the team being so small and you all having so much influence on the product and getting to work on the stuff you really enjoy.

body:
Yeah, I think you’re in a very unique position. know, the team being so small and you all having so much influence on the product and getting to work on the stuff you really enjoy.


id: interviews/dennis-brotzky/dennis-brotzky-23-24
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (23:24)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-23-24
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-23-24.md
excerpt:
Yeah. And, and that’s exactly what can answer your question of why our team is only three people. Because you only have one life. You might as well be having fun with your best friends, building a product you find exciting, doing what makes you happy. And that, that really is why we’re, three people.

body:
Yeah. And, and that’s exactly what can answer your question of why our team is only three people. Because you only have one life. You might as well be having fun with your best friends, building a product you find exciting, doing what makes you happy. And that, that really is why we’re, three people.


id: interviews/dennis-brotzky/emil-23-33
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (23:33)
url_path: /lessons/interviews/dennis-brotzky/#emil-23-33
markdown_path: /md/sections/interviews/dennis-brotzky/emil-23-33.md
excerpt:
Yeah, this course is about animations and people really like animations. You have posted a few stuff related to motion on your Twitter feed as well. How do you guys think about, let’s start with that. How do you guys think about motion animations at Fe? Is it something that should be built to compliment the rest or what’s the function of it at Fe?

body:
Yeah, this course is about animations and people really like animations. You have posted a few stuff related to motion on your Twitter feed as well. How do you guys think about, let’s start with that. How do you guys think about motion animations at Fe? Is it something that should be built to compliment the rest or what’s the function of it at Fe?


id: interviews/dennis-brotzky/dennis-brotzky-24-25
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (24:25)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-24-25
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-24-25.md
excerpt:
It always has to enhance the product. It has to enhance the experience. It has to make it delightful to use. And that’s the primary driver of whenever we add motion. we just added in Fade 2.0, we added the dock. And that has a ton of little details of how fast it opens, the blur, it’s resizing, and it just feels really good to use.

body:
It always has to enhance the product. It has to enhance the experience. It has to make it delightful to use. And that’s the primary driver of whenever we add motion. we just added in Fade 2.0, we added the dock. And that has a ton of little details of how fast it opens, the blur, it’s resizing, and it just feels really good to use.

And that enhances the product. makes people excited to use it. And that’s where we like to add in motion. also makes the product feel more alive. I think if everything is just super static, it feels almost too boring to me. I like to have motion to like indicate where things are going. I know like linear, for example, they had their composer box. It was like go into the side and it would teach people, hey, that’s where your draft is. Follow the UI. That’s a great example of motion. We have a few things like that in Fey. And in that sense, it’s amazing.


id: interviews/dennis-brotzky/emil-25-42
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (25:42)
url_path: /lessons/interviews/dennis-brotzky/#emil-25-42
markdown_path: /md/sections/interviews/dennis-brotzky/emil-25-42.md
excerpt:
And when it comes to more complex motion, you mentioned Thiago designing like three states, for example, is that usually what happens? He designs like static states and you like come up with how it should animate to those states or?

body:
And when it comes to more complex motion, you mentioned Thiago designing like three states, for example, is that usually what happens? He designs like static states and you like come up with how it should animate to those states or?


id: interviews/dennis-brotzky/dennis-brotzky-26-02
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (26:02)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-26-02
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-26-02.md
excerpt:
Yeah, he gives a rough draft, I would say. He’ll use After Effects or just static states in Figma. And then it really is up to me to like implement it, make it feel nice. Like we just released a landing page, our new portfolio landing page that had like this expanding cards kind of springing into motion. And for that, he just put one little card and then he put like six cards stacked on top of each other. And I was like, okay, they should come up like this. But obviously it’s really hard to recreate that springiness in Figma or any tool. So it’s up to me to implement it, make it feel right. And that’s what I did. That’s what I do.

body:
Yeah, he gives a rough draft, I would say. He’ll use After Effects or just static states in Figma. And then it really is up to me to like implement it, make it feel nice. Like we just released a landing page, our new portfolio landing page that had like this expanding cards kind of springing into motion. And for that, he just put one little card and then he put like six cards stacked on top of each other. And I was like, okay, they should come up like this. But obviously it’s really hard to recreate that springiness in Figma or any tool. So it’s up to me to implement it, make it feel right. And that’s what I did. That’s what I do.


id: interviews/dennis-brotzky/emil-26-52
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (26:52)
url_path: /lessons/interviews/dennis-brotzky/#emil-26-52
markdown_path: /md/sections/interviews/dennis-brotzky/emil-26-52.md
excerpt:
And how, how do you do use, if we get more technical, do you use frame of motion or I don’t know, CSS animations? How do you usually, what do you usually use? What’s your like stack?

body:
And how, how do you do use, if we get more technical, do you use frame of motion or I don’t know, CSS animations? How do you usually, what do you usually use? What’s your like stack?


id: interviews/dennis-brotzky/dennis-brotzky-27-06
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (27:06)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-27-06
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-27-06.md
excerpt:
My stack, I use React Spring just because I have been using it for years and years and years. It’s pretty much the same as framer motion or motion nowadays. It’s all spring based. I looked up both APIs. Motion is definitely the one to pick if you’re gonna do it now. But back when I started down that path, they were kind of neck and neck or framer motion was a very new, would recommend that one definitely. But I really think The secret to any smooth feeling UI, don’t use CSS transitions. Use a library that uses springs under the hood. That’s really what’s going to make it feel like butter. If you’re wondering like, oh, why is my, my button or whatever you’re working on, it doesn’t feel quite as smooth as this other thing. It’s probably because that other thing is using a spring underneath instead of a Bezier curve or just some other thing.

body:
My stack, I use React Spring just because I have been using it for years and years and years. It’s pretty much the same as framer motion or motion nowadays. It’s all spring based. I looked up both APIs. Motion is definitely the one to pick if you’re gonna do it now. But back when I started down that path, they were kind of neck and neck or framer motion was a very new, would recommend that one definitely. But I really think The secret to any smooth feeling UI, don’t use CSS transitions. Use a library that uses springs under the hood. That’s really what’s going to make it feel like butter. If you’re wondering like, oh, why is my, my button or whatever you’re working on, it doesn’t feel quite as smooth as this other thing. It’s probably because that other thing is using a spring underneath instead of a Bezier curve or just some other thing.


id: interviews/dennis-brotzky/emil-28-16
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (28:16)
url_path: /lessons/interviews/dennis-brotzky/#emil-28-16
markdown_path: /md/sections/interviews/dennis-brotzky/emil-28-16.md
excerpt:
I definitely agree that I guess everything looks bad. Everything is a big word, but I think, know, very, very a lot of things look better with the spring curve. I think there are a few cases. are definitely like Sonar. I think I like the animation. I made it, but you know, I like the transition that Sonar has. It’s just the ease type easing, just the default ease.

body:
I definitely agree that I guess everything looks bad. Everything is a big word, but I think, know, very, very a lot of things look better with the spring curve. I think there are a few cases. are definitely like Sonar. I think I like the animation. I made it, but you know, I like the transition that Sonar has. It’s just the ease type easing, just the default ease.


id: interviews/dennis-brotzky/emil-28-48
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (28:48)
url_path: /lessons/interviews/dennis-brotzky/#emil-28-48
markdown_path: /md/sections/interviews/dennis-brotzky/emil-28-48.md
excerpt:
Yeah, but you know, I think it’s almost always better to use a spring. I wouldn’t say evreything looks... like transition, looks bad, but it’s definitely easier to make something look nice with a animation.

body:
Yeah, but you know, I think it’s almost always better to use a spring. I wouldn’t say evreything looks... like transition, looks bad, but it’s definitely easier to make something look nice with a animation.


id: interviews/dennis-brotzky/dennis-brotzky-29-04
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (29:04)
excerpt: Yeah, I agree.
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-29-04
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-29-04.md
body:
Yeah, I agree.

I think, especially if there’s motion, the spring will just take it to the next level. Like if you’re just doing a hover and changing opacity or color or something like that. Sure. She says transition is fine. But if you can put a spring into it, you’ll be very surprised how much better it feels. I know Sonar, for example, just uses ease, which is my favorite default transition.

And it makes a lot of sense if you’re going to do like a lighter weight library that everybody’s going to use. You might not want to add in a whole another library into your library.


id: interviews/dennis-brotzky/emil-29-34
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (29:34)
url_path: /lessons/interviews/dennis-brotzky/#emil-29-34
markdown_path: /md/sections/interviews/dennis-brotzky/emil-29-34.md
excerpt:
Yeah, and it’s a very small animation, know, like it’s just, it definitely adds to the smoothness, but it’s not a very complex animation or something like that. I think, and it’s not as big, I think you see more difference in bigger sort of movements as well. Not a very tiny one. Well, don’t know, Dynamic Island definitely looks way better with spring animations. But yeah.

body:
Yeah, and it’s a very small animation, know, like it’s just, it definitely adds to the smoothness, but it’s not a very complex animation or something like that. I think, and it’s not as big, I think you see more difference in bigger sort of movements as well. Not a very tiny one. Well, don’t know, Dynamic Island definitely looks way better with spring animations. But yeah.


id: interviews/dennis-brotzky/dennis-brotzky-30-14
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (30:14)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-30-14
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-30-14.md
excerpt:
Exactly. That’s a perfect example. Pretty much any of the iOS stuff would not be possible with CSS transitions.

body:
Exactly. That’s a perfect example. Pretty much any of the iOS stuff would not be possible with CSS transitions.


id: interviews/dennis-brotzky/emil-30-25
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (30:25)
url_path: /lessons/interviews/dennis-brotzky/#emil-30-25
markdown_path: /md/sections/interviews/dennis-brotzky/emil-30-25.md
excerpt:
I mean, it is not possible with CSS transitions, you know, and a lot of CSS stuff is not interruptible, which is also a big thing that I don’t think many people talk about like page view transitions API is not interruptible, which to me is a, it’s a big thing when it comes to like making something feel good. If I use key frames for sonar, yeah, go ahead.

body:
I mean, it is not possible with CSS transitions, you know, and a lot of CSS stuff is not interruptible, which is also a big thing that I don’t think many people talk about like page view transitions API is not interruptible, which to me is a, it’s a big thing when it comes to like making something feel good. If I use key frames for sonar, yeah, go ahead.


id: interviews/dennis-brotzky/dennis-brotzky-30-27
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (30:27)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-30-27
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-30-27.md
excerpt:
Honestly, bit of hot take, but I find almost everything implemented on the web just doesn’t feel that great. Like all the base APIs view transition. If you’re comparing it to iOS or Mac OS, these just, they’re so bad. Like I, have a new narrative.co website and built. It’s not released yet. It’s kind of been on the back burner because we have so much stuff to build, but, I use the view transition API there and it’s just. You can feel like an engineer implemented the API to kind of recreate the feeling or the functionality, but like the feel isn’t there and all the details. it’s that’s the very frustrating part about the web is it’s very limiting and you can’t always do what’s in your head. and maybe you can, but then it doesn’t work on a different browser or it drops a ton of frames. That aspect is very annoying.

body:
Honestly, bit of hot take, but I find almost everything implemented on the web just doesn’t feel that great. Like all the base APIs view transition. If you’re comparing it to iOS or Mac OS, these just, they’re so bad. Like I, have a new narrative.co website and built. It’s not released yet. It’s kind of been on the back burner because we have so much stuff to build, but, I use the view transition API there and it’s just. You can feel like an engineer implemented the API to kind of recreate the feeling or the functionality, but like the feel isn’t there and all the details. it’s that’s the very frustrating part about the web is it’s very limiting and you can’t always do what’s in your head. and maybe you can, but then it doesn’t work on a different browser or it drops a ton of frames. That aspect is very annoying.


id: interviews/dennis-brotzky/emil-31-57
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (31:57)
url_path: /lessons/interviews/dennis-brotzky/#emil-31-57
markdown_path: /md/sections/interviews/dennis-brotzky/emil-31-57.md
excerpt:
Yeah, which is not to say that like, you know, view transition API or whatever is bad. It’s great that we get closer to like iOS like experiences, but it’s also what’s kinda, well, even if the technology was here, like it’s hard to, because you interact with your phone with your like finger, you know, and it’s much more intuitive and it just feels better because there’s like less.

body:
Yeah, which is not to say that like, you know, view transition API or whatever is bad. It’s great that we get closer to like iOS like experiences, but it’s also what’s kinda, well, even if the technology was here, like it’s hard to, because you interact with your phone with your like finger, you know, and it’s much more intuitive and it just feels better because there’s like less.

sort of layers between you and the phone. And here you have a mouse which feels kind of disconnected from the screen. So I don’t think, like, I really like gestures on iOS. And I made vol, which feels nice on the phone, like on the web it’s kind of useless or kind of weird to use with a mouse. But I agree with you that we, iOS has a lot of, Swift has a lot of.

It’s also way easier to create animations they have built things to make that process way easier.


id: interviews/dennis-brotzky/dennis-brotzky-33-03
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (33:03)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-33-03
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-33-03.md
excerpt:
The most pain I know I’ll be in is when Thiago comes and he’s like, check out this animation I want you to implement. And it’s like something default iOS. I’m just like, this is not going to work on the web. It’s just not going to feel the same. Yeah. I’m very jealous of that aspect of Swift.

body:
The most pain I know I’ll be in is when Thiago comes and he’s like, check out this animation I want you to implement. And it’s like something default iOS. I’m just like, this is not going to work on the web. It’s just not going to feel the same. Yeah. I’m very jealous of that aspect of Swift.


id: interviews/dennis-brotzky/dennis-brotzky-33-30
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (33:30)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-33-30
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-33-30.md
excerpt:
Yeah, when SwiftUI came out, I gave it another shot. I just can’t deal with the feedback loop. It takes too long to compile everything. You have to set up Xcode. And it was just too slow for me. I’m too used to the web.

body:
Yeah, when SwiftUI came out, I gave it another shot. I just can’t deal with the feedback loop. It takes too long to compile everything. You have to set up Xcode. And it was just too slow for me. I’m too used to the web.


id: interviews/dennis-brotzky/emil-33-51
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (33:51)
url_path: /lessons/interviews/dennis-brotzky/#emil-33-51
markdown_path: /md/sections/interviews/dennis-brotzky/emil-33-51.md
excerpt:
I’ve never, maybe I should do it at some point, but I’m curious how that is, but maybe that. So, yeah, I don’t know. did watch some, how do you call it? Like Apple’s presentations about the stuff that they release for it. It all seems very nice. It seems like they also have like presets for spring animations. I feel like built in like ease and ease in out, but then for, you know, spring animations type of thing. so you use almost exclusively spring animations then, unless it’s a hover background change or something.

body:
I’ve never, maybe I should do it at some point, but I’m curious how that is, but maybe that. So, yeah, I don’t know. did watch some, how do you call it? Like Apple’s presentations about the stuff that they release for it. It all seems very nice. It seems like they also have like presets for spring animations. I feel like built in like ease and ease in out, but then for, you know, spring animations type of thing. so you use almost exclusively spring animations then, unless it’s a hover background change or something.


id: interviews/dennis-brotzky/dennis-brotzky-34-30
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (34:30)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-34-30
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-34-30.md
excerpt:
Yeah, if it’s a key component on a marketing landing page or in-fe, it’s almost always using a spring. It just feels so much smoother to me. That’s all it comes down to.

body:
Yeah, if it’s a key component on a marketing landing page or in-fe, it’s almost always using a spring. It just feels so much smoother to me. That’s all it comes down to.


id: interviews/dennis-brotzky/emil-34-47
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (34:47)
url_path: /lessons/interviews/dennis-brotzky/#emil-34-47
markdown_path: /md/sections/interviews/dennis-brotzky/emil-34-47.md
excerpt:
And do you have like a few spring configs that you use or do you create a spring specifically for a specific animation?

body:
And do you have like a few spring configs that you use or do you create a spring specifically for a specific animation?


id: interviews/dennis-brotzky/dennis-brotzky-35-03
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (35:03)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-35-03
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-35-03.md
excerpt:
Man, the amount of time I’ve fiddling with and tweaking springs is insane. So unless it’s the same component, it will have its own custom spring configuration. So intention, own friction, own mass and all that stuff. And I will obsess over that. I’ll change it by a hundred, change it by 10, change it by one take a recording of it, record my screen, play the animation, open the recording, go through the recording frame by frame by frame, see if there’s any weirdness going on, and then I’ll adjust it from there. Like for the dock on Fey.

body:
Man, the amount of time I’ve fiddling with and tweaking springs is insane. So unless it’s the same component, it will have its own custom spring configuration. So intention, own friction, own mass and all that stuff. And I will obsess over that. I’ll change it by a hundred, change it by 10, change it by one take a recording of it, record my screen, play the animation, open the recording, go through the recording frame by frame by frame, see if there’s any weirdness going on, and then I’ll adjust it from there. Like for the dock on Fey.

You should have seen my desktop. had maybe 200 recordings of videos of like each frame of elements going in and out, blurring the timing of like stuff overlapping the springiness of when it closes and all those details. So yeah, definitely hand crafting each config for each element.


id: interviews/dennis-brotzky/emil-36-17
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (36:17)
url_path: /lessons/interviews/dennis-brotzky/#emil-36-17
markdown_path: /md/sections/interviews/dennis-brotzky/emil-36-17.md
excerpt:
And do you, when you adjust the config, do you just do it and you kind of know what this change will do? Or do you have like a visualizer for, for this or, or do you just do it and see what happens?

body:
And do you, when you adjust the config, do you just do it and you kind of know what this change will do? Or do you have like a visualizer for, for this or, or do you just do it and see what happens?


id: interviews/dennis-brotzky/dennis-brotzky-36-34
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (36:34)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-36-34
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-36-34.md
excerpt:
No, think just over the years, you learn how it’s going to behave if you add more tension, if you add more friction, if you make the mass heavier, and you have a vision in your head, you’ll have a rough idea. But then just getting it all to work together. I just tweak it, refresh the page, replay it, tweak it, refresh the page, and on and on and on.

body:
No, think just over the years, you learn how it’s going to behave if you add more tension, if you add more friction, if you make the mass heavier, and you have a vision in your head, you’ll have a rough idea. But then just getting it all to work together. I just tweak it, refresh the page, replay it, tweak it, refresh the page, and on and on and on.


id: interviews/dennis-brotzky/emil-36-42
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (36:42)
url_path: /lessons/interviews/dennis-brotzky/#emil-36-42
markdown_path: /md/sections/interviews/dennis-brotzky/emil-36-42.md
excerpt:
Yeah, that’s nice. good that you say that you record animation. That’s one of the tips from the course that I gave, because I do record my animations as well and replay them frame by frame. Just like you said, I think that’s really useful.

body:
Yeah, that’s nice. good that you say that you record animation. That’s one of the tips from the course that I gave, because I do record my animations as well and replay them frame by frame. Just like you said, I think that’s really useful.


id: interviews/dennis-brotzky/dennis-brotzky-37-15
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (37:15)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-37-15
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-37-15.md
excerpt:
I think their DevTool has some sort of animation playback. To be honest, I’ve never really used it. I just find it easier to just do a quick screen recording.

body:
I think their DevTool has some sort of animation playback. To be honest, I’ve never really used it. I just find it easier to just do a quick screen recording.


id: interviews/dennis-brotzky/emil-37-28
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (37:28)
url_path: /lessons/interviews/dennis-brotzky/#emil-37-28
markdown_path: /md/sections/interviews/dennis-brotzky/emil-37-28.md
excerpt:
The dev tools in Chrome, mean, or the motion dev tools? Yeah, I think in Chrome, it’s just like for key frames, I’m pretty sure, or CSS transitions, not for the custom stuff. But I’m pretty sure like motion, you are using a React Spring, so it’s different, but motion, I think has a Chrome extension that also lets you run it frame by frame. But I think a recording, even though we...

body:
The dev tools in Chrome, mean, or the motion dev tools? Yeah, I think in Chrome, it’s just like for key frames, I’m pretty sure, or CSS transitions, not for the custom stuff. But I’m pretty sure like motion, you are using a React Spring, so it’s different, but motion, I think has a Chrome extension that also lets you run it frame by frame. But I think a recording, even though we...


id: interviews/dennis-brotzky/emil-37-58
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (37:58)
url_path: /lessons/interviews/dennis-brotzky/#emil-37-58
markdown_path: /md/sections/interviews/dennis-brotzky/emil-37-58.md
excerpt:
We maybe do have something like this in the browser. A recording is more, I don’t know, realistic or it might be placebo, but I like to see the actual thing, how it behaves on an actual, I don’t know, record, how, how someone would actually see it. Maybe it’s one-to-one with a DevTools thing, but I don’t know. No.

body:
We maybe do have something like this in the browser. A recording is more, I don’t know, realistic or it might be placebo, but I like to see the actual thing, how it behaves on an actual, I don’t know, record, how, how someone would actually see it. Maybe it’s one-to-one with a DevTools thing, but I don’t know. No.


id: interviews/dennis-brotzky/dennis-brotzky-38-31
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (38:31)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-38-31
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-38-31.md
excerpt:
If it’s a key feature or if it’s like the hero over landing page or it’s something that’s going to be used a ton. Yeah. I record it like crazy. It’s just the easiest way to see everything. Cause if you can go frame by frame and like, it’s really easy to go back and forth. Highly recommend recording it and tweaking it from there.

body:
If it’s a key feature or if it’s like the hero over landing page or it’s something that’s going to be used a ton. Yeah. I record it like crazy. It’s just the easiest way to see everything. Cause if you can go frame by frame and like, it’s really easy to go back and forth. Highly recommend recording it and tweaking it from there.


id: interviews/dennis-brotzky/emil-39-01
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (39:01)
url_path: /lessons/interviews/dennis-brotzky/#emil-39-01
markdown_path: /md/sections/interviews/dennis-brotzky/emil-39-01.md
excerpt:
Yeah. You see things that you wouldn’t or you can also record things that you think look good made by other people to see, you to, kind of learn as well, I guess, even if it’s the iOS animations that we talked about, I think that’s a great way to learn as well.

body:
Yeah. You see things that you wouldn’t or you can also record things that you think look good made by other people to see, you to, kind of learn as well, I guess, even if it’s the iOS animations that we talked about, I think that’s a great way to learn as well.


id: interviews/dennis-brotzky/dennis-brotzky-39-19
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (39:19)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-39-19
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-39-19.md
excerpt:
Yeah, for I’ll, the amount of time that I have spent on my phone, just like dragging my finger very slowly, opening apps and like moving them with my finger, doing gestures to like fully understand how elements are blurred in, where they’re moving, where they’re coming from, the speed of stuff.

body:
Yeah, for I’ll, the amount of time that I have spent on my phone, just like dragging my finger very slowly, opening apps and like moving them with my finger, doing gestures to like fully understand how elements are blurred in, where they’re moving, where they’re coming from, the speed of stuff.

Or just testing the limits of like, I wonder if they thought of what if I hold my finger down, move it all the way and still have my finger down and then keep doing that over and over and over. And like the animation just keeps going, going, going, going, going, going, I’ve obsessed a lot over how iOS works, all the animations and the fluidity of all of it.


id: interviews/dennis-brotzky/emil-40-00
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (40:00)
url_path: /lessons/interviews/dennis-brotzky/#emil-40-00
markdown_path: /md/sections/interviews/dennis-brotzky/emil-40-00.md
excerpt:
What’s like the biggest surprise or what’s the biggest... Maybe you found something that is repeated in all animations that makes it look good. I don’t know. What’s like the biggest surprise or something?

body:
What’s like the biggest surprise or what’s the biggest... Maybe you found something that is repeated in all animations that makes it look good. I don’t know. What’s like the biggest surprise or something?


id: interviews/dennis-brotzky/dennis-brotzky-40-29
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (40:29)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-40-29
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-40-29.md
excerpt:
Biggest surprise, I would say is how simple a lot of the things are. Like I really liked the, how you can just close an app. You just flick up and I was always, when you see it in real time, you don’t realize just like the moment where the app changes to the icon. It’s very simple, but in real time, it just feels so good. The way they have like the app turning into the icon. I spent a lot of time. I thought there was some crazy secret sauce behind it, but it’s just in the end, it’s very simple. It’s a good spring curve on it and it feels really nice.

body:
Biggest surprise, I would say is how simple a lot of the things are. Like I really liked the, how you can just close an app. You just flick up and I was always, when you see it in real time, you don’t realize just like the moment where the app changes to the icon. It’s very simple, but in real time, it just feels so good. The way they have like the app turning into the icon. I spent a lot of time. I thought there was some crazy secret sauce behind it, but it’s just in the end, it’s very simple. It’s a good spring curve on it and it feels really nice.


id: interviews/dennis-brotzky/emil-41-41
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (41:41)
url_path: /lessons/interviews/dennis-brotzky/#emil-41-41
markdown_path: /md/sections/interviews/dennis-brotzky/emil-41-41.md
excerpt:
What’s like an example of good web UI. Cause Fey is considered, you know, I see Fey in like the same category as, you know, linear is a very well built software or stuff. And I think to build something like this, you have to have good taste, obviously. And what makes a good web UI to you? Have you seen something that you are impressed by? For example, yeah, I’m curious about that. Maybe you have some other app that you look up to or, you know.

body:
What’s like an example of good web UI. Cause Fey is considered, you know, I see Fey in like the same category as, you know, linear is a very well built software or stuff. And I think to build something like this, you have to have good taste, obviously. And what makes a good web UI to you? Have you seen something that you are impressed by? For example, yeah, I’m curious about that. Maybe you have some other app that you look up to or, you know.


id: interviews/dennis-brotzky/dennis-brotzky-42-23
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (42:23)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-42-23
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-42-23.md
excerpt:
Yeah, I don’t have any secret app that I look up to. I think to be frank, like most web apps are shit. the biggest thing about the web that I hate is jank and like when stuff moves around. So when something loads in elements move around and they go in different places, I absolutely hate loading skeletons. I think that was.

body:
Yeah, I don’t have any secret app that I look up to. I think to be frank, like most web apps are shit. the biggest thing about the web that I hate is jank and like when stuff moves around. So when something loads in elements move around and they go in different places, I absolutely hate loading skeletons. I think that was.

One of Facebook’s worst creations when they added the loading skeleton. just like ruined the web experience in my opinion. Linear is definitely well-crafted. It’s all on the client, super smooth, things don’t move around. We try and do the same in Fe, although we don’t have the same sync engine and full client side, but we do a lot of preloading to make it feel very seamless.

When you open a page, things don’t move around. There aren’t loaders. And if you can achieve that, I think you’ll be in the top, top 1 % of web apps. What you want to avoid is...

If someone clicks on your page, either a ton of spinners or a ton of skeletons appear, I think that completely ruins the experience. What you want to do is try and have the data already there. And as soon as the person clicks that button, it just loads in and the page doesn’t shift around.


id: interviews/dennis-brotzky/emil-43-57
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (43:57)
url_path: /lessons/interviews/dennis-brotzky/#emil-43-57
markdown_path: /md/sections/interviews/dennis-brotzky/emil-43-57.md
excerpt:
What happens on the initial load then? Because that’s, there was no chance to load something before the initial load when you come into the page for the first time, like from a new tab or something.

body:
What happens on the initial load then? Because that’s, there was no chance to load something before the initial load when you come into the page for the first time, like from a new tab or something.


id: interviews/dennis-brotzky/dennis-brotzky-44-11
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (44:11)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-44-11
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-44-11.md
excerpt:
Yeah. The initial, what do you mean? Like when you’re already in the app, preload it. But if you’re just going to Fade.com for example, yeah, okay. We have, we don’t show a skeleton. just show the shapes and they stay where they are with the data. I think that’s a huge aspect. If that makes sense.

body:
Yeah. The initial, what do you mean? Like when you’re already in the app, preload it. But if you’re just going to Fade.com for example, yeah, okay. We have, we don’t show a skeleton. just show the shapes and they stay where they are with the data. I think that’s a huge aspect. If that makes sense.


id: interviews/dennis-brotzky/dennis-brotzky-44-42
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (44:42)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-44-42
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-44-42.md
excerpt:
I, yeah, to me, if you can master that, if you can figure out how to get your page to load in and say you do want to put a skeleton or something, make sure the content when it loads in is where it’s supposed to be. And it doesn’t like shift around. I think, that’ll make your experience way better.

body:
I, yeah, to me, if you can master that, if you can figure out how to get your page to load in and say you do want to put a skeleton or something, make sure the content when it loads in is where it’s supposed to be. And it doesn’t like shift around. I think, that’ll make your experience way better.

Do get what I mean?


id: interviews/dennis-brotzky/emil-45-08
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (45:08)
url_path: /lessons/interviews/dennis-brotzky/#emil-45-08
markdown_path: /md/sections/interviews/dennis-brotzky/emil-45-08.md
excerpt:
No, yeah, I get that. It’s just like at Vercel. did use skeletons as well. There is a lot of dynamic content and you know, like the width of some stuff, the width of the team’s name, for example, we don’t know how wide it’s going to be. So it has to shift horizontally, not vertically, which is not as bad. But like if you use skeletons, I think there is no way to do it 100% accurately, you know.

body:
No, yeah, I get that. It’s just like at Vercel. did use skeletons as well. There is a lot of dynamic content and you know, like the width of some stuff, the width of the team’s name, for example, we don’t know how wide it’s going to be. So it has to shift horizontally, not vertically, which is not as bad. But like if you use skeletons, I think there is no way to do it 100% accurately, you know.


id: interviews/dennis-brotzky/dennis-brotzky-45-43
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (45:43)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-45-43
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-45-43.md
excerpt:
I think, designed it in a way where it’ll work for any team name would be my solution to that. To me, that’s like an engineering solution thinking first of like, we have to put the team name there. we have to put a skeleton there because we don’t know it beforehand. Whereas a designer might think of, well, how can we approach this differently where we show something else other than the team name, maybe like an icon or something standard or we somehow do something. I can’t think of a solution off the top of my head, but that’s when having a really good designer helping you one-on-one goes a long way.

body:
I think, designed it in a way where it’ll work for any team name would be my solution to that. To me, that’s like an engineering solution thinking first of like, we have to put the team name there. we have to put a skeleton there because we don’t know it beforehand. Whereas a designer might think of, well, how can we approach this differently where we show something else other than the team name, maybe like an icon or something standard or we somehow do something. I can’t think of a solution off the top of my head, but that’s when having a really good designer helping you one-on-one goes a long way.


id: interviews/dennis-brotzky/emil-46-25
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (46:25)
url_path: /lessons/interviews/dennis-brotzky/#emil-46-25
markdown_path: /md/sections/interviews/dennis-brotzky/emil-46-25.md
excerpt:
Yeah. But I agree that not having skeletons at all, like makes the process way, way more easier because skeletons themselves take a lot of time and you have to update them when the design is slightly updated and everything. There is a lot of solid, like it’s way easier to, and it’s also way easier to build from linear perspective. For example, it’s easier to build new stuff in linear because it’s built differently. You don’t have to think about. Things like skeletons, for example.

body:
Yeah. But I agree that not having skeletons at all, like makes the process way, way more easier because skeletons themselves take a lot of time and you have to update them when the design is slightly updated and everything. There is a lot of solid, like it’s way easier to, and it’s also way easier to build from linear perspective. For example, it’s easier to build new stuff in linear because it’s built differently. You don’t have to think about. Things like skeletons, for example.


id: interviews/dennis-brotzky/dennis-brotzky-46-54
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (46:54)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-46-54
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-46-54.md
excerpt:
Yeah, data is, imagine if you didn’t have to think of the data loading in. From what I understand, linear does a lot of the caching upfront. So maybe the first time you load it. But I think they’ve gotten even smarter about that, where they’re breaking it up into different chunks, prioritizing. I’m sure they’re prioritizing the page you’re on. I’m sure they do a lot to make sure it feels super seamless.

body:
Yeah, data is, imagine if you didn’t have to think of the data loading in. From what I understand, linear does a lot of the caching upfront. So maybe the first time you load it. But I think they’ve gotten even smarter about that, where they’re breaking it up into different chunks, prioritizing. I’m sure they’re prioritizing the page you’re on. I’m sure they do a lot to make sure it feels super seamless.


id: interviews/dennis-brotzky/emil-47-23
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (47:23)
url_path: /lessons/interviews/dennis-brotzky/#emil-47-23
markdown_path: /md/sections/interviews/dennis-brotzky/emil-47-23.md
excerpt:
Yeah, but you’re right, the only thing that could load a while or longer than instantly is the initial load. But everything after that is basically instant.

body:
Yeah, but you’re right, the only thing that could load a while or longer than instantly is the initial load. But everything after that is basically instant.


id: interviews/dennis-brotzky/dennis-brotzky-47-23
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (47:23)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-47-23
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-47-23.md
excerpt:
And it makes perfect sense for products like theirs that you always have open. It’s always on your desktop. You’re always going back to it back and forth. So, yeah, it’s good design and engineering from them.

body:
And it makes perfect sense for products like theirs that you always have open. It’s always on your desktop. You’re always going back to it back and forth. So, yeah, it’s good design and engineering from them.


id: interviews/dennis-brotzky/emil-47-44
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (47:44)
url_path: /lessons/interviews/dennis-brotzky/#emil-47-44
markdown_path: /md/sections/interviews/dennis-brotzky/emil-47-44.md
excerpt:
Yeah. All right, There are a lot of people listening that either might have a job or are trying to get one. And even if they have, maybe they want to get better at what they’re doing or even switch jobs. Do you have any advice for people that are listening to? To that are trying to get better at mainly animations, but you know, just engineering or even design in general, any advice, something that you wish you have done earlier or things like that.

body:
Yeah. All right, There are a lot of people listening that either might have a job or are trying to get one. And even if they have, maybe they want to get better at what they’re doing or even switch jobs. Do you have any advice for people that are listening to? To that are trying to get better at mainly animations, but you know, just engineering or even design in general, any advice, something that you wish you have done earlier or things like that.


id: interviews/dennis-brotzky/dennis-brotzky-48-22
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (48:22)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-48-22
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-48-22.md
excerpt:
I think a big reason why I’m able to do what I do is because I really do enjoy it. I know it’s a very, maybe cliche answer, but if you’re not excited to be doing animations or getting better at engineering or getting better at design, maybe evaluate yourself first and figure out what you want to do. Because when you’re excited to build something, when you obsess over a hundred recordings of a single interaction or

body:
I think a big reason why I’m able to do what I do is because I really do enjoy it. I know it’s a very, maybe cliche answer, but if you’re not excited to be doing animations or getting better at engineering or getting better at design, maybe evaluate yourself first and figure out what you want to do. Because when you’re excited to build something, when you obsess over a hundred recordings of a single interaction or

You can’t wait to read the next design thing or you open FigMine, you’re super excited. You want to design something, you want to build something. Then everything just becomes easy. And I think people that are excited and passionate, they attract very similar people. and then that makes it very easy to find your next opportunity, your next job, whatever it is.


id: interviews/dennis-brotzky/emil-49-23
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (49:23)
url_path: /lessons/interviews/dennis-brotzky/#emil-49-23
markdown_path: /md/sections/interviews/dennis-brotzky/emil-49-23.md
excerpt:
Yeah, I think I agree. I think that’s the hardest part of finding a thing you enjoy doing very, very much. And then after that, it goes naturally and you build beautiful things.

body:
Yeah, I think I agree. I think that’s the hardest part of finding a thing you enjoy doing very, very much. And then after that, it goes naturally and you build beautiful things.


id: interviews/dennis-brotzky/emil-49-44
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (49:44)
url_path: /lessons/interviews/dennis-brotzky/#emil-49-44
markdown_path: /md/sections/interviews/dennis-brotzky/emil-49-44.md
excerpt:
But yeah, I think that’s it. Thank you for your time and great answers. know, Fey is a great product and everyone listening should at least check the site out and maybe even the product. I think you guys are only in the US. Like I’m pretty sure I cannot use it in Europe.

body:
But yeah, I think that’s it. Thank you for your time and great answers. know, Fey is a great product and everyone listening should at least check the site out and maybe even the product. I think you guys are only in the US. Like I’m pretty sure I cannot use it in Europe.


id: interviews/dennis-brotzky/dennis-brotzky-50-09
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (50:09)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-50-09
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-50-09.md
excerpt:
Yeah, we focused on the US. We’re all Canadians ourselves. But US data is the most readily available. It’s the biggest market. So we’re focused on the US, but we have a lot of people asking for Europe. So we’ll see one day.

body:
Yeah, we focused on the US. We’re all Canadians ourselves. But US data is the most readily available. It’s the biggest market. So we’re focused on the US, but we have a lot of people asking for Europe. So we’ll see one day.


id: interviews/dennis-brotzky/emil-50-25
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (50:25)
excerpt: Yeah.
url_path: /lessons/interviews/dennis-brotzky/#emil-50-25
markdown_path: /md/sections/interviews/dennis-brotzky/emil-50-25.md
body:
Yeah.

Would be great even to, for people to, you know, as an inspiration even, let alone the usefulness of the product itself.


id: interviews/dennis-brotzky/dennis-brotzky-50-36
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Dennis Brotzky (50:36)
url_path: /lessons/interviews/dennis-brotzky/#dennis-brotzky-50-36
markdown_path: /md/sections/interviews/dennis-brotzky/dennis-brotzky-50-36.md
excerpt:
Honestly, we have a lot of designers and engineers and just people in the industry. They go sign up, use Fey and then they’ll counsel and we always ask for feedback. And then it’s always like, I just wanted to interact with the app. I just wanted to learn about how this was designed. I wanted to give it a, give it a try myself and learn something new. We get that a lot. Yeah.

body:
Honestly, we have a lot of designers and engineers and just people in the industry. They go sign up, use Fey and then they’ll counsel and we always ask for feedback. And then it’s always like, I just wanted to interact with the app. I just wanted to learn about how this was designed. I wanted to give it a, give it a try myself and learn something new. We get that a lot. Yeah.


id: interviews/dennis-brotzky/emil-51-03
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (51:03)
url_path: /lessons/interviews/dennis-brotzky/#emil-51-03
markdown_path: /md/sections/interviews/dennis-brotzky/emil-51-03.md
excerpt:
Yeah, that’s a great compliment, I would say. Nice thing to hear. If people sign up just to see how great a product is.

body:
Yeah, that’s a great compliment, I would say. Nice thing to hear. If people sign up just to see how great a product is.


id: interviews/dennis-brotzky/emil-51-15
lesson_id: interviews/dennis-brotzky
module_slug: interviews
module_title: Interviews
lesson_slug: dennis-brotzky
lesson_title: Interviews/Dennis Brotzky
heading: Emil (51:15)
url_path: /lessons/interviews/dennis-brotzky/#emil-51-15
markdown_path: /md/sections/interviews/dennis-brotzky/emil-51-15.md
excerpt:
Yeah, cool. I guess that’s it. Thank you again for your time. Is there anything else you would like to share or tell people?

body:
Yeah, cool. I guess that’s it. Thank you again for your time. Is there anything else you would like to share or tell people?


id: interviews/henry-heffernan/overview
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Overview
url_path: /lessons/interviews/henry-heffernan/#overview
markdown_path: /md/sections/interviews/henry-heffernan/overview.md
excerpt:
Henry is a design engineer working at Vercel. He contributed to a lot of Vercel’s new marketing pages, Next.js Conf, etc. He also has a very impressive personal site. In this interview, we’ll talk about animations, easings, design engineer in general and how he got his job at Vercel.

body:
Henry Heffernan Mariana Castilho Lochie Axon Dennis Brotzky

Henry is a design engineer working at Vercel. He contributed to a lot of Vercel’s new marketing pages, Next.js Conf, etc. He also has a very impressive personal site. In this interview, we’ll talk about animations, easings, design engineer in general and how he got his job at Vercel.


id: interviews/henry-heffernan/emil-00-00
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (00:00)
url_path: /lessons/interviews/henry-heffernan/#emil-00-00
markdown_path: /md/sections/interviews/henry-heffernan/emil-00-00.md
excerpt:
Hey, so this is the first interview for Animations on the Web. Today, we will talk with Henry from Vercel who works as a design engineer there. Henry and I worked together for a while on the design engineering team at Vercel. I’m a big fan of his work.

body:
Hey, so this is the first interview for Animations on the Web. Today, we will talk with Henry from Vercel who works as a design engineer there. Henry and I worked together for a while on the design engineering team at Vercel. I’m a big fan of his work.

Henry, why don’t you tell a bit more about yourself first?


id: interviews/henry-heffernan/henry-00-24
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (00:24)
url_path: /lessons/interviews/henry-heffernan/#henry-00-24
markdown_path: /md/sections/interviews/henry-heffernan/henry-00-24.md
excerpt:
Yep - so I’m Henry Heffernan. I’m a design engineer at Vercel. I primarily work on the marketing related properties of Vercel, so things like our events such as NextConf, Vercel Ship. I work very directly with the design team and also some engineering to craft those experiences and create those websites as well as the actual Vercel marketing pages - like our Vercel homepage. Basically anything that’s not the dashboard or docs is something that I’ve likely touched over the last two years.

body:
Yep - so I’m Henry Heffernan. I’m a design engineer at Vercel. I primarily work on the marketing related properties of Vercel, so things like our events such as NextConf, Vercel Ship. I work very directly with the design team and also some engineering to craft those experiences and create those websites as well as the actual Vercel marketing pages - like our Vercel homepage. Basically anything that’s not the dashboard or docs is something that I’ve likely touched over the last two years.


id: interviews/henry-heffernan/henry-01-19
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (01:19)
url_path: /lessons/interviews/henry-heffernan/#henry-01-19
markdown_path: /md/sections/interviews/henry-heffernan/henry-01-19.md
excerpt:
I actually got hired to Vercel as a marketing engineer initially. I was “a marketing engineer” for about a year before transitioning to the design team and becoming a design engineer. However, like just being and existing on the marketing team before that really, paved the path for me to just continue doing that on the design engineering side of things.

body:
I actually got hired to Vercel as a marketing engineer initially. I was “a marketing engineer” for about a year before transitioning to the design team and becoming a design engineer. However, like just being and existing on the marketing team before that really, paved the path for me to just continue doing that on the design engineering side of things.

There were a lot of really fun projects that we got to tackle when I moved over to the design engineering team - like the Vercel homepage redesign - which was super fun to work on and a like multi -month long project as well.


id: interviews/henry-heffernan/emil-02-04
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (02:04)
url_path: /lessons/interviews/henry-heffernan/#emil-02-04
markdown_path: /md/sections/interviews/henry-heffernan/emil-02-04.md
excerpt:
We are going to talk about the redesign later, but could we go back a bit? You obviously have a very impressive personal site. Many people on the course’s Discord were very impressed by that. Could you tell us a bit more about why you built it, how you built it, and how that happened?

body:
We are going to talk about the redesign later, but could we go back a bit? You obviously have a very impressive personal site. Many people on the course’s Discord were very impressed by that. Could you tell us a bit more about why you built it, how you built it, and how that happened?


id: interviews/henry-heffernan/henry-02-23
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (02:23)
url_path: /lessons/interviews/henry-heffernan/#henry-02-23
markdown_path: /md/sections/interviews/henry-heffernan/henry-02-23.md
excerpt:
The whole story started my last year of college when I was applying to jobs and I was really failing to get any offers because I was a pretty unremarkable graduate on paper, so to say.

body:
The whole story started my last year of college when I was applying to jobs and I was really failing to get any offers because I was a pretty unremarkable graduate on paper, so to say.

So after submitting countless applications and attending a handful of interviews, I realized I should probably try and do something to differentiate myself a little bit, so I decided to start creating a website that would showcase my skills.

The idea for my website really stemmed from this vision that I had of wanting to have a 3D experience that people could go into and just see who I was, but in a very immersive way. Initially the whole setting and everything for this website was to be taking place in some cyber-punky future. But I realized shortly after starting that iteration of it that that would be nearly impossible on my pretty short timeline of trying to get a job before I graduated. So I did a hard pivot towards a 90s vibe and theme. That actually really worked out for me too, because the UI from the 90s is actually very simple and straightforward. So you can just make stuff really quickly and it looks period-accurate because it’s super simple.

After spending about two or three months on that project and pretty much doing only that and ignoring all of my schoolwork, I ended up releasing it to Twitter and it got a very positive response, which I was super grateful for.

I guess technically it’s made with just create-react-app - not Next.js or three.js or react-three-fiber. It’s pretty bare bones, which is just sort of a symptom of me being more new to the space at that time and not knowing that Next.js or react-three-fiber even existed when I created it.


id: interviews/henry-heffernan/emil-04-59
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (04:59)
url_path: /lessons/interviews/henry-heffernan/#emil-04-59
markdown_path: /md/sections/interviews/henry-heffernan/emil-04-59.md
excerpt:
How did you learn? Because this is pretty impressive 3D stuff that’s going on there. Did you like learn it at school or did you like take a course for it?

body:
How did you learn? Because this is pretty impressive 3D stuff that’s going on there. Did you like learn it at school or did you like take a course for it?


id: interviews/henry-heffernan/henry-05-16
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (05:16)
url_path: /lessons/interviews/henry-heffernan/#henry-05-16
markdown_path: /md/sections/interviews/henry-heffernan/henry-05-16.md
excerpt:
So I guess I learned a lot of 3D stuff in high school, just through self-exploration and learning things myself.

body:
So I guess I learned a lot of 3D stuff in high school, just through self-exploration and learning things myself.

This is pretty deep, personal lore for myself… but I was active in some Call of Duty zombie-modding communities, where you’d create maps and stuff. I definitely learned some modeling then. Once you learn that stuff, you know how it works fundamentally.

So when it came to “I have to learn Blender, I have to learn all this stuff” - well, I did have courses at the time I was looking at, like Bruno Simon’s three.js journey that really helped learn the basics and everything.

I guess fundamentally having that background in just knowing how 3D stuff works in general really helped me create the experience.


id: interviews/henry-heffernan/emil-06-23
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (06:23)
url_path: /lessons/interviews/henry-heffernan/#emil-06-23
markdown_path: /md/sections/interviews/henry-heffernan/emil-06-23.md
excerpt:
Right. Because you did this to find a job basically and I’m guessing that’s how you got to work at Vercel, right? Because this is your first real job.

body:
Right. Because you did this to find a job basically and I’m guessing that’s how you got to work at Vercel, right? Because this is your first real job.

Did you apply? Or did someone reach out to you based on the tweet about the personal site?


id: interviews/henry-heffernan/henry-06-37
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (06:37)
url_path: /lessons/interviews/henry-heffernan/#henry-06-37
markdown_path: /md/sections/interviews/henry-heffernan/henry-06-37.md
excerpt:
Yeah, so when I “released this website” to my zero followers and I got a bunch of likes, I got reached out to a bunch - which was really cool - and I had the absolute privilege of being able to interview with a lot of companies and have my choices regarding who I wanted to go with. As opposed to the much more grim reality that I was facing before - which was like sending out a hundred applications and hoping to get two interviews.

body:
Yeah, so when I “released this website” to my zero followers and I got a bunch of likes, I got reached out to a bunch - which was really cool - and I had the absolute privilege of being able to interview with a lot of companies and have my choices regarding who I wanted to go with. As opposed to the much more grim reality that I was facing before - which was like sending out a hundred applications and hoping to get two interviews.

So with that context - Guillermo, our CEO, actually reached out to me over Twitter DMs and that’s what kickstarted my interview process. I did still interview and I did several interviews with them before signing an offer. But that’s how I joined Vercel.


id: interviews/henry-heffernan/emil-07-20
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (07:20)
excerpt: Right. Interesting. That’s how I joined Vercel as well, Guillermo DM’d me on Twitter.
url_path: /lessons/interviews/henry-heffernan/#emil-07-20
markdown_path: /md/sections/interviews/henry-heffernan/emil-07-20.md
body:
Right. Interesting. That’s how I joined Vercel as well, Guillermo DM’d me on Twitter.

So you work at Vercel. You switch teams - you didn’t work on the design engineering team from the beginning because there was no design engineering team when you joined.

Obviously a lot of people are interested in design engineering; design engineering is a very interesting career path at the moment. Many companies are hiring design engineers. Could you tell us a bit more about like how design engineering looks like at Vercel? How does your job look like?


id: interviews/henry-heffernan/henry-08-42
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (08:42)
url_path: /lessons/interviews/henry-heffernan/#henry-08-42
markdown_path: /md/sections/interviews/henry-heffernan/henry-08-42.md
excerpt:
I think at Vercel, the design engineer’s role is really just, as a whole, almost a replacement for what would otherwise be two people: a designer and an engineer both working on a project. So in any sort of case where you have that “iteration loop” of a designer and engineer, you can just replace it with one design engineer.

body:
I think at Vercel, the design engineer’s role is really just, as a whole, almost a replacement for what would otherwise be two people: a designer and an engineer both working on a project. So in any sort of case where you have that “iteration loop” of a designer and engineer, you can just replace it with one design engineer.

Because of that, we’ve worked on a lot of random things (seemingly). We’re kind of all over the place, which is very nice because novelty is always nice at a job and to get to do new experiences, not just feeling stuck working in one place.

To even like say that there is a single thing I do day to day, I don’t think that’s very honest. I do such different things every single day. It’s mostly just doing some designs, doing engineering, and owning a lot of the work that you do as well and not really having that back-and-forth that some people are so familiar with - where you’re talking as an engineer (or coming from an engineering background), that loop of talking to a designer, and going back-and-forth. That’s just not a thing that you have to worry about when it comes to the stuff that you’re doing. With that said, we do work with the designers as well and we do work with engineers to do our work too. It’s really hard to pinpoint exactly what we do, I guess.


id: interviews/henry-heffernan/emil-10-46
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (10:46)
url_path: /lessons/interviews/henry-heffernan/#emil-10-46
markdown_path: /md/sections/interviews/henry-heffernan/emil-10-46.md
excerpt:
But okay, so I obviously kind of know how it looks like at Vercel because I worked on the same team but -

body:
But okay, so I obviously kind of know how it looks like at Vercel because I worked on the same team but -


id: interviews/henry-heffernan/emil-11-04
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (11:04)
url_path: /lessons/interviews/henry-heffernan/#emil-11-04
markdown_path: /md/sections/interviews/henry-heffernan/emil-11-04.md
excerpt:
From my perspective, I think design engineers are people that sort of - you know - if you and I would work together on something, then we would operate on the same wavelength: I would like what you created and I hope you would like what I created.

body:
From my perspective, I think design engineers are people that sort of - you know - if you and I would work together on something, then we would operate on the same wavelength: I would like what you created and I hope you would like what I created.

Then the designers would also not have to give us a list with 40 - like it’s not that we built exactly what the designer wanted and there is no like feedback at all. There’s obviously feedback and we work together, but I don’t think that I could design and build something myself and be very proud of that.

I could say I’m a design engineer but I’m not a design engineer that designs and builds the designs that he designed. I know that there are design engineers that do that, but I would say that’s how I see it.


id: interviews/henry-heffernan/henry-12-17
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (12:17)
url_path: /lessons/interviews/henry-heffernan/#henry-12-17
markdown_path: /md/sections/interviews/henry-heffernan/henry-12-17.md
excerpt:
It’s definitely a spectrum of design engineers who lie more on the design side and the engineering side. For me, I definitely lie more on the engineering side, but I’m sure we can both think of plenty of design engineers who lie more on the design side.

body:
It’s definitely a spectrum of design engineers who lie more on the design side and the engineering side. For me, I definitely lie more on the engineering side, but I’m sure we can both think of plenty of design engineers who lie more on the design side.

Obviously, I don’t think there’s anything wrong with that - like everyone has their own strengths - I honestly wish I was more on the design side because I feel like that’s way cooler to be honest, but…


id: interviews/henry-heffernan/emil-12-50
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (12:50)
url_path: /lessons/interviews/henry-heffernan/#emil-12-50
markdown_path: /md/sections/interviews/henry-heffernan/emil-12-50.md
excerpt:
Yeah, yeah. And it gives you more, it gives you more power sort of, right? If you can design and build everything yourself, you don’t need anyone else. Like, like it’s pretty powerful. But I guess there are design engineers at Verso like that, right? I think yes, designs and builds her stuff. So.

body:
Yeah, yeah. And it gives you more, it gives you more power sort of, right? If you can design and build everything yourself, you don’t need anyone else. Like, like it’s pretty powerful. But I guess there are design engineers at Verso like that, right? I think yes, designs and builds her stuff. So.


id: interviews/henry-heffernan/emil-13-10
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (13:10)
url_path: /lessons/interviews/henry-heffernan/#emil-13-10
markdown_path: /md/sections/interviews/henry-heffernan/emil-13-10.md
excerpt:
I guess at Vercel specifically there are many design engineers that focus on different things; like you and I code a lot, someone else designs a bit more.

body:
I guess at Vercel specifically there are many design engineers that focus on different things; like you and I code a lot, someone else designs a bit more.

One question that I’m very interested in because I think about it a lot and there is no one answer to it; taste is obviously something that gets talked about a lot when it comes to design engineering and people say that good design engineers have good taste.

How do you think about taste? How can someone learn or get good taste? And what does taste mean to you in terms of design engineering and everything around that.


id: interviews/henry-heffernan/henry-14-06
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (14:06)
excerpt: I feel like taste is pretty difficult to define.
url_path: /lessons/interviews/henry-heffernan/#henry-14-06
markdown_path: /md/sections/interviews/henry-heffernan/henry-14-06.md
body:
I feel like taste is pretty difficult to define.

Even as like design engineers, I don’t feel totally qualified to answer this question. Because I feel like taste really applies to any profession almost.

It is a very important thing with design engineering, and that is what people really get drawn towards. But I don’t think it’s inherently a design engineering trait, if that makes any sense. I definitely feel like taste is much more of a designer trait that design engineers inherently hold having both of those skills.

So if you wanted to develop your taste, I’d say... the probably the number one thing is try and develop your design muscles first. It’s like breaking the problem down a little bit easier as opposed to just being like “taste is a magical thing that you just sort of have to have and some people have it, some people don’t”. It’s definitely a learned thing over time. Just based off of your experiences: what you do, what you make, whatever.

In terms of how do you refine your taste? You look at people who are really good. You try to educate yourself. Like there are books and courses on this. Your course does a great job, honestly, at trying to explain this very complicated topic.

Just finding inspiration for yourself, looking at new ideas. Even just being on Twitter alone, you already have an upper hand in terms of seeing what’s really going on in the UI craft/taste space. I open up my Twitter feed and I’m just bombarded with people making these gorgeous things and I’m like, damn.

The last thing is just practicing it like any skill. It’s just something that you have to do - over time, you’ll develop it, you’ll refine it. Everyone comes in with different tastes too, and everyone will have different tastes in everything too. So it’s not like something that you either have or don’t. It’s just something that you either have refined to be very accessible and people really are drawn towards it. I think that’s really what a refined taste is like. It’s just something that resonates with most people that see it.


id: interviews/henry-heffernan/emil-17-06
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (17:06)
url_path: /lessons/interviews/henry-heffernan/#emil-17-06
markdown_path: /md/sections/interviews/henry-heffernan/emil-17-06.md
excerpt:
Right, right. Yeah, I agree with that answer, but you said that taste is not necessarily a trait of a design engineer. So what is a trait? What are you looking for in a design engineer? Because you are obviously hiring at Vercel right now. What does make a good design engineer in your opinion?

body:
Right, right. Yeah, I agree with that answer, but you said that taste is not necessarily a trait of a design engineer. So what is a trait? What are you looking for in a design engineer? Because you are obviously hiring at Vercel right now. What does make a good design engineer in your opinion?


id: interviews/henry-heffernan/henry-17-25
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (17:25)
url_path: /lessons/interviews/henry-heffernan/#henry-17-25
markdown_path: /md/sections/interviews/henry-heffernan/henry-17-25.md
excerpt:
Okay, so I do think that taste is something that a design engineer has, but I just mean it’s not explicitly something that the role has.

body:
Okay, so I do think that taste is something that a design engineer has, but I just mean it’s not explicitly something that the role has.

It’s just more so something that the design part of a design engineer has to have. So good taste is obviously something that we’re looking for. Or, at the very least, taste that resonates with us, because you know... to say one taste is good and one is bad is not too good to say.

The other things we just look for are just people that are really passionate about it. If you have the passion to create cool user interfaces or web experiences: that is number one best thing to see. And it’s honestly a requirement to be a successful design engineer in this space. And then beyond passion, just curiosity: wanting to learn, do more, and create stuff.


id: interviews/henry-heffernan/emil-18-30
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (18:30)
url_path: /lessons/interviews/henry-heffernan/#emil-18-30
markdown_path: /md/sections/interviews/henry-heffernan/emil-18-30.md
excerpt:
Okay, so we talked about your career, design engineering, and this course is obviously about animations and you created quite a few animations on the new Vercel marketing pages.

body:
Okay, so we talked about your career, design engineering, and this course is obviously about animations and you created quite a few animations on the new Vercel marketing pages.

How do you think about animations? How do you build them? Do you just jump straight in the code and see what sticks? How do you think about easing, duration? Could you walk us through your process of building an animation and thinking about it while you do it.


id: interviews/henry-heffernan/henry-19-11
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (19:11)
url_path: /lessons/interviews/henry-heffernan/#henry-19-11
markdown_path: /md/sections/interviews/henry-heffernan/henry-19-11.md
excerpt:
The number one thing I try and do when I start an animation is to try and visualize it or see in my head what I want the final thing to look like. Just that exercise of trying to visualize something in its complete form.

body:
The number one thing I try and do when I start an animation is to try and visualize it or see in my head what I want the final thing to look like. Just that exercise of trying to visualize something in its complete form.

That’s something that I personally tend to go towards in terms of iterating towards something that I want to create. Maybe this isn’t super applicable to people that don’t like to do that or aren’t good at that. I like to have a pretty strong idea of like what I want to do. And then I’ll just continue iterating towards that.

Most of the time that initial version I thought I wanted to create is not what I end up making. But even just having that strong vision to direct me towards something is really useful in just starting - it’s always hard to start doing something. But once you get the ball rolling and start iterating and get feedback on stuff, it’s way easier to keep going and making something great.

I do definitely jump right into the code, though, as the first step.


id: interviews/henry-heffernan/emil-20-22
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (20:22)
url_path: /lessons/interviews/henry-heffernan/#emil-20-22
markdown_path: /md/sections/interviews/henry-heffernan/emil-20-22.md
excerpt:
On those marketing pages that we see at least specifically, do you use like Framer motion for example, or do you mostly use CSS animations? What do you choose most of the time and why?

body:
On those marketing pages that we see at least specifically, do you use like Framer motion for example, or do you mostly use CSS animations? What do you choose most of the time and why?


id: interviews/henry-heffernan/henry-20-41
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (20:41)
url_path: /lessons/interviews/henry-heffernan/#henry-20-41
markdown_path: /md/sections/interviews/henry-heffernan/henry-20-41.md
excerpt:
At Vercel, specifically just because we are so performance focused, we tend to really lean towards as much native CSS animations as possible. Just because Frame Motion is JavaScript, so you have to hydrate before you can start playing any animations. And also, they’re not necessarily hardware accelerated if you’re doing layout stuff.

body:
At Vercel, specifically just because we are so performance focused, we tend to really lean towards as much native CSS animations as possible. Just because Frame Motion is JavaScript, so you have to hydrate before you can start playing any animations. And also, they’re not necessarily hardware accelerated if you’re doing layout stuff.

Honestly, if you’re doing anything with layout, you have to use Framer Motion, basically. We tend to just go towards CSS animations, just because it’s super performant.


id: interviews/henry-heffernan/emil-21-23
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (21:23)
url_path: /lessons/interviews/henry-heffernan/#emil-21-23
markdown_path: /md/sections/interviews/henry-heffernan/emil-21-23.md
excerpt:
Yeah, yeah, I guess you also don’t do a lot of layout animations on those marketing pages, right? At least I haven’t seen a lot of those.

body:
Yeah, yeah, I guess you also don’t do a lot of layout animations on those marketing pages, right? At least I haven’t seen a lot of those.


id: interviews/henry-heffernan/henry-21-31
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (21:31)
url_path: /lessons/interviews/henry-heffernan/#henry-21-31
markdown_path: /md/sections/interviews/henry-heffernan/henry-21-31.md
excerpt:
Yeah, and if they are layout animations, they’re usually basic enough to where you can really just fake it with transforms.

body:
Yeah, and if they are layout animations, they’re usually basic enough to where you can really just fake it with transforms.


id: interviews/henry-heffernan/emil-21-37
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (21:37)
excerpt: Okay, I see.
url_path: /lessons/interviews/henry-heffernan/#emil-21-37
markdown_path: /md/sections/interviews/henry-heffernan/emil-21-37.md
body:
Okay, I see.

When it comes to easings - I have a set of easings that I use most of the time. And then I choose which one to pick. I rarely create my own easings.


id: interviews/henry-heffernan/henry-22-12
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (22:12)
url_path: /lessons/interviews/henry-heffernan/#henry-22-12
markdown_path: /md/sections/interviews/henry-heffernan/henry-22-12.md
excerpt:
Yeah, of course. While I’m doing stuff, I’ll usually create four or five easing curves for something until I really dial it in and get it right. Because I like that level of touch and polish that like a very custom curve for looks.

body:
Yeah, of course. While I’m doing stuff, I’ll usually create four or five easing curves for something until I really dial it in and get it right. Because I like that level of touch and polish that like a very custom curve for looks.


id: interviews/henry-heffernan/emil-22-27
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (22:27)
url_path: /lessons/interviews/henry-heffernan/#emil-22-27
markdown_path: /md/sections/interviews/henry-heffernan/emil-22-27.md
excerpt:
No, that makes sense. But which type of curve do you usually go for? Or is it very specific or very one-off for each animation? Like ease-in-out or ease-out or do you just see what works each and every time?

body:
No, that makes sense. But which type of curve do you usually go for? Or is it very specific or very one-off for each animation? Like ease-in-out or ease-out or do you just see what works each and every time?


id: interviews/henry-heffernan/henry-22-56
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (22:56)
url_path: /lessons/interviews/henry-heffernan/#henry-22-56
markdown_path: /md/sections/interviews/henry-heffernan/henry-22-56.md
excerpt:
I tend to lean towards, as of late, since I think my taste has evolved a lot. Like I used to really like exp-out where it’s super fast at the beginning and then it’s super exaggerated like this. I’ve been leaning a lot more towards just a pretty basic ease curve with a bit more exaggerated of an out. So I don’t know if you can visualize that.

body:
I tend to lean towards, as of late, since I think my taste has evolved a lot. Like I used to really like exp-out where it’s super fast at the beginning and then it’s super exaggerated like this. I’ve been leaning a lot more towards just a pretty basic ease curve with a bit more exaggerated of an out. So I don’t know if you can visualize that.


id: interviews/henry-heffernan/emil-23-11
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (23:11)
excerpt: So, faster at the end?
url_path: /lessons/interviews/henry-heffernan/#emil-23-11
markdown_path: /md/sections/interviews/henry-heffernan/emil-23-11.md
body:
So, faster at the end?

No, slower at the end.

So like an ease out, but a very subtle one, I guess.


id: interviews/henry-heffernan/henry-23-37
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (23:37)
excerpt: A subtle ease in, a more aggressive ease out.
url_path: /lessons/interviews/henry-heffernan/#henry-23-37
markdown_path: /md/sections/interviews/henry-heffernan/henry-23-37.md
body:
A subtle ease in, a more aggressive ease out.

So it’s not symmetrical, of course. That’s definitely what I’ve been leaning towards. I really love that curve. I think they’re something very natural about it.

I’ve also been leaning towards - weirdly - linear easings lately too. At least stuff that just looks a little bit more linear, but it’s not linear. Just a little - you wouldn’t be able to know.


id: interviews/henry-heffernan/emil-24-20
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (24:20)
url_path: /lessons/interviews/henry-heffernan/#emil-24-20
markdown_path: /md/sections/interviews/henry-heffernan/emil-24-20.md
excerpt:
Yeah, that’s interesting. I don’t think I ever use linear unless it’s something like moving all the time like logos.

body:
Yeah, that’s interesting. I don’t think I ever use linear unless it’s something like moving all the time like logos.

So you’ve obviously worked in the design engineering field for a while. What advice would you give someone who wants to become a design engineer? Like what would you do if you weren’t a design engineer and wanted to become one?


id: interviews/henry-heffernan/henry-25-06
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (25:06)
url_path: /lessons/interviews/henry-heffernan/#henry-25-06
markdown_path: /md/sections/interviews/henry-heffernan/henry-25-06.md
excerpt:
As a disclaimer, I personally don’t think I’ve been in this space for too long, first of all. But it is also a very new space. I’m incredibly hesitant to like prescribe a single path or even allude to there being a correct path to like go from engineer to design engineer, designer to engineer because that is normally how it goes.

body:
As a disclaimer, I personally don’t think I’ve been in this space for too long, first of all. But it is also a very new space. I’m incredibly hesitant to like prescribe a single path or even allude to there being a correct path to like go from engineer to design engineer, designer to engineer because that is normally how it goes.

With that said, I think one of the best things you can do is to just make stuff and get experience doing stuff, and then being very public about it. I think that’s worked out for both of us. I don’t want to sound too crazy being like “Yeah, just get super popular on Twitter. Then you can become a design engineer”. But that is a pretty valid path, honestly.

If you can gain a little bit of a following - just making cool things and sharing your work - that’s a very valid path to get this role.

Companies are also hiring. So if you have a strong portfolio: apply, see if you can get the job. And I also think f you’re currently employed at a company that’s interested in design engineering, you can try and push a little bit internally to get into that role.

I mean, you were the first design engineer at Vercel basically. Without you, I wouldn’t be a design engineer because you basically pushed us to create a whole team. If you feel like it’s valuable and you’re confident in your skills as a design engineer, then I’d say that’s also a pretty valid path to just try and do - but definitely up to the company and individual.


id: interviews/henry-heffernan/emil-27-13
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (27:13)
url_path: /lessons/interviews/henry-heffernan/#emil-27-13
markdown_path: /md/sections/interviews/henry-heffernan/emil-27-13.md
excerpt:
Yeah, I agree. You have to build stuff and let people know that you built those things. That puts you in an advantageous position because you don’t have to apply like you did to 100 companies - you will be approached if your work is good enough, obviously.

body:
Yeah, I agree. You have to build stuff and let people know that you built those things. That puts you in an advantageous position because you don’t have to apply like you did to 100 companies - you will be approached if your work is good enough, obviously.


id: interviews/henry-heffernan/henry-27-41
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (27:41)
url_path: /lessons/interviews/henry-heffernan/#henry-27-41
markdown_path: /md/sections/interviews/henry-heffernan/henry-27-41.md
excerpt:
And I think too you have to just get better at it, obviously. If you are interested in it and you have a passion for it - if you just do a bunch of work and just keep flexing your design and engineering muscles to make new and exciting things, then you’ll get better over time.

body:
And I think too you have to just get better at it, obviously. If you are interested in it and you have a passion for it - if you just do a bunch of work and just keep flexing your design and engineering muscles to make new and exciting things, then you’ll get better over time.


id: interviews/henry-heffernan/emil-28-09
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (28:09)
url_path: /lessons/interviews/henry-heffernan/#emil-28-09
markdown_path: /md/sections/interviews/henry-heffernan/emil-28-09.md
excerpt:
Mm -hmm. I think the most important thing is just to build stuff because the more stuff you build the better you get at it, but that’s with everything.

body:
Mm -hmm. I think the most important thing is just to build stuff because the more stuff you build the better you get at it, but that’s with everything.

When it comes to animations, how do you decide when to animate something and when not? It’s very easy to overdo it and animate a lot of stuff.


id: interviews/henry-heffernan/henry-28-43
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (28:43)
url_path: /lessons/interviews/henry-heffernan/#henry-28-43
markdown_path: /md/sections/interviews/henry-heffernan/henry-28-43.md
excerpt:
There are examples of people animating a lot of stuff and it really working out great and also examples of people animating nothing and it looking great. You have to use your taste to sort of dictate this decision.

body:
There are examples of people animating a lot of stuff and it really working out great and also examples of people animating nothing and it looking great. You have to use your taste to sort of dictate this decision.

But as a rule of thumb, when it comes to figuring out what you want to animate on a page… I come from the marketing side of things where you’re trying to tell a story. How do I tell a story with this animation? Is this animation even worth doing? Is it even contributing to that story?

Having that as a foundation to dictate what you do is helpful because it will avoid you over-animating stuff. Because I don’t think you should just go into being like, every single thing has to be animated. Because sometimes it really doesn’t and it really shouldn’t. So you have to be very conscious about what it is you’re doing.

If you ever find yourself just grinding out animations, maybe you don’t have to animate all that stuff. Just take a step back, think about it, figure out what you’re trying to actually tell and how you can use animation to accomplish that.

I would say do it tastefully. That is a pretty loaded phrase, but…


id: interviews/henry-heffernan/emil-30-08
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (30:08)
excerpt: Okay.
url_path: /lessons/interviews/henry-heffernan/#emil-30-08
markdown_path: /md/sections/interviews/henry-heffernan/emil-30-08.md
body:
Okay.

Right.

No, but it makes sense. But you talked about like telling a story and that’s what Vercel wants to do with their marketing pages as well.

Is there something that you guys do to ensure that every page feels like Vercel? I’m talking about consistency in terms of animations. The AI page sort of feels the same as another page when it comes to the way everything moves and is animated or is every page just different?


id: interviews/henry-heffernan/henry-30-54
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (30:54)
url_path: /lessons/interviews/henry-heffernan/#henry-30-54
markdown_path: /md/sections/interviews/henry-heffernan/henry-30-54.md
excerpt:
At its core, we are always trying to tell the story of speed and performance and with that framework, it can help dictate how you animate things. And most of the time, we actually don’t animate a lot of stuff because it’s actually way faster obviously. And we don’t believe in super flourishy animations just for nothing.

body:
At its core, we are always trying to tell the story of speed and performance and with that framework, it can help dictate how you animate things. And most of the time, we actually don’t animate a lot of stuff because it’s actually way faster obviously. And we don’t believe in super flourishy animations just for nothing.


id: interviews/henry-heffernan/henry-31-27
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (31:27)
excerpt: We do sometimes go all out and animate some crazy things. And when we do do that, it’s really fun.
url_path: /lessons/interviews/henry-heffernan/#henry-31-27
markdown_path: /md/sections/interviews/henry-heffernan/henry-31-27.md
body:
We do sometimes go all out and animate some crazy things. And when we do do that, it’s really fun.

But it’s usually only to tell a story or if it’s like an important beat to the story that we want to tell. For example, a large globe in the middle of a page that is fully animated, and it’s actually telling this whole story of how Vercel works. It’s obviously worth investing into animating that whole thing because it can really draw people in and explain to people very visually what we do.


id: interviews/henry-heffernan/emil-32-05
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (32:05)
url_path: /lessons/interviews/henry-heffernan/#emil-32-05
markdown_path: /md/sections/interviews/henry-heffernan/emil-32-05.md
excerpt:
When you think about animation, is there something that you should never do in your opinion or rarely do or should avoid when it comes to animations? Like something performance related or easing related, duration related, anything.

body:
When you think about animation, is there something that you should never do in your opinion or rarely do or should avoid when it comes to animations? Like something performance related or easing related, duration related, anything.


id: interviews/henry-heffernan/henry-32-34
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (32:34)
url_path: /lessons/interviews/henry-heffernan/#henry-32-34
markdown_path: /md/sections/interviews/henry-heffernan/henry-32-34.md
excerpt:
Some actual performance tips are, you should never use Framer Motion to animate something coming in. If it’s something that should happen immediately on the page load, because that requires JavaScript to be loaded and everything so you can delay entire visual things just because JavaScript isn’t loaded. That’s one thing that I always keep in mind. I don’t know if there’s any hard rules you shouldn’t do.

body:
Some actual performance tips are, you should never use Framer Motion to animate something coming in. If it’s something that should happen immediately on the page load, because that requires JavaScript to be loaded and everything so you can delay entire visual things just because JavaScript isn’t loaded. That’s one thing that I always keep in mind. I don’t know if there’s any hard rules you shouldn’t do.

What would you say?


id: interviews/henry-heffernan/emil-33-03
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (33:03)
url_path: /lessons/interviews/henry-heffernan/#emil-33-03
markdown_path: /md/sections/interviews/henry-heffernan/emil-33-03.md
excerpt:
What would I say? I don’t have anything on the spot right now, but what you said is pretty valid. I know we avoided that when I was at Vercel, you should just use CSS animations at that point.

body:
What would I say? I don’t have anything on the spot right now, but what you said is pretty valid. I know we avoided that when I was at Vercel, you should just use CSS animations at that point.

Maybe one thing that I would say is to avoid linear almost always. I know you just mentioned that you like using linear -ish easing curves.


id: interviews/henry-heffernan/henry-33-20
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (33:20)
url_path: /lessons/interviews/henry-heffernan/#henry-33-20
markdown_path: /md/sections/interviews/henry-heffernan/henry-33-20.md
excerpt:
Yeah, linear-ish. Sorry, I want to clarify. I don’t like to use the actual linear curve, but I like things to almost look like it is, but it doesn’t look like it. I don’t know how to explain it.

body:
Yeah, linear-ish. Sorry, I want to clarify. I don’t like to use the actual linear curve, but I like things to almost look like it is, but it doesn’t look like it. I don’t know how to explain it.

There’s a very specific curve I have in my mind and what that looks like. But it’s more just feeling responsive and fast that I like from that curve, I guess.


id: interviews/henry-heffernan/emil-33-47
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (33:47)
excerpt: Okay, yeah, that’s good. But yeah, linear easing is something that comes to my mind.
url_path: /lessons/interviews/henry-heffernan/#emil-33-47
markdown_path: /md/sections/interviews/henry-heffernan/emil-33-47.md
body:
Okay, yeah, that’s good. But yeah, linear easing is something that comes to my mind.

And not make your animations like too long, because that’s kind of annoying. Obviously, if it fits the vibe, or you tell a story, like you said, and it really fits this very specific situation, then yes. But in general, I feel like animation should be very fast to not become annoying to users.


id: interviews/henry-heffernan/henry-34-39
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (34:39)
url_path: /lessons/interviews/henry-heffernan/#henry-34-39
markdown_path: /md/sections/interviews/henry-heffernan/henry-34-39.md
excerpt:
There’s almost never a case that like animation - like a transition or something - should be over a second, absolute max. And that’s with a pretty extreme easing curve too.

body:
There’s almost never a case that like animation - like a transition or something - should be over a second, absolute max. And that’s with a pretty extreme easing curve too.

I would say anything users clicking on - 200 milliseconds is the go-to you can even go lower like 150 for super fast stuff you want.

If you ever find yourself at 150 milliseconds for an animation, you’re like “this is not fast enough”, it is probably not worth animating at all. That’s always an option. You don’t always have to animate everything. I know that this is a course in animations, but sometimes the best animation is no animation. So something to keep in mind, I guess.


id: interviews/henry-heffernan/emil-35-19
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (35:19)
url_path: /lessons/interviews/henry-heffernan/#emil-35-19
markdown_path: /md/sections/interviews/henry-heffernan/emil-35-19.md
excerpt:
Yeah, that’s what Sam Selikoff said that to me about design as well: the best design is no design because the outcome has already been anticipated.

body:
Yeah, that’s what Sam Selikoff said that to me about design as well: the best design is no design because the outcome has already been anticipated.


id: interviews/henry-heffernan/emil-35-52
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (35:52)
url_path: /lessons/interviews/henry-heffernan/#emil-35-52
markdown_path: /md/sections/interviews/henry-heffernan/emil-35-52.md
excerpt:
To close this off, are there any people, books, blogs, courses or anything that you recommend to people that are interested in design, engineering, or design-engineering?

body:
To close this off, are there any people, books, blogs, courses or anything that you recommend to people that are interested in design, engineering, or design-engineering?


id: interviews/henry-heffernan/henry-36-12
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (36:12)
url_path: /lessons/interviews/henry-heffernan/#henry-36-12
markdown_path: /md/sections/interviews/henry-heffernan/henry-36-12.md
excerpt:
In terms of courses, I will be an absolute shill until the day I die for Bruno Simon three.js course because he really personally did a lot for me in terms of helping me progress my career. So I would say his course - if you want to learn anything about 3D, that is definitely the place to do it.

body:
In terms of courses, I will be an absolute shill until the day I die for Bruno Simon three.js course because he really personally did a lot for me in terms of helping me progress my career. So I would say his course - if you want to learn anything about 3D, that is definitely the place to do it.

It’s honestly not that expensive and the amount of content he has - like 80 hours or something - it’s unbelievable how much he’s done. It’s absolute crazy value for what you’re spending. It’s like a dollar an hour for the price, which is insane.

So if you want to do anything with 3D, definitely check out his course.

In terms of other designers and engineers, I would say Twitter is one of the best places to be right now. I personally just follow a bunch of design engineers on Twitter. I love following their work and getting inspired that way.


id: interviews/henry-heffernan/henry-37-26
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (37:26)
url_path: /lessons/interviews/henry-heffernan/#henry-37-26
markdown_path: /md/sections/interviews/henry-heffernan/henry-37-26.md
excerpt:
It’s an everyday occurrence that I go on Twitter and I’m impressed by someone’s work. So I really do not have anyone off the top of my head.

body:
It’s an everyday occurrence that I go on Twitter and I’m impressed by someone’s work. So I really do not have anyone off the top of my head.

I can just start listing all the people I follow, but if anyone wants to know who I follow, just go and look at my Following list on Twitter.

A lot of design engineers typically don’t follow too many people, so check out their lists and you can just start looking at people’s stuff.


id: interviews/henry-heffernan/henry-38-02
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (38:02)
url_path: /lessons/interviews/henry-heffernan/#henry-38-02
markdown_path: /md/sections/interviews/henry-heffernan/henry-38-02.md
excerpt:
Okay, I guess that’s it for the interview. Is there like anything else you want to to say? This is your time.

body:
Okay, I guess that’s it for the interview. Is there like anything else you want to to say? This is your time.


id: interviews/henry-heffernan/henry-38-13
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Henry (38:13)
excerpt: I don’t have anything to push, right now.
url_path: /lessons/interviews/henry-heffernan/#henry-38-13
markdown_path: /md/sections/interviews/henry-heffernan/henry-38-13.md
body:
I don’t have anything to push, right now.

I’m trying to post more things on Twitter. I have a few fun things lined up. Which is very exciting.

Follow me on Twitter, at @henryheffernan.


id: interviews/henry-heffernan/emil-38-26
lesson_id: interviews/henry-heffernan
module_slug: interviews
module_title: Interviews
lesson_slug: henry-heffernan
lesson_title: Interviews/Henry Heffernan
heading: Emil (38:26)
excerpt: Okay.
url_path: /lessons/interviews/henry-heffernan/#emil-38-26
markdown_path: /md/sections/interviews/henry-heffernan/emil-38-26.md
body:
Okay.

Yeah, follow Henry on Twitter and and he will post some very exciting stuff soon.

So this was the first interview. Thank you for listening. And there actually at the time of this record of when this gets published, there is already another interview with Mariana, who also works at Vercel as well so you can check this interview out in case you haven’t already, but that’s it.

Thank you Henry for your time.


id: interviews/lochie-axon/overview
lesson_id: interviews/lochie-axon
module_slug: interviews
module_title: Interviews
lesson_slug: lochie-axon
lesson_title: Interviews/Lochie Axon
heading: Overview
url_path: /lessons/interviews/lochie-axon/#overview
markdown_path: /md/sections/interviews/lochie-axon/overview.md
excerpt:
Lochie is a design engineer working at Avara. He built the Family site, and docs, ConnectKit, Avara’s site, and many other great things. In this conversation we’ll how Lochie got into design engineering, how he thinks about animations, and how he builds them.

body:
Henry Heffernan Mariana Castilho Lochie Axon Dennis Brotzky

Lochie is a design engineer working at Avara. He built the Family site, and docs, ConnectKit, Avara’s site, and many other great things. In this conversation we’ll how Lochie got into design engineering, how he thinks about animations, and how he builds them.


id: interviews/lochie-axon/lochie-00-00
lesson_id: interviews/lochie-axon
module_slug: interviews
module_title: Interviews
lesson_slug: lochie-axon
lesson_title: Interviews/Lochie Axon
heading: Lochie (00:00)
url_path: /lessons/interviews/lochie-axon/#lochie-00-00
markdown_path: /md/sections/interviews/lochie-axon/lochie-00-00.md
excerpt:
I started like with animation in particular, I started in school with like flip books, that’s like, I’ve always loved animation. And even before that, I had my dad’s. camcorder, and I would make stop motion with it.

body:
I started like with animation in particular, I started in school with like flip books, that’s like, I’ve always loved animation. And even before that, I had my dad’s. camcorder, and I would make stop motion with it.

We had to stop start the recording constantly, and I had little toys that I would animate around. So I’ve always been into animation in general, but my first experience with coding animation was, again, as a kid we had a computer. And I found, I don’t know what the software is called, but it’s a turtle that you can animate on the screen with code.

And that’s all you could do. Just draw. You can tell it to go north 30 pixels, south 40 pixels, and it’ll go up and down. And that just. Piqued my interest so much, but then nothing, there was no development for a good, like five or six years until I learned about Macromedia Flash and then that I would have been a teenager at that point.

And then, yeah, just started learning that and learning how to animate in that 2D software and then found out you can code in there as well. So that brought all that around. I went back to coding again, and then I started making some flash games and started posting them on newgrounds. com and did pretty well there for, especially for the eight.

Like I was, I would have been 15 when I posted my first like video game that I made. And then I started getting sponsorships for them and started generating some income and that was, that would have been. At the peak of like Flash games era. And then when I turned 18, I started applying for jobs, but that’s the exact same time I started applying for jobs using Flash, but that’s the exact same time when Flash started dying out.

With the whole Flash is dead thing where Steve Jobs just didn’t want, like he did a whole press release about not wanting Adobe Flash at that point. Flash Player doesn’t, he didn’t want that to exist anymore. It was pretty crazy to think this guy just kind of shut down his whole product. But yeah, so I had to pivot from Flash to HTML5.

Because that just started becoming a thing. This is a good 15 years ago now. 2000 and, yeah, 2010. So, yeah, so I had to pivot to HTML5 and learn but there was no CSS 3 animation sort of stuff yet, like anything that worked across browsers, so I had to do everything with JavaScript and jQuery only did a little bit of that sort of stuff that I wanted to achieve.

So I just started writing my own libraries and then I started working for agencies who saw my work. I did some smaller stuff and then I kept moving into banner ads and started making banner ads where I broke my own library because GreenSock was too heavy at the time because you could only fit a banner ad in 40 kilobytes.

So the GreenSock library was 8 kilobytes and that used up too much space. In my eyes, cause I wanted a high quality images. So instead I wrote my own managed to get down to zero 0. 4 kilobytes for just a really basic light version of it. Cause that’s all you really needed for that sort of stuff. And yeah, just managed to produce high quality banner ads, even though no one wants to see them.

I don’t think many people cared if they were high quality or not. So it was a bit disappointing to put all that effort. Into something that people didn’t want to see and then I’ve just basically been trying to chase a way to get out of that So then at some point I moved back to Video games and I started doing Mobile games in doing like triple a mobile games, and I just wasn’t a fan I thought going back to video games would have been great, but just wasn’t for me too much politics involved In that it just didn’t feel like it didn’t feel like they were trying to create a product.

It felt like more, they were just trying to make something that would generate as much revenue as possible rather than something that was as high quality as possible, which in turn would generate revenue. But yeah, so it just, it felt like a little bit of a, just what you assume, like the mobile game hype would have been a good.

just like everyone just trying to make as much stuff as they could. So I jumped out of that, couldn’t do that anymore. And then went back to some agency work. And then started posting some stuff on Twitter. Met a few people that were in, in the sort of startup industry and just had a few people shout my name out and then managed to find my way to meet Benji.

And then started working at family. It was a really, and it was like a good 10 years of not quite finding what I wanted. And then finally finding this sort of location, this sort of industry where they actually care about what you make and how you make it. And. Just all the user experience stuff behind it as well.

Just making sure that people enjoy what they’re actually using It’s like I love this sort of stuff way better than anything else.


id: interviews/lochie-axon/lochie-05-34
lesson_id: interviews/lochie-axon
module_slug: interviews
module_title: Interviews
lesson_slug: lochie-axon
lesson_title: Interviews/Lochie Axon
heading: Lochie (05:34)
url_path: /lessons/interviews/lochie-axon/#lochie-05-34
markdown_path: /md/sections/interviews/lochie-axon/lochie-05-34.md
excerpt:
Essentially, I got my work through Twitter. There was a few other things Associated to that because there was a couple of the other that came from people posting on Twitter.

body:
Essentially, I got my work through Twitter. There was a few other things Associated to that because there was a couple of the other that came from people posting on Twitter.

So I would go join them and join those little social networks that were coming out. Like little apps. One was called, I don’t know if you recall, it was called screen wall. It kind of shut down a few years ago.


