Jakke.fi logo
jakke.fi/blog

Blogging mostly about my free-time projects.

HomeBloganimating-with-framer-motion-half-circle-towards-origin

Animating with Framer Motion (Half-Circle towards Origin)

@Type: Report

@Published:November 10, 2024 at 11:05

@Last Updated:November 12, 2024 at 17:16

@Author: Jakke Korpelainen

Intro

This was a bit wild to attempt to create a title for. Basically I wanted to create this animation using Framer Motion (framer.com/motion) in which I spawn entities inside a half circle from an origin. In the animation, I'm animating icons that represent data sources that fly towards a server, implying that the server is consuming various amounts and types of data. For icons I'm using the lucide.dev library. Also included a minor detail that the server svg light is blinking in sync.

This animation is inside a nextjs application. On the same page other Framer Motion powered animations are also present.

Source & Demo

Source: N/A (in report)

Demo: smartdatahub.io/about

animation gif

Study

Animation Logic

helper.ts

Created a helper for randomizing a point inside a half circle.

1export function pickPointInHalfCircle(
2  cx: number = 0,
3  cy: number = 0,
4  r: number = 200,
5): { x: number; y: number } {
6  const theta = Math.random() * Math.PI;
7  const u = Math.random() + Math.random();
8  const r2 = u > 1 ? 2 - u : u;
9  const x = cx - r2 * r * Math.cos(theta);
10  const y = cy - r2 * r * Math.sin(theta);
11
12  // bounds could perhaps be configurable
13  const clampedY: number = Math.min(y, -150);
14  const clampedX = Math.max(Math.min(x, 150), -150);
15
16  return { x: clampedX, y: clampedY };
17}
18
19export const getRandomDelay = () => -(Math.random() * 0.7 + 0.05);
20
21export const randomDuration = () => Math.random() * 0.07 + 0.23;
22
23

AnimatedPipeline/index.tsx

1'use client';
2
3import { AnimatePresence, motion } from 'framer-motion';
4import {
5  ArrowDown,
6  FileArchive,
7  FileBox,
8  FileDigit,
9  FileJson2,
10  FileLineChart,
11  FilePieChart,
12  FileScan,
13  FileSpreadsheet,
14  FileTextIcon,
15  LandPlot,
16  TableProperties,
17} from 'lucide-react';
18import { useEffect, useState } from 'react';
19import { v4 } from 'uuid';
20
21import { AnimatedPipelineItem } from './Item';
22
23const classes = {
24  icon: 'text-secondary-readable/[0.5]',
25};
26
27const pipelineItemIcons = [
28  <LandPlot className={classes.icon} key="land-plot-icon" />,
29  <FileSpreadsheet className={classes.icon} key="file-scan-icon" />,
30  <FileScan className={classes.icon} key="file-scan-icon" />,
31  <FilePieChart className={classes.icon} key="file-pie-chart-icon" />,
32  <FileLineChart className={classes.icon} key="file-line-chart-icon" />,
33  <FileBox className={classes.icon} key="file-box-icon" />,
34  <FileJson2 className={classes.icon} key="file-json2-icon" />,
35  <FileArchive className={classes.icon} key="file-archive-icon" />,
36  <FileDigit className={classes.icon} key="file-digit-icon" />,
37  <FileTextIcon className={classes.icon} key="file-text-icon" />,
38] as const;
39
40const getRandomIcon = () => {
41  return pipelineItemIcons[
42    Math.floor(Math.random() * pipelineItemIcons.length)
43  ];
44};
45
46export const ANIMATION_SPEED_MS = 750;
47
48function AnimatedPipeline() {
49  const [data, setData] = useState<{ id: string; icon: React.ReactElement }[]>(
50    [],
51  );
52
53  useEffect(() => {
54    const interval = setInterval(() => {
55      setData(() => [{ id: v4(), icon: getRandomIcon() }]);
56    }, ANIMATION_SPEED_MS);
57
58    //Clearing the interval
59    return () => clearInterval(interval);
60  }, []);
61
62  return (
63    <div className="flex items-center justify-center">
64      <div className="absolute mb-[4rem] select-none">
65        <AnimatePresence>
66          {data.map((i) => (
67            <AnimatedPipelineItem item={i} key={i.id} />
68          ))}
69        </AnimatePresence>
70      </div>
71
72      <div
73        style={{
74          backgroundImage:
75            'radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 1) 35%, transparent 60%, transparent 100%)',
76        }}
77        className="z-10 pt-10 px-20 mb-10"
78      >
79        <div className="items-center justify-center flex flex-col space-y-2">
80          <div className="relative">
81            <div className="w-20 h-20">
82              <motion.svg
83                xmlns="http://www.w3.org/2000/svg"
84                viewBox="0 0 24 24"
85                fill="none"
86                stroke="currentColor"
87                strokeWidth="1"
88                strokeLinecap="round"
89                strokeLinejoin="round"
90                className="text-primary w-20 h-20"
91                initial="hidden"
92                animate="visible"
93              >
94                <path d="M4.5 10H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-.5" />
95                <path d="M4.5 14H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2h-.5" />
96                <motion.path
97                  animate={{
98                    color: ['#4f0063', 'hsl(119, 100%, 30%)'],
99                    strokeWidth: [2, 2.1],
100                  }}
101                  transition={{
102                    duration: 0.75,
103                    ease: 'easeInOut',
104                    repeat: Infinity,
105                    delay: 0.75,
106                    repeatType: 'loop',
107                    repeatDelay: 0,
108                  }}
109                  strokeWidth={2}
110                  d="M6 6h.01"
111                />
112              </motion.svg>
113            </div>
114          </div>
115          <ArrowDown className="text-primary/[0.5]" />
116          <div className="flex items-center gap-2">
117            <TableProperties className="text-primary w-7 h-7" />
118            <span className="font-mono text-sm">Data Catalog</span>
119          </div>
120        </div>
121      </div>
122    </div>
123  );
124}
125
126export default AnimatedPipeline;
127

AnimatedPipeline/Item.tsx

1import { motion } from 'framer-motion';
2
3import { ANIMATION_SPEED_MS } from '.';
4import { pickPointInHalfCircle } from '../helper';
5
6export const AnimatedPipelineItem = ({
7  item,
8}: {
9  item: { id: string; icon: React.ReactNode };
10}) => (
11  <motion.div
12    key={`animation-${item.id}`}
13    className="absolute flex shadow-xl"
14    transition={{
15      duration: (ANIMATION_SPEED_MS / 1000) * 1.5,
16      ease: 'easeInOut',
17      times: [0, 0.2, 0.5, 0.8, 1],
18    }}
19    initial={{ ...pickPointInHalfCircle(), opacity: 0 }}
20    animate={{
21      x: 0,
22      y: 0,
23      opacity: 1,
24    }}
25  >
26    {item.icon}
27  </motion.div>
28);
29