For the best experience, please use a desktop for our interactive courses.

How to Create a Scroll Animated Portfolio With React

Getting Started with CSS animations in React

Ferenc Almasi β€’ 2023 October 10 β€’ Read time 91 min read

Scroll-animated websites can create captivating user experiences, and making a portfolio interactive with animations can stand on its own as a great way to showcase your skills.

In this interactive course, we'll take a look at how to build a portfolio from scratch, animated through scrolling, using React and TypeScript. Before we begin, let's take a look at what we're going to build.

If you'd like to jump to a specific point of interest, you can use the sidebar to navigate between lessons. Each lesson will contain code changes from previous lessons, and the source code for the lessons can also be found on GitHub.

What We Will Build

We'll create three different sections for our portfolio, each with unique animations:

  • Hero: When the page opens, we'll introduce the user to a hero section with a reveal animation. Here we'll have a logo, a navigation menu, a simple h1 with a subtitle using h2, and a call to action.
  • Projects: The projects section will be revealed as we scroll. We'll look into how to create a linear interpolation function (called lerp) to animate CSS properties tied to the scroll position. We'll also explore how to create a 3D card hover effect with a glow on hover.
  • Contact: Lastly, we'll take a look at how to build a basic contact form and create a beating heart animation.
The design and animations of the portfolio
Scroll-animated portfolio in React + TypeScript

Prerequisites

While we'll go into detail and cover most aspects, this course assumes you have a basic understanding of HTML, CSS, JavaScript, and React. This course won't cover how to use these technologies from scratch. If you're new to any of them, we recommend checking out our roadmaps, where you can learn everything from the start.

Master Frontend

The Project Structure

In this project, we'll be using Vite, a front-end tool for building front-end projects. It comes with many different commands to bootstrap projects with different configurations. It supports the following template presets:

JavaScriptTypeScript
vanillavanilla-ts
vuevue-ts
reactreact-ts
preactpreact-ts
litlit-ts
sveltesvelte-ts
solidsolid-ts
qwikqwik-ts

We'll be using the react-ts template for this project. This lesson has already been set up with this template. However, you can create a new Vite project by using the following command in your terminal:

Copied to clipboard!
npm create vite@latest my-app -- --template react-ts
Command for creating a new Vite project with React + TypeScript

If you're interested in seeing what a brand new React + TypeScript Vite project looks like, you can check out the following StackBlitz project: React + TypeScript template

Open the sidebar to view the current project files. Each Vite project comes with the following folders and files that we'll make use of:

  • public: The public folder is where we can store static assets such as fonts, images, or icons. Anything placed here will remain unaltered by Vite and will be copied to the build folder as-is. This means these files will not be bundled or minified.
  • src: The source code of the project, including components, utility functions, and styles, will be created within the src folder.
  • tsconfig.json: Since this is a TypeScript project, we have a tsconfig.json file at the project's root that configures how TypeScript behaves. We'll use it later to set up absolute imports.
  • vite.config.ts: Vite comes with its own configuration file where we can set up plugins, build configurations, and import aliases. We'll use this in conjunction with the TypeScript configuration file to create absolute imports later on.

Setting Up The Project

Let's begin at the very start by setting up the project. Inside the editor, create a new folder named components, and add three subfolders for the three different sections: Hero, Projects, and Contact.

Inside each of these subfolders, create an index.ts file, a .tsx file with the component's name, and an .scss file for the styles. This means you should end up with the following project structure:

Copied to clipboard!
src
β”œβ”€ components
β”‚  β”œβ”€ Contact
β”‚  β”‚  β”œβ”€ contact.scss
β”‚  β”‚  β”œβ”€ Contact.tsx
β”‚  β”‚  β”œβ”€ index.ts
β”‚  β”œβ”€ Hero
β”‚  β”‚  β”œβ”€ hero.scss
β”‚  β”‚  β”œβ”€ Hero.tsx
β”‚  β”‚  β”œβ”€ index.ts
β”‚  β”œβ”€ Projects
β”‚  β”‚  β”œβ”€ projects.scss
β”‚  β”‚  β”œβ”€ Projects.tsx
β”‚  β”‚  β”œβ”€ index.ts
The structure of the components folder

By following this project structure, we can keep components and their styles together. As you can see, we're using Sass in this setup.

Copied to clipboard!
npm i sass
Sass documentation
Command to install Sass for new projects

Vite natively supports Sass, but it's still necessary to have the Sass package installed. This has already been taken care of in this project. Inspect the contents of the package.json file to confirm that Sass is included. For now, we can leave the .scss files empty. In each component file, export an h1 so we can verify they appear when we include the components:

Copied to clipboard!
export const Hero = () => {
    return (
        <h1>Hero</h1>
    )
}
Hero.tsx
Create the base of the components

Repeat the same process for the other files, using appropriate component names. In the index.ts files, simply re-export these components to simplify imports in the future. Inside each index.ts file, add the following line with the correct paths:

Copied to clipboard!
export * from './Hero'
index.ts
Re-export components
Copied to clipboard! Playground
// Import without index.ts:
import { Hero } from './components/Hero/Hero'

// Import with index.ts:
import { Hero } from './components/Hero'
Imports without and with index files

To finish this first lesson, import the components into App.tsx, and replace the contents of the file with the three components in the following way:

Copied to clipboard!
import React from 'react'

import { Hero } from './components/Hero'
import { Projects } from './components/Projects'
import { Contact } from './components/Contact'

export const App = () => {
    return (
        <React.Fragment>
            <Hero />
            <Projects />
            <Contact />
        </React.Fragment>
    )
}
App.tsx
Replace the contents of App.tsx

Create the Hero Component

We'll start creating the portfolio from top to bottom. However, before we begin, let's set up a configuration file to store all the information about the portfolio in one place.

This way, we can easily make changes in the future if necessary, without having to modify the components directly. In the project's root directory, create a new file called config.ts and export the following object:

Copied to clipboard! Playground
export const config = {
    title: 'Short H1 title of your <b>portfolio</b>',
    subTitle: 'Subtitle, where you can further elaborate about the purpose of your work.',
    navigation: [
        'Projects',
        'Contact'
    ],
    heroCTA: 'Explore Projects',
}
config.ts
Create the configuration file

We'll use the exported information from this object across all three components. In the Hero component, import the config object. Inside the component, destructure each property from the object. If you get stuck on how to do this, you can find the solution below.

Copied to clipboard!
import { config } from '../../../config.ts'

export const Hero = () => {
    const {
        title,
        subTitle,
        navigation,
        heroCTA
    } = config

    return ( ... )
}
Hero.tsx
Destructure the properties from the config object

We'll display each of these properties in the appropriate place inside the return statement. Remove everything from the return statement and replace it with the following code to create the layout for the Hero component:

Copied to clipboard!
return (
    <section className="hero">
        <div className="container">
            <div className="logo">
                <img
                    src="/assets/img/logo.svg"
                    alt="logo"
                    width="75"
                    height="30"
                />
            </div>
            <h1 dangerouslySetInnerHTML={{ __html: title }} />
            <strong>{subTitle}</strong>

            <div className="hamburger">
                <span className="slice"></span>
                <span className="slice"></span>
                <span className="slice"></span>
                <span className="slice"></span>
            </div>
            <ul className="menu">
                {navigation.map((link, index) => (
                    <li className="menu-item" key={index}>
                        {link}
                    </li>
                ))}
            </ul>
            {heroCTA}
        </div>
    </section>
)
Hero.tsx
Create the layout for the Hero component

Let's review what we did for this component:

  • Line 3: We're using a container inside the .hero section to ensure that the main content of the portfolio remains centered for larger screen sizes. We'll achieve this by setting the max-width of the container to 1200px.
  • Lines 5-10: In Vite, we can reference static assets from the root of the public folder. For images, always provide an alt tag to improve accessibility, as well as width and height attributes to prevent layout shifts. Since this image is above the fold, there's no need for lazy loading.
  • Line 12: The title will be displayed within an h1 tag. As the title variable that is exported from the config file contains HTML tags, we need to use the dangerouslySetInnerHTML prop to correctly render the HTML elements inside the h1 tag.
  • Line 13: The subtitle is wrapped in a strong tag. We'll use h2 tags for the projects and contact sections.
  • Lines 15-27: We'll use a hamburger menu for the navigation. There are no links involved, as we'll handle scrolling through JavaScript.

At the end of the container, we'll have the heroCTA, but we're currently missing the button. As all buttons will share the same style in the portfolio, let's create a component for it.

layout of the hero component
Layout of the Hero component

Create the Button component

To add the button, create a new folder inside the components folder called Button and add the three files (index file, .tsx, and .scss) similar to the other components.

Copied to clipboard!
src
β”œβ”€ components
β”‚  β”œβ”€ Button
β”‚  β”‚  β”œβ”€ button.scss
β”‚  β”‚  β”œβ”€ Button.tsx
β”‚  β”‚  β”œβ”€ index.ts
Structure of the Button folder

We can leave the scss file empty for now. For the Button component, add the following lines of code:

Copied to clipboard!
import React from 'react'

import './button.scss'

export type ButtonProps = {
    children: React.ReactNode
    onClick: React.MouseEventHandler<HTMLButtonElement>
    className?: string
}

export const Button = ({
    children,
    onClick,
    className
}: ButtonProps) => {
    return (
        <button
            onClick={onClick}
            className={className}
        >
            {children}
        </button>
    )
}
Button.tsx
Add the Button component

When working with typed React components, it's a good practice to export types so that the same types can be reused elsewhere in the project. For buttons, we only need three props at this time:

  • children: Anything passed between the Button component will be displayed within the button element. In React, we can type children as React.ReactNode.
  • onClick: Buttons will trigger specific actions, and for this, we can pass an onClick prop. Click events can be typed using React.MouseEventHandler, which is a generic type, allowing us to specify the triggering element.
  • className: We also want to pass class names to style the buttons. This is an optional prop denoted by ?.

In the index.ts file, re-export the Button component from Button.tsx:

Copied to clipboard!
export * from './Button'
index.ts
Re-export the button component

To complete this lesson, import the Button component into Hero.tsx, and wrap the heroCTA variable with the component, including an empty onClick event handler. In the next lesson, we'll dive into configuring Sass, adding resets, and styling the Button component.

Copied to clipboard!
// Import button at the top
+ import { Button } from '../Button'

- {heroCTA}
+ <Button onClick={() => {}}>
+     {heroCTA}
+ </Button>
Hero.tsx
Use the Button component in Hero.tsx

Configure Sass

In this lesson, we'll look at how to configure Sass to make applying styles to elements easier down the road. We'll introduce some resets, as well as mixins and variables to share styles across different elements for a consistent look and feel. Create a config folder inside the scss folder, and add the following four new files:

  • color-palette.scss: This file is dedicated to colors. Separating colors into a distinct file not only makes it easier to maintain color consistency and make changes later on but also simplifies the process of introducing new themes to a project.
  • mixins.scss: We'll take a quick look at how to create mixins in Sass for reusable code. Again, this helps in maintaining consistent styles across components.
  • typography.scss: Just like for colors, we want to have a consistent typography style with specified font types and sizes. This file will contain the necessary variables for typography.
  • variables.scss: Any elements that don't fit into the above three files will be defined in this file.
Copied to clipboard!
src
β”œβ”€ scss
β”‚  β”œβ”€ config
β”‚  β”‚  β”œβ”€ color-palette.scss
β”‚  β”‚  β”œβ”€ mixins.scss
β”‚  β”‚  β”œβ”€ typography.scss
β”‚  β”‚  β”œβ”€ variables.scss
β”‚  β”œβ”€ resets.scss
The structure of the scss folder

This categorization is not mandatory for a good project structure. You can organize your files in a meaningful way that makes the most sense to you.

Adding Colors

For this simple portfolio, we'll only work with six individual colors. Open the color-palette.scss file and add the following variables:

Copied to clipboard!
$black: #000;
$white: #FFF;
$off-white: #777;
$primary: #6A59FF;
$primary-dark: #0B0063;
$error: #e74c3c;
color-palette.scss
Add colors

In Sass, variables are created by prefixing their names with a dollar sign. By importing this file, we can access these colors.

Except for $black and $white, it's best to avoid using color names for variables to prevent variable names from becoming meaningless and needing updates after a color change. For instance, if we initially name $primary as $purple and later change the primary color to blue, $purple would lose its meaning. On the other hand, $primary remains meaningful.

Adding typography

For typography, we want to define two font types: regular and bold, along with font weights and sizes for each. To achieve this in Sass, we can use maps. Open the typography.scss file and add the following variables:

Copied to clipboard!
$fontTypes: (
    'regular':  (Regular, sans-serif),
    'bold':     (Bold, sans-serif)
);

$fontWeights: (
    'regular':  400,
    'bold':     600
);

$fontSizes: (
    'xs':       12px,
    'sm':       14px,
    'md':       16px,
    'regular':  18px,
    'l':        21px,
    'xl':       24px,
    '2xl':      28px
);
typography.scss
Create typographies

A map is a collection of values represented by key-value pairs. Values can include CSS units, such as in the $fontSizes variable, or even lists, which is another structured data type in Sass. Lists are comma-separated sequences of values.

The $fontTypes variable references a custom font that we'll import into the project after we finish setting up our Sass variables.

Adding mixins

To easily use these typography rules, we can create mixins β€” functions for encapsulating styles. In the mixins.scss file, add the following three mixins:

Copied to clipboard!
@mixin media($size: 'xs') {
    $mediasizes: (
        'xs': 600px,
        'sm': 800px,
        'md': 1024px,
        'lg': 1200px
    );
    
    @media (min-width: #{map-get($mediasizes, $size)}) {
        @content;
    }
}

@mixin fontType($type: 'regular') {
    font-family: #{map-get($fontTypes, $type)};
    font-weight: #{map-get($fontWeights, $type)};
}

@mixin fontSize($size: 'regular') {
    font-size: #{map-get($fontSizes, $size)};
}
mixins.scss
Add the mixins

Mixins are defined using the @mixin <mixinName> { ... } syntax. When a mixin is used, all CSS-related content within the mixin is generated into the stylesheets. These mixins allow us to access the following functionalities:

  • media: Use predefined media queries. Mixins can accept parameters just like JavaScript functions. Inside the mixin, we have a $mediasizes map with different screen sizes. To create a mobile-first media query when the mixin is invoked, we can use the map-get function to retrieve a value ($size) from the provided map ($mediasizes).
    • Note that we need to use the #{} interpolation to include Sass functions. Anything inside the mixin is placed in place of the @content keyword, which is called a content block.
  • fontType: We can follow a similar pattern for font types. This mixin relies on the variables defined in typography.scss. We can also set default values for mixin parameters, making them optional when calling the mixin. This way, the mixin defaults to a regular font type.
  • fontSize: Similar to the fontType mixin, but it requires passing the $fontSizes map to the map-get function.

You can also use map.get, which will replace map-get in future versions of Sass.

Extra variables

Finally, in the variables.scss file, we can include variables that don't fit into the previously created categories. For now, we need the following two variables defined in this file:

Copied to clipboard!
$radius: 4px;
$easing: cubic-bezier(.55, 0, .1, 1);
variables.scss
Define variables for border-radius and easing

Resetting styles

Now that all configurations are in place, let's focus on adding reset styles. To use these configurations, we need to import the files. However, to avoid importing them individually, create a config.scss file next to resets.scss and include the following:

Copied to clipboard!
@import './config/color-palette';
@import './config/mixins';
@import './config/typography';
@import './config/variables';
config.scss
Import everything from the config folder

We can import this file into resets.scss to automatically import other configurations for us. Open resets.scss and add an import for config.scss, along with the following CSS rules:

Copied to clipboard!
@import './config';

@font-face {
    font-family: Regular;
    font-display: swap;
    src: url('/assets/fonts/Inter-Regular.woff2') format('woff2');
}

@font-face {
    font-family: Bold;
    font-display: swap;
    src: url('/assets/fonts/Inter-Bold.woff2') format('woff2');
}

body {
    @include fontType('regular');
    @include fontSize('regular');
    background: $black;
    color: $white;
    margin: 0;
}
resets.scss
Creating the first CSS resets

This is where we define custom fonts. All static assets are already available in the public folder for this project. When specifying the font-family property, we use the same names we've set in the $fontTypes variable in typography.scss.

It's recommended to use font-display: swap when loading custom fonts to reduce layout shifts during font loading. This will improve our core web vitals score.

Notice how we can use mixins and variables:

Copied to clipboard!
// Sass also allows the use of comments with "//"
body {
    @include fontType('regular'); // Mixin
    @include fontSize('regular'); // Mixin
    background: $black;           // Variable
    color: $white;                // Variable
    margin: 0;
}
resets.scss
How to use mixins and variables

We also want to add a couple of resets for other elements to remove unnecessary margins and padding. This is also a good place to add styles for reusable classes, such as for the .container class. Extend resets.scss with the following rules:

Copied to clipboard!
section {
    min-height: 100vh;
    padding: 40px;
    box-sizing: border-box;
    position: relative;
}

h1,
h2 {
    margin: 0;
}

ul {
    list-style-type: none;
    padding: 0;
    margin: 0;
}

p {
    line-height: 1.4;
    margin: 0;
}

a {
    text-decoration: none;
}

.container {
    max-width: 1200px;
    margin: 0 auto;
}

@include media('xs') {
    section {
        padding: 120px;
    }
}
resets.scss
Add resets for the rest of the elements

There's one important rule to point out: section elements need to have at least a minimum height of 100vh to ensure each section completely fills up the screen.

Using the media mixin, we can easily create mobile-first media queries for elements. Keep in mind that anything written inside the mixin will be placed in place of the @content keyword.

Styling the Button component

Now that we've completed the configurations and resets, we can begin styling and animating the Hero component. However, as a final step for this lesson, let's create the styles for the Button component. Open button.scss and include the following styles:

Copied to clipboard!
@import '../../scss/config';

button {
    @include fontType('bold');
    @include fontSize('md');
    border: 0;
    background: $primary;
    color: $white;
    padding: 15px 20px;
    border-radius: $radius;
    cursor: pointer;
}
button.scss
Add styles for the Button component

For buttons, we want to make them bold and slightly reduce their font size. We'll use the $primary color for the background and $white as the text color to ensure proper color contrast. We also make use of the $radius variable here. Now that we have everything prepared for the Hero component, let's see how to style and animate the elements.

Animate the Hero Component

To style the Hero component, we first need to import the hero.scss file below the config import in Hero.tsx:

Copied to clipboard!
import './hero.scss'
Hero.tsx
Import the hero.scss to the Hero component

Next, open hero.scss, import the Sass config file, and add the following rules to style the main elements:

Copied to clipboard!
@import '../../scss/config';

.hero {
    background: url('/assets/img/hero.png');
    background-size: cover;
    background-position: bottom;
    background-repeat: no-repeat;
    
    .logo {
        height: 30px;
        overflow: hidden;
        position: absolute;
        top: 50px;
        left: 50px;
        z-index: 1;
        animation: LogoAnimation 1s $easing forwards;
    }
    
    h1 {
        @include fontType('bold');
        font-size: 38px;
        max-width: 400px;
        margin-top: 120px;
        
        b {
            color: $primary;
        }
    }
    
    strong {
        @include fontType('regular');
        @include fontSize('l');
        max-width: 450px;
        display: block;
        line-height: 1.4;
        margin-top: 20px;
    }
    
    button {
        position: absolute;
        bottom: 50px;
        left: 50%;
        transform: translateX(-50%);
    }
}

@include media('xs') {
    .hero h1 {
        margin-top: 30px;
        font-size: 48px;
    }
}
hero.scss
Style the main elements for the Hero component

Let's break down the rules to understand why they're necessary:

  • Lines 4-7: To make the hero image cover the entire screen, we need to set background-size to cover and position the image at the bottom of the container by setting background-position to bottom.
  • Lines 9-17: The logo will have a writing animation, revealing the logo from left to right by animating the width of its container. To achieve this, we need to set overflow to hidden and use animation-fill-mode: forwards to ensure the animation stops after completion. We'll create the LogoAnimation in a moment.
  • Line 26: The h1 can accept b tags. Here we can set its color to $primary to allow us to emphasize words visually.
  • Lines 39-44: We want to position the CTA at the bottom center of the screen. When using left: 50%, the anchor position is located in the top-left corner of the element. To ensure it's centered, we need to use translateX(-50%) to offset it by 50% of its width.
Logo and menu animation
Logo and menu animation

To create the LogoAnimation, create a new file named animations.scss at the root of the scss folder and add the following:

Copied to clipboard!
@keyframes LogoAnimation {
    0% { width: 0; }
    100% { width: 75px; }
}
animations.scss
Add the LogoAnimation

We'll collect animations in this file. We animate the width from 0 to 75px, which is the width of the logo. To make animations available throughout the project, import this file in resets.scss after the config import.

Copied to clipboard!
@import './config';
@import './animations';
resets.scss
Import animations to resets.scss

Animating the menu

Now that we have an animated logo, let's focus on the hamburger menu. You'll notice from the gif that we visually have three bars, but there are four .slice elements inside Hero.tsx. The fourth element is hidden behind the middle bar.

hamburger menu animation
Menu animation in slow motion

This hidden element will be animated into a cross shape with rotation, while simultaneously hiding the top and bottom bars. To align the bars correctly, add the following rules to hero.scss after the button selector:

Copied to clipboard!
.hamburger {
    position: absolute;
    top: 50px;
    right: 50px;
    width: 40px;
    height: 25px;
    user-select: none;
    cursor: pointer;
    z-index: 1;
    
    &.animate-in .slice {
        opacity: 0;
        animation: MenuSlideIn .6s $easing forwards;
    }

    .slice {
        position: absolute;
        width: 100%;
        height: 3px;
        border-radius: $radius;
        background: $white;
        transition: all .3s $easing;
        
        &:first-child {
            top: 0;
        }
        
        &:nth-child(2),
        &:nth-child(3) {
            top: 45%;
            animation-delay: .2s;
        }
        
        &:last-child {
            bottom: 0;
            animation-delay: .4s;
        }
    }
}
hero.scss
Add styles for the hamburger

The important parts are highlighted. We have an animate-in class that initially sets the opacity of the .slice elements to 0. We'll create the MenuSlideIn animation for this shortly.

For the .slice elements, we can use absolute positioning to align them at the top (first-child), middle, and bottom (last-child) of the container. Note that the second and third children share the same position. These elements will be turned into a cross, and an additional class will animate them. Inside the .hamburger selector, add the following new rules:

Copied to clipboard!
&.close {
    .slice:first-child,
    .slice:last-child {
        opacity: 0;
    }
    
    .slice:first-child {
        transform: translateY(20px) scale(0);
    }
    
    .slice:last-child {
        transform: translateY(-20px) scale(0);
    }
    
    .slice:nth-child(2) {
        transform: rotate(45deg);
    }
    
    .slice:nth-child(3) {
        transform: rotate(-45deg);
    }
}
hero.scss
Add the styles for the close position
  • The first and last children are animated to the middle position, making them invisible by scaling and reducing opacity.
  • The second and third children are rotated by (+/-)45Β° to form a cross.

This class can be toggled through an onClick event listener in our Hero component. Open the Hero component and add the following new lines to introduce both .close and .animate-in classes:

Copied to clipboard!
import { useState } from 'react'

import { Button } from '../Button'

import { config } from '../../../config.ts'
import { classNames } from '../../utils.ts'

import './hero.scss'

export const Hero = () => {
    const [menuOpen, setMenuOpen] = useState(false)
    const [mounted, setMounted] = useState(false)
    
    const {
        title,
        subTitle,
        navigation,
        heroCTA
    } = config
    
    const toggleMenu = () => setMenuOpen(!menuOpen)
    
    setTimeout(() => {
        setMounted(true)
    }, 700)

    return (
        <section className="hero">
            <div className="container">
                <div className="logo">
                    <img
                        src="/assets/img/logo.svg"
                        alt="logo"
                        width="75"
                        height="30"
                    />
                </div>
                <h1 dangerouslySetInnerHTML={{ __html: title }} />
                <strong>{subTitle}</strong>

                <div
                    className={classNames([
                        'hamburger',
                        menuOpen && 'close',
                        !mounted && 'animate-in'
                    ])}
                    onClick={toggleMenu}
                >
                    <span className="slice"></span>
                    <span className="slice"></span>
                    <span className="slice"></span>
                    <span className="slice"></span>
                </div>
                <ul className="menu">
                    {navigation.map((link, index) => (
                        <li className="menu-item" key={index}>
                            {link}
                        </li>
                    ))}
                </ul>
                <Button onClick={() => {}}>
                    {heroCTA}
                </Button>
            </div>
        </section>
    )
}
Hero.tsx
Update the Hero component with the new classes

On line:47, we trigger the toggleMenu function when the hamburger menu is clicked, toggling the menuOpen state. This state determines whether to add the .close class to the hamburger menu.

We also have a mounted state that is set to true after 700ms using a setTimeout. This state is responsible for removing the .animate-in class from the hamburger menu after the animation completes.

We've set the animation duration to 600ms for the menu, so we can time the setTimeout to 700ms to remove the class only after the animation finishes.

We also have an import for a classNames function imported from a file called utils.ts. We don't have this file yet, so create the file in the same directory as App.tsx and export the following function:

Copied to clipboard!
export const classNames = (
  arr: Array<string | null | boolean | undefined>
): string | undefined => arr?.filter(Boolean).join(' ')
utils.ts
Export classNames from the utils file

This function will be responsible for conditionally applying class names to elements. It's a lightweight alternative to the popular classNames library. Now, whenever we click on the hamburger icon, it animates into the close state. However, we still need to create the animation for the .animate-in class. Go to animations.scss and add the following keyframes:

Copied to clipboard!
@keyframes MenuSlideIn {
    0% {
        opacity: 0;
        transform: translateX(20px);
    }
    100% {
        opacity: 1;
        transform: translateX(0);
    }
}
animations.scss
Create the animation for the menu

This creates a transform animation, sliding elements into view with a fade-in effect through opacity. As we applied animation delays to subsequent .slice elements, we'll have a sequence of animations:

Hamburger slice animations
Hamburger slices animated using transforms

Animating menu items

The navigation elements need styling to show only when the hamburger menu is active. To achieve this, extend the hero.scss file with the following CSS rules, just under the .hamburger element:

Copied to clipboard!
.menu {
    position: absolute;
    right: 50px;
    width: 100px;
    height: 100px;
    top: 100px;
    overflow: hidden;
    z-index: 1;
    
    &.visible .menu-item {
        right: 0;
    }
    
    .menu-item {
        position: absolute;
        right: -100%;
        transition: all 0.6s $easing;
        cursor: pointer;
        
        &:first-child {
            top: 0;
        }
        
        &:nth-child(2) {
            top: 30px;
            transition-delay: 0.1s;
        }
    }
}
hero.scss
Add styles for the menu

This absolutely positions the .menu element under the .hamburger, with fixed dimensions and overflow set to hidden. This allows us to create another slide-in animation for the menu items. By default, their right position is set to -100%, making them invisible as they overflow their container. When the navigation is toggled, this position is animated back to 0.

Whenever working with animating properties, be sure to include a transition property on the element to smoothly transition between the two states.

Of course, we also need to conditionally apply the .visible class to the menu. To do this, replace the ul inside Hero.tsx with the following:

Copied to clipboard!
- <ul className="menu">
+ <ul className={menuOpen ? 'menu visible' : 'menu'}>
Hero.tsx
Apply the visible class conditionally to ul

Intro animation

Now we have animated menus as well. A final touch for the Hero component is an intro animation that acts like a curtain pulling up after the logo and menu animations finish. Inside Hero.tsx, add the following element after the Button component:

Copied to clipboard!
<Button onClick={() => {}}>
    {heroCTA}
</Button>
<div className="curtain" />
Hero.tsx
Add the .curtain element after the Button

We'll style this element to fully cover the Hero component and slowly slide up after the logo and menu animations are finished, creating the following effect:

curtain animation
Curtain animation

We can achieve this by animating its height from 100% to 0. Open hero.scss and add the following styles for the .curtain element:

Copied to clipboard!
.curtain {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: $black;
    transition: height .3s $easing;
    animation: CurtainSlideUp .9s $easing forwards .6s;
}
hero.scss
Add styles for the .curtain element

This calls the CurtainSlideUp animation and plays it over 900ms. The animation is delayed by 600ms, so it starts only after the logo and menu animations finish. To add the CurtainSlideUp animation, open animations.scss and create a new animation that animates the height attribute from 100% to 0.

Copied to clipboard!
@keyframes CurtainSlideUp {
    0% { height: 100%; }
    100% { height: 0%; }
}
animation.scss
Animating the .curtain element

Adding scroll to menu items

There's one last thing missing from our Hero component, and that is scrolling to the appropriate sections when we click on one of the menu items. For this, we can create another utility function. Export the following function from utils.ts:

Copied to clipboard!
export const scrollTo = (element: string, index?: number) => index
    ? document
        .querySelectorAll(element)?.[index]
        ?.scrollIntoView({ behavior: 'smooth' })
    : document
        .querySelector(element)
        ?.scrollIntoView({ behavior: 'smooth' })
utils.ts
Export the scrollTo utility function

This function expects an element and an optional index. If an index is provided, it'll use querySelectorAll to select the correct element from a NodeList. Otherwise, it'll use querySelector, which returns a single element. Using scrollIntoView with behavior set to smooth, we can animate the scroll. Import this function into the Hero component and add the highlighted lines of code to enable scrolling:

Copied to clipboard!
...
import { classNames, scrollTo } from '../../utils.ts'

import './hero.scss'

export const Hero = () => {
    const [menuOpen, setMenuOpen] = useState(false)
    const [mounted, setMounted] = useState(false)

    const { ... } = config

    const scroll = (index: number) => {
        scrollTo('section', index)
        setMenuOpen(false)
    }
    
    const toggleMenu = () => setMenuOpen(!menuOpen)
    
    setTimeout(() => { ... }, 700)

    return (
        <section className="hero">
            <div className="container">
                <div className="logo">...</div>
                <h1 dangerouslySetInnerHTML={{ __html: title }} />
                <strong>{subTitle}</strong>

                <div>...</div>
                <ul className="menu">
                    {navigation.map((link, index) => (
                        <li
                            className="menu-item"
                            key={index}
                            onClick={() => scroll(index + 1)}
                        >
                            {link}
                        </li>
                    ))}
                </ul>
                <Button onClick={() => scroll(1)}>
                    {heroCTA}
                </Button>
            </div>
        </section>
    )
}
Hero.tsx
Add scrolling functionality to the Hero component

The scroll helper function closes the hamburger menu every time a menu item is clicked. We also want to attach the same functionality to the Button component, which triggers a scroll to the first section element, the container for the projects section. We'll build this next. With this, you've successfully created the Hero component!

Setup Projects Component

Now that we've successfully completed building the Hero component, let's move on to the next section, where we'll display a list of projects animated by the scroll position of the browser. In this section, we're going to display new information, just like we did for the Hero component. We'll source this information from our config file. Open config.ts and extend it with the following properties:

Copied to clipboard! Playground
export const config = {
    ...
    projectsTitle: 'My work',
    projectsDescription: 'Share some insights about the work you’ve done...',
    projects: [
        {
            url: 'link-to-github',
            icon: 'react',
            title: 'Project title',
            description: 'Project description',
            background: '#23272F',
        },
        {
            url:'link-to-project-page',
            icon: 'javascript',
            title: 'Project title',
            description: 'Project description',
            background: '#272327'
        },
        {
            url: 'link-to-netlify-app',
            icon: 'svelte',
            title: 'Project title',
            description: 'Project description',
            background: '#FF3E00',
            color: '#FFF'
        }
    ],
    projectsCTA: 'Get in Touch',
}
config.ts
Extend the config file with the project information

Each project will have the following properties that we'll use in our component within a loop:

  • url: A link where the card will lead the user upon click.
  • icon: The name of the icon in the icons folder. The URL to the image will be created dynamically.
  • title: The project's title in bold.
  • description: A brief description about the project, displayed under the title.
  • background: The background color of the project tile. We'll use a linear gradient to create a dynamic background from black to the color specified in this property.
  • color: The color of the description. If not set, it'll default to an off-white color. Otherwise, the color specified here will be used.
project card properties
Project card properties explained

Now that we have all the information ready, let's create the layout, and then we'll apply the styles. Open Projects.tsx and replace the component with the following code:

Copied to clipboard!
import { Button } from '../Button'

import { config } from '../../../config.ts'
import { scrollTo } from '../../utils.ts'

import './projects.scss'

export const Projects = () => {
    const {
        projectsTitle,
        projectsDescription,
        projects,
        projectsCTA
    } = config

    return (
        <section className="project-section">
            <div className="container">
                <ul className="projects">
                    {projects.map((project, index) => (
                        <li
                            key={index}
                            style={{
                                background: `linear-gradient(
                                    315deg,
                                    #000 0%,
                                    ${project.background} 100%
                                )`
                            }}
                        >
                            <a href={project.url}>
                                <img
                                    src={`/assets/icons/${project.icon}.svg`}
                                    alt={project.icon}
                                    width="50"
                                    height="50"
                                    loading="lazy"
                                />

                                <b>{project.title}</b>
                                <span style={{ color: project.color }}>
                                    {project.description}
                                </span>
                            </a>
                            <div className="texture" />
                        </li>
                    ))}
                </ul>
                <Button
                    onClick={() => scrollTo('.contact')} className="project-cta"
                >
                    {projectsCTA}
                </Button>
            </div>
        </section>
    )
}
Projects.tsx
Replace the code inside the Projects component

Just as we did with the Hero component, we use a .container inside this section too. To display the projects, we can use a simple map:

  • Lines 23-29: We can use the style attribute to dynamically create the background gradient. We use a linear gradient at a 315-degree angle, starting from #000 to project.background. To experiment with different gradient configurations, you can use tools such as cssgradient.io.
  • Lines 32-38: We create the full URL for the icon. For the alt text, we can use the icon name. Note that we need the width and height attributes to avoid layout shifts. As these icons are below the fold, we also need to lazy load them by setting the loading attribute to lazy.
  • Line 41: We can use the style attribute again to dynamically set the color for the project description. If no color is specified for the object, the style attribute will be omitted automatically.
  • Line 45: We also have an empty div with a class of .texture. This will be used to create a subtle texture for the cards via CSS.
  • Lines 49-53: For the button, we can reuse the previously created scrollTo function to trigger a scroll to the contact section.

Adding the Section component

Currently, we're missing the projectsTitle and projectsDescription variables from the layout. As they share the same style with the title and description in the contact section, we can create a new component for this part.

Design for the section component
Sections titles and descriptions share the same styles

Inside the components folder, create a new folder called Section and replicate the same structure as currently exists for the other components.

Copied to clipboard!
src
β”œβ”€ components
β”‚  β”œβ”€ Section
β”‚  β”‚  β”œβ”€ index.ts
β”‚  β”‚  β”œβ”€ section.scss
β”‚  β”‚  β”œβ”€ Section.tsx
Create the new files for the Section component

Once the files are created, add the following code to the Section.tsx file to create the Section component, and don't forget to re-export it through the index.ts file.

Copied to clipboard!
import React from 'react'

import './section.scss'

export type SectionProps = {
    title: string
    description: string
}

export const Section = ({
    title,
    description
}: SectionProps) => {
    return (
        <React.Fragment>
            <h2 className="section-title">
                {title}
            </h2>
            <p
                className="section-content"
                dangerouslySetInnerHTML={{ __html: description }}
            />
        </React.Fragment>
    )
}
Section.tsx
Create the Section component

This component will use an h2 and a p tag to display the title along with the description. As the description can contain HTML tags, we need to use the dangerouslySetInnerHTML prop on the p tag. To create the styles for this element, open section.scss and add the following code:

Copied to clipboard!
@import '../../scss/config';

.section-title {
    @include fontSize('xl');
    margin-bottom: 10px;
    color: $primary;
}

.section-content {
    max-width: 400px;
    
    a {
        color: #FFF;
        background: $primary;
        padding: 2px 4px;
        border-radius: $radius;
    }
}
section.scss
Create the styles for the section element

Inside the Projects component, import the Section component and include it before the ul element while passing projectsTitle and projectsDescription as props.

Copied to clipboard!
import { Section } from '../Section'

export const Projects = () => {
    const { ... } = config

    return (
        <section className="project-section">
            <div className="container">
                <Section
                    title={projectsTitle}
                    description={projectsDescription}
                />
                <ul className="projects">
Projects.tsx

Styling the projects section

We have everything in place to start applying styles for the project section. We can get the styling done relatively quickly with just a couple of rules. Open projects.scss and add the following styles:

Copied to clipboard!
@import '../../scss/config';

.project-section {
    background: radial-gradient(ellipse at bottom center, $primary-dark 0%, $black 75%);
    
    .projects {
        display: grid;
        gap: 20px;
        margin-top: 40px;
        
        li {
            border-radius: $radius;
            padding: 15px;
            background: $black;
            transition: all .3s ease-out;
            position: relative;
            
            .texture {
                border-radius: $radius;
                background-image: url('/assets/img/card.png');
                mix-blend-mode: color-burn;
                pointer-events: none;
                position: absolute;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
            }
        }
        
        a {
            color: $white;
        }
        
        img {
            margin-left: auto;
            display: block;
        }
        
        b {
            display: block;
            margin-top: 20px;
        }
        
        span {
            @include fontSize('md');
            color: $off-white;
        }
    }
    
    .project-cta {
        display: block;
        margin: 100px auto 0;
    }
}

@include media('xs') {
    .projects {
        grid-template-columns: repeat(2, 1fr);
    }
}

@include media('sm') {
    .projects {
        grid-template-columns: repeat(3, 1fr);
    }
}
projects.scss
Add styles for the Project component

This will create a radial gradient as the background for the project section. We can position the gradient at the bottom center using the at bottom center keywords. For the .projects element, we want to display it as a grid. Initially, cards will be displayed in a single column. At each wider media query, we'll increase the column count until it reaches a maximum of three columns. This is done using the media mixins at the bottom.

As for the project cards themselves, we want to completely overlay the texture using absolute positioning, as seen in lines 23-27. Note that without setting the parent element's position to relative, absolute positioning will not take effect.

CSS position preventing properties from having an effect
Without absolute positioning, top and left will not be applied

To ensure the texture doesn't cover the elements but only applies a texture to the background, we can use mix-blend-mode. Experiment with different values for mix-blend-mode to see their effects. Also, make sure to set pointer-events to none to prevent the element from capturing any click events.

With this, we're basically done styling our Project component. In the next lesson, we'll explore how to add scroll animations.

Add Scroll Animations

Animating elements on scroll can be achieved in several different ways. One popular option for attaching animations to scrolls is to use a popular library called GSAP. However, it comes with many extra helper functions that we won't need for this portfolio. Instead, we'll look at how to create animations using regular JavaScript functions.

This approach will reduce our overall JavaScript bundle size and help improve our understanding of how scroll animations work. The idea behind animating elements on scroll is simple and can be broken down into four main steps:

  1. Create a state for keeping track of styles, such as opacity.
  2. Attach a scroll event listener to the window.
  3. Update the state inside the event listener based on the position of elements.
  4. Attach the state to the elements using the style attribute.

We'll follow these steps to create the animations. Let's start by creating the state. Inside the Projects component, create the following new state for the animations and a new reference for the section element:

Copied to clipboard!
import { useEffect, useState, useRef } from 'react'

...

export const Projects = () => {
    const { ... } = config

    const sectionRef = useRef<null | HTMLElement>(null)
    const [animation, setAnimation] = useState({
        heading: {
            opacity: 0,
            transform: 0
        },
        description: {
            opacity: 0,
            transform: 0
        },
        projects: Array(projects.length).fill({
            opacity: 0,
            transform: 0
        }),
        cta: {
            opacity: 0,
            transform: 0
        }
    })

    useEffect(() => {
        window.addEventListener('scroll', () => {
            // We'll update the state here
        })
    }, [projects])

    return (
        <section className="project-section" ref={sectionRef}>...</section>
    )
}
Projects.tsx
Add state for the animations

Each element inside the projects section will have its opacity and transform animated. To keep all of these elements in one state, we can use an object.

For the projects property, we want to create as many elements as there are projects inside our configuration file. For this, we can use Array(projects.length).fill(...). This will create an array with x number of elements, all filled with an opacity and transform property.

Copied to clipboard! Playground
// Mock projects
const projects = [1, 2, 3]

// Generating arrays based on the `projects` variable
Array(projects.length) // Returns [3 x empty]

// Generating the same object 3 times
Array(projects.length).fill({
    opacity: 0,
    transform: 0
})
The projects property explained

After the useState hook, we've also created a useEffect for updating the state, with an empty scroll event listener. This is where we'll update the animation state. Note that we need to pass projects as a dependency to avoid unnecessary re-renders.

Don't forget to pass sectionRef to the ref property of the section element.

With this in place, we only need the last two steps. So how do we actually update the state based on the scroll position? We want to begin animating when the project's section element starts appearing in the viewport. To calculate the position of the element, add the following lines to the scroll event listener:

Copied to clipboard! Playground
const top = window.scrollY - sectionRef.current!.offsetTop

if (top <= 100) {
    console.log(top)
}
Projects.tsx
Getting the position of the section

The top variable will contain the position of the section element relative to the viewport. When the top variable equals zero, it means the section element is situated in the middle of the viewport. At this stage, all elements should be in their final positions. So how do we update the animation state based on the scroll position? Enter the world of interpolation.

Linear interpolation

Linear interpolation, also known as lerp for short, is a method to linearly map one value to another. For example, if we have a scale from 0 to 100, and we want to map the values to a scale between 0 to 1, a value of 0.5 would equal 50. This can be achieved by using linear interpolation. Take the following function as an example:

🚫 Refresh console

This essentially returns the percentage between two values, eg 50% of 100. The lerp function takes in three variables:

  • x: The starting position of the scale.
  • y: The ending position of the scale.
  • z: A percentage between 0 and 1.

Experiment with different values to see how the lerp function can give back the percentage of the passed scale. The opposite of this function is called an inverse lerp, where we get back a decimal value β€” the third parameter of the above function:

🚫 Refresh console

This also makes use of a clamp function internally, which ensures that if a number falls outside of a given range, it'll either return the minimum value (if it's smaller than the minimum number) or the maximum value (if it's larger than the maximum number). Otherwise, it'll return the number as is.

Combining lerp and invlerp

These three functions (clamp, lerp, and invlerp) can help us create a function that maps the scroll position to certain values. The reason we need both lerp and invlerp is because the output can differ based on the CSS property.

For example, for opacity, we want to map the scroll position to a value between 0 and 1. However, for a translate property, it can be a range of anything from 30px to 0, 0 to 50px, or any other ambiguous number. We can solve this by allowing the developer to pass the desired output values. Take the following as an example:

Copied to clipboard!
// Accepts a value, as well as the input and output ranges
const interpolate = (
    value: number,
    input: [x: number, y: number],
    output: [x: number, y: number],
) => ...

// Map the value of `top` between -600 and -400 to a value between 0 an 1
interpolate(top, [-600, -400], [0, 1])
interpolate(top, [-600, -400], [30, 0])
Defining a configurable interpolation function

We can translate the above two function calls as:

  • Map the value of top between -600 and -400 to a value between 0 and 1.
  • Map the value of top between -600 and -400 to a value between 30 and 0.

This configurability makes the interpolate function extremely flexible, and now it can be used to apply animation to any CSS property. So, how does this interpolate function work? It combines a lerp with an invlerp:

Copied to clipboard!
export const interpolate = (
    value: number,
    input: [x: number, y: number],
    output: [x: number, y: number],
) => lerp(output[0], output[1], invlerp(input[0], input[1], value))
The implementation of the interpolate function

This may look a bit intimidating at first, but it works in a simple way:

  • It returns a lerp function that accepts a range of values and a decimal. The lerp function returns the value between output[0] and output[1] based on the passed decimal as the third parameter.
  • The third parameter of the lerp function is generated by an invlerp using input[0] and input[1], as well as the value. This returns a decimal value that is passed over to the lerp function. The lerp function then maps the input range to the correct output range.
Copied to clipboard! Playground
// Map 50 between 0 and 100 to 0 and 1
interpolate(50, [0, 100], [0, 1]) // Returns 0.5

// Map 50 between 0 and 100 to 0 and 50
interpolate(50, [0, 100], [0, 50]) // Returns 25
Explaining the interpolate function

Animating CSS properties

Now that we understand how lerp and invlerp work, export them along with the clamp and interpolate functions from the utils.ts file:

Copied to clipboard!
export const clamp = (
    num: number,
    min: number,
    max: number
    ) => Math.min(Math.max(num, min), max)
    
export const lerp = (
    x: number,
    y: number,
    z: number
) => x * (1 - z) + y * z
        
export const invlerp = (
    x: number,
    y: number,
    z: number
) => clamp((z - x) / (y - x), 0, 1)
    
export const interpolate = (
    value: number,
    input: [x: number, y: number],
    output: [x: number, y: number],
) => lerp(output[0], output[1], invlerp(input[0], input[1], value))
utils.ts
Export clamp, lerp, invlerp and interpolate

While we've exported everything for later use, in this course, we're only going to make use of the interpolate function in the Projects component. Open the Projects component and inside the if statement in the scroll event listener, define the following list of opacities, and transform rules after importing the interpolate function from the utils:

Copied to clipboard! Playground
// Don't forget to update the import statement
import { scrollTo, interpolate } from '../../utils.ts'

// Inside the if statement, follow the structure of the animations state
if (top <= 100) {
    const opacities = {
        heading: interpolate(top, [-400, 0], [0, 1]),
        description: interpolate(top, [-300, 0], [0, 1]),
        projects: [
            interpolate(top, [-600, -400], [0, 1]),
            interpolate(top, [-400, -200], [0, 1]),
            interpolate(top, [-200, 0], [0, 1]),
            interpolate(top, [-600, -400], [0, 1]),
            interpolate(top, [-400, -200], [0, 1]),
            interpolate(top, [-200, 0], [0, 1])
        ],
        cta: interpolate(top, [-400, 0], [0, 1]),
    }
    
    const transforms = {
        heading: interpolate(top, [-400, 0], [20, 0]),
        description: interpolate(top, [-300, 0], [20, 0]),
        projects: [
            interpolate(top, [-600, -400], [30, 0]),
            interpolate(top, [-400, -200], [30, 0]),
            interpolate(top, [-200, 0], [30, 0]),
            interpolate(top, [-600, -400], [30, 0]),
            interpolate(top, [-400, -200], [30, 0]),
            interpolate(top, [-200, 0], [30, 0])
        ],
        cta: interpolate(top, [-400, 0], [50, 0]),
    }
}
Projects.tsx
Define opacities and transform rules inside the if statement

For each property inside the state, we've created a new interpolate function with different configurations. For example:

  • heading: Animate the opacity between 0 and 1 when the scroll position is between -400 and 0.
  • description: Animate the opacity between 0 and 1 when the scroll position is between -300 and 0.

You can try logging some of the values to the console to see how they change based on the scroll position.

This way, we can create intricate animations with simple rules. For the projects, we want to animate each column one after the other. Notice the repeating pattern:

Copied to clipboard! Playground
interpolate(top, [-600, -400], [0, 1])
interpolate(top, [-400, -200], [0, 1])
interpolate(top, [-200, 0], [0, 1])

interpolate(top, [-600, -400], [0, 1])
interpolate(top, [-400, -200], [0, 1])
interpolate(top, [-200, 0], [0, 1])
We repeat the same interpolate for the first and second row

This means that the cards inside each column will be animated simultaneously. To update the state based on these interpolate functions, all we have to do is call setAnimation with the proper references. After the transforms variable, add the following updater function to update the state with the interpolated values:

Copied to clipboard! Playground
setAnimation({
    heading: {
        opacity: opacities.heading,
        transform: transforms.heading
    },
    description: {
        opacity: opacities.description,
        transform: transforms.description
    },
    projects: Array(projects.length).fill({
        opacity: 0,
        transform: 0
    }).map((_, index) => ({
        opacity: opacities.projects[index],
        transform: transforms.projects[index]
    })),
    cta: {
        opacity: opacities.cta,
        transform: transforms.cta
    }
})
Projects.tsx
Update the animations state

Attaching styles

We're only one last step away from seeing the animations. Now that we have the interpolated values, we need to attach them to the elements using style attributes. Let's go from top to bottom and start with the heading and description. As these are displayed through the Section component, we need to create new props to pass the styles. Modify the Section component according to the following:

Copied to clipboard!
import React from 'react'

import './section.scss'

export type SectionProps = {
    title: string
    description: string
    headingStyles?: React.CSSProperties
    contentStyles?: React.CSSProperties
}

export const Section = ({
    title,
    description,
    headingStyles,
    contentStyles
}: SectionProps) => {
    return (
        <React.Fragment>
            <h2
                className="section-title"
                style={headingStyles}
            >
                {title}
            </h2>
            <p
                className="section-content"
                dangerouslySetInnerHTML={{ __html: description }}
                style={contentStyles}
            />
        </React.Fragment>
    )
}
Section.tsx
Pass styles as props to the Section component

We can make these props optional by using the ? annotation for the type. The correct type for style attributes in React is React.CSSProperties. Now we can pass the animated state to this component by updating the Section component inside Projects.tsx as follows:

Copied to clipboard!
<Section
    title={projectsTitle}
    description={projectsDescription}
    headingStyles={{
        opacity: animation.heading.opacity,
        transform: `translateX(-${animation.heading.transform}px)`
    }}
    contentStyles={{
        opacity: animation.description.opacity,
        transform: `translateX(-${animation.description.transform}px)`
    }}
/>
Projects.tsx
Pass the animation state to the Section component

We can use string interpolation to inject transform rules inside a translateX function. We want the title and the description to slide in from the left side, for which we need to use negative values. Both of them will be animated on the x-axis from -20px to 0.

We want to follow the same logic for the project cards and the CTA. Expand the style property of the li elements with the following two properties:

Copied to clipboard!
<li
    key={index}
    style={{
        background: `linear-gradient(...)`,
        opacity: animation.projects[index].opacity,
        transform: `translateY(${animation.projects[index].transform}px)`
    }}
>
Projects.tsx
Animating project cards

Lastly, try implementing the same logic for the Button component. Don't forget to pass the props to the component! If you get stuck with the implementation, you can reveal the solution below.

Copied to clipboard!
// Inside the `Button` component
export type ButtonProps = {
    ...
    styles?: React.CSSProperties
}

export const Button = ({
    ...,
    styles
}: ButtonProps) => {
    return (
        <button
            onClick={onClick}
            className={className}
            style={styles}
        >
            {children}
        </button>
    )
}

// Inside the `Projects` component
<Button
    onClick={() => scrollTo('.contact')}
    className="project-cta"
    styles={{
        opacity: animation.cta.opacity,
        transform: `translateY(${animation.cta.transform}px)`
    }}
>
Animate the CTA

And with that being said, now you have a scroll-animated projects section where cards are revealed depending on the scroll position! In the next lesson, we'll take a look at how to make the cards have a 3D effect on hover.

3D Card Animation

To create 3D card animations, we want to dynamically add a CSS transform when an element is hovered. When the element is being hovered, we'll calculate the cursor's position on the element to attach dynamic scale3d and rotate3d rules using CSS. For this, we'll need to introduce two new event listeners on the li element:

Copied to clipboard!
const rotate = (event: React.MouseEvent<HTMLLIElement>) => {
    console.log('hover')
}
        
const resetStyles = (event: React.MouseEvent<HTMLLIElement>) => {
    console.log('hover out')
}

// Inside the return:
<ul className="projects">
    {projects.map((project, index) => (
        <li
            key={index}
            style={{ ... }}
            onMouseMove={rotate}
            onMouseLeave={resetStyles}
        >
Projects.tsx
Add the two new event listeners on the li

Create the rotate and resetStyles functions above the useEffect hook, and attach them to the onMouseMove and onMouseLeave events on the li. When we hover the li, the onMouseMove function will be executed. When the li loses focus, the function in the onMouseLeave listener will be called.

In the rotate function, we'll change the element's transform rule to create a 3D effect. In the resetStyles function (which is called when the li loses focus), we'll reset the transform rule to remove the 3D effect. To create the 3D effect, add the following lines to the rotate function:

Copied to clipboard! Playground
const rotate = (event: React.MouseEvent<HTMLLIElement>) => {
    const target = event.currentTarget
    const bounds = target.getBoundingClientRect()
    
    const mouseX = event.clientX
    const mouseY = event.clientY
    
    const left = mouseX - bounds.x
    const top = mouseY - bounds.y
    
    const center = {
        x: left - bounds.width / 2,
        y: top - bounds.height / 2
    }
    
    target.style.transform = `
        scale3d(1.1, 1.1, 1.1)
        rotate3d(
            ${center.y},
            ${-center.x},
            0,
            15deg
        )
    `;
}
Projects.tsx
Update the transform rule of the li

The 3D effect works by calculating the mouse's position on the element and updating the transform rule based on its position. Let's cover each line to better understand how this function works.

  • Line 3: We get the bounding client of the element, which returns its width and height and its position relative to the viewport.
  • Lines 5-6: We also need the mouse position, which we can get using event.clientX and event.clientY.
  • Lines 8-9: We need to calculate the top and left position of the card, which we can use to later calculate the center position as well. We want to emphasize the effect as the mouse gets closer to the edges of the card, which means the effect will be barely visible when the mouse is in the center of the card.
  • Lines 18-23: We can apply a scale3d and rotate3d effect to animate the card when the mouse moves. Notice that the x and y values are switched. rotate3d expects the x value first, but we pass the y to make the rotation opposite to the mouse movement direction.

However, the animation is currently a little bit off. This is because we're missing a CSS property. Whenever working with 3D in CSS, we need to define a perspective on the 3D element's parent to tell the browser how far the 3D objects are from the user. Inside projects.scss, add the following to the .projects element:

Copied to clipboard!
.projects {
    display: grid;
    gap: 20px;
    margin-top: 40px;
    perspective: 5000px;
projects.scss
Add perspective to the .projects element

You can experiment with different effects by switching the values or changing the degree in the rotate3d rule. You can also make the effect less or more dramatic by either increasing (making it less prominent) or decreasing (making it more prominent) the CSS perspective property.

However, when the card loses focus, the transform stays. Try resetting the transform rule in the resetStyles function.

Copied to clipboard! Playground
const resetStyles = (event: React.MouseEvent<HTMLLIElement>) => {
    const target = event.currentTarget
    
    target.style.transform = '';
}
Projects.tsx
Reset the transform styles

Adding a glow effect

To emphasize the 3D effect, let's also add a glow effect to the cards. We can achieve this by applying a semi-transparent radial gradient over the cards:

glow effect created in CSS
Glow effect created with a radial-gradient in CSS

Add a new empty div after the .texture element with a className of glow:

Copied to clipboard!
<div className="texture" />
<div className="glow" />
Projects.tsx
Add an element for the glow effect

Inside projects.scss, define the same positioning for the .glow element as we have for the texture. As they share the same styles, we can remove them from the .texture element and create a shared selector in the following way:

Copied to clipboard!
#readonly  
  .texture {
      border-radius: $radius;
      background-image: url('/assets/img/card.png');
      mix-blend-mode: color-burn;
      pointer-events: none;
-     position: absolute;
-     top: 0;
-     left: 0;
-     width: 100%;
-     height: 100%;
-   }

+ .texture,
+ .glow {
+     position: absolute;
+     top: 0;
+     left: 0;
+     width: 100%;
+     height: 100%;
+ }
projects.scss
Define positioning for the .glow element

To add the glow effect, extend the rotate function with the following lines of code which will attach a radial-gradient to the element dynamically.

Copied to clipboard!
(target.querySelector('.glow') as HTMLDivElement).style.backgroundImage = `
    radial-gradient(
        circle at
        ${center.x + bounds.width / 2}px
        ${center.y + bounds.height / 2}px,
        rgba(255, 255, 255, .15),
        rgba(0, 0, 0, 0)
    )
`
Projects.tsx
Add glow effect to the rotate function

This creates a white radial gradient with 15% opacity transitioning into a transparent black. The position of the gradient is recalculated at each mouse move by using the center object in conjunction with the width and height of the card. Last, but not least, try resetting the glow effect in the resetStyles function, just like we did for the transform rule.

Copied to clipboard!
(target.querySelector('.glow') as HTMLDivElement).style.background = ''
Projects.tsx
Reset the gradient in the resetStyles function

With these changes, you've successfully created a scroll-animated portfolio with captivating 3D hover effects! Congratulations on reaching this far. To finish this course and leave you with a fully functional animated portfolio, we'll look into how to add a contact form in the next lesson.

Create the Contact Form

In this lesson, we'll complete the project and create the contact section with a contact form, social links, as well as a little heart animation:

heart animation in CSS
Heart animation created with CSS

Let's start by expanding our configuration file with the contact details. Open config.ts and after the projectsCTA property, add the following new properties:

Copied to clipboard! Playground
    ...
    projectsCTA: 'Get in Touch',
    contactTitle: 'Get in touch',
    contactDescription: 'Get in touch: <a href="mailto:john@email.com">john@email.com</a>.',
    socialLinks: [
        'https://facebook.com',
        'https://twitter.com',
        'https://instagram.com'
    ]
}
config.ts
Add details into the config for the contact section

For the contactTitle and contactDescription properties, we can reuse the Section component. For the socialLinks, we'll have to write a small utility function for grabbing the correct icon for each link.

Let's see what the layout for the Contact component looks like. Open the Contact.tsx file and replace the existing code with the following:

Copied to clipboard!
import { Button } from '../Button'
import { Section } from '../Section'

import { config } from '../../../config.ts'

import './contact.scss'

export const Contact = () => {
    const {
        contactTitle,
        contactDescription,
        socialLinks
    } = config
    
    const getSocialType = (link: string) => { }

    return (
        <section className="contact">
            <div className="container">
                <Section
                    title={contactTitle}
                    description={contactDescription}
                />

                <form>
                    <input type="email" placeholder="Your email" />
                    <textarea placeholder="Your message" />
                    <Button>Send</Button>
                </form>

                <ul className="socials">
                    {socialLinks.map((link, index) => (
                        <li key={index}>
                            <a href={link} target="_blank" rel="noreferrer">
                                <img
                                    src={`/assets/icons/${getSocialType(link)}.svg`}
                                    alt={getSocialType(link)}
                                    width="35"
                                    height="35"
                                    loading="lazy"
                                />
                            </a>
                        </li>
                    ))}
                </ul>

                <div className="made">
                    Made with
                    <img
                        src="/assets/icons/heart.svg"
                        alt="Heart"
                        width="25"
                        height="20"
                        loading="lazy"
                        className="heart"
                    />
                </div>
            </div>
        </section>
    )
}
Contact.tsx
Create the layout for the Contact component

Here's a breakdown of the changes made:

  • Lines 20-23: We can reuse the Section component with different props.
  • Lines 25-29: For the contact form, we only need an input and textarea, as well as a submit button.
  • Lines 31-45: For the socialLinks, we'll need to grab the correct icon URL based on the social link. For this, we'll use a helper function called getSocialType. Make sure these images are lazily loaded, as they're off-screen initially.

To get the correct social icon type, we just need to grab the domain from the URL:

Copied to clipboard!
// Social URL to icon name
'https://facebook.com'  -> 'facebook'
'https://instagram.com' -> 'instagram'
'https://twitter.com'   -> 'twitter'
Social URL transformation

This of course requires us to name the social icon files accordingly. To grab the domain, add the following code to the getSocialType function:

Copied to clipboard!
const getSocialType = (link: string) => {
    const domain = link
        .split('.')[0]
        .replace('https://', '')
    
    return domain
}
Contact.tsx
Implement the getSocialType function

This function works by splitting the passed string at the dot (removing everything from .com) and replacing the https:// part with an empty string. Now that we have the social icons sorted, let's focus our attention on implementing the contact form.

Implementing the contact form

For the contact form, we'll need to connect the inputs with a useRef hook, so that we can reference their values. Import useRef from React and create the following two hooks at the top of the component:

Copied to clipboard!
import React, { useRef } from 'react'

...

export const Contact = () => {
    const emailRef = useRef<null | HTMLInputElement>(null)
    const messageRef = useRef<null | HTMLTextAreaElement>(null)
Contact.tsx
Create useRef hooks for the inputs

Assign them to the input and textarea using the ref attribute. Next, create an empty function called send, then assign it to the onClick prop of the Button component.

Copied to clipboard!
const send = (event: React.MouseEvent) => {}

return (
    <section className="contact">
        <div className="container">
            <Section
                title={contactTitle}
                description={contactDescription}
            />

            <form>
                <input
                    type="email"
                    placeholder="Your email"
                    ref={emailRef}
                />
                <textarea
                    placeholder="Your message"
                    ref={messageRef}
                />
                <Button onClick={send}>Send</Button>
            </form>
Contact.tsx
Assign refs to the inputs

We'll create the sending functionality inside the send function. But first, we'll need a function for properly validating email addresses. Open utils.ts and inspect the isValidEmail function. It uses a regex to validate email addresses. If the passed string is a valid email address, it returns true; otherwise, it returns false. For example:

🚫 Refresh console

Inside the send function, we'll need the following steps to validate the form fields:

  • Get the values of the inputs using the useRef hooks.
  • Prevent the form's default action to avoid a full page reload.
  • Validate each field and add an .error class to provide feedback to the user if the field is filled incorrectly.

Once all of these steps are successfully completed, it indicates that our form is filled out correctly and can be submitted. Building on these steps, add the following code to the send function:

Copied to clipboard! Playground
// Don't forget to add the import at the top
import { isValidEmail } from '../../utils.ts'

const send = (event: React.MouseEvent) => {
    const email = emailRef.current!.value
    const message = messageRef.current!.value
    
    event.preventDefault()
    
    emailRef.current!.classList.remove('error')
    messageRef.current!.classList.remove('error')
    
    if (!isValidEmail(email)) {
        emailRef.current!.classList.add('error')
    }
    
    if (!message) {
        messageRef.current!.classList.add('error')
    }
    
    if (isValidEmail(email) && message) {
        console.log('Send message', email, message)
    }
}
Contact.tsx
Implement the send function

Don't forget to import isValidEmail at the top of the component. This function will apply the .error class to the relevant input when necessary. It's important to note that the .error classes are removed at the beginning to ensure that if they were previously applied and the fields have been corrected, they will be removed. Now, let's move on to styling the contact section.

Styling the contact section

First, let's start with styling the contact form. To style the contact form, open contact.scss, and add the following lines of code:

Copied to clipboard!
@import '../../scss/config';

.contact {
    background: radial-gradient(ellipse at top center, $primary-dark 0%, $black 75%);
    
    form {
        display: flex;
        flex-direction: column;
        gap: 10px;
        margin-top: 20px;
    }
    
    input,
    textarea {
        @include fontType('regular');
        padding: 10px;
        border: 0;
        border-radius: $radius;
        
        &.error {
            border: 2px solid $error;
        }
    }
    
    textarea {
        resize: none;
        height: 100px;
    }
}
contact.scss
Style the contact form

The key points in the above code are highlighted for clarity. We're creating a seamless transition between the projects and contact sections by applying a radial background at the top of the contact section. We've also added styling for the .error class within the form, which creates a 2px red border.

Also, make sure to set the resize property to none to prevent it from being resized into a size that breaks the layout. It'll have a fixed height of 100px which is plenty of space to work with.

To align the social icons, try to display them as flex and align them to the middle of the screen with a 10px gap on the flex container. Also, set margin-top to 50px.

Copied to clipboard!
.socials {
    margin-top: 50px;
    display: flex;
    justify-content: center;
    gap: 10px;
}
contact.scss
Align the social icons

Heartbeat animation

To finish this course, let's create a beating heart animation as a small easter egg for our last element. Still in contact.scss, extend the file with the following rules:

Copied to clipboard!
.made {
    position: absolute;
    bottom: 50px;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    justify-content: center;
    gap: 5px;
}

.heart {
    cursor: pointer;
    
    &:hover {
        animation-name: Beating;
        animation-iteration-count: 1;
        animation-duration: 2s;
    }
}
contact.scss
Create the styles for the heart animation

This will align the elements in the center using absolute positioning + flexbox. When the heart is hovered, we'll add the beating animation. We need to set animation-interation-count to one, to ensure the animation only plays once on hover. The beating is created with a sequence of scale animations. Inside animations.scss, create the following:

Copied to clipboard!
@keyframes Beating {
    0%   { transform: scale(1); }
    25%  { transform: scale(.97); }
    35%  { transform: scale(.9); }
    45%  { transform: scale(1.1); }
    55%  { transform: scale(.9); }
    65%  { transform: scale(1.1); }
    75%  { transform: scale(1.03); }
    100% { transform: scale(1); }
}

To create seamless animations, make sure you always return to the initial state at 100%.

This animation will scale the heart back and forth to different sizes. You can decrease the .9 values and increase the 1. values to make the effect more dramatic.

Summary

If you've reached this far, congratulations! You've successfully completed building a scroll-animated portfolio in React + TypeScript with the help of Sass. There's one last thing we haven't covered, which is setting up absolute imports.

We only need to make two changes; one in our tsconfig.json and the other in our vite.config.ts file. Inside the compilerOptions section of tsconfig.json, we need to provide a paths property with a list of the imports we want to resolve:

Copied to clipboard!
"paths": {
    "@components/*": ["src/components/*"],
    "@config": ["config.js"],
    "@utils": ["src/utils.js"],
}
tsconfig.json
Adding absolute imports in tsconfig

This instructs TypeScript on how to resolve components, our configuration file, and the utils file. We can set up aliases for both folders and individual files. However, we also need to inform Vite about these changes, which can be done using the resolve.alias property in vite.config.ts:

Copied to clipboard!
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
    plugins: [react()],
    resolve: {
        alias: [
            { find: '@components', replacement: '/src/components' },
            { find: '@config', replacement: 'config.ts' },
            { find: '@utils', replacement: '/src/utils.js' }
        ]
    }
})
vite.config.ts
Adding import aliases to Vite

This has already been done in the editor. Open Projects.tsx to see how these new absolute imports are being utilized.

With that said, the only task remaining is to update the text and projects within your configuration file and deploy your site. For deployment, you can use services such as Cloudflare Pages, Netlify, Vercel, or create your own custom deployment script via CI/CD.

You can also find the source code for this project hosted on GitHub.

So, where do you go from here? If you wish to expand your knowledge of React further, check out our PRO platform where you can:

  • check Gain comprehensive knowledge of React
  • check Learn JavaScript and Semantic HTML
  • check Build various projects
  • check Prepare for technical interviews
  • check Tackle coding challenges
  • check Obtain certificates

For additional free resources on solving common problems in React, check out our React Projects Roadmap where you can learn how to build more than 10 different projects, like one of the following:

If you'd like to revisit the fundamentals and learn more about how React works, then you can find additional tutorials in our React Roadmap, which covers everything from JSX, props, and state to React patterns and common pitfalls.

Master React

Thank you for taking this course, and I wish you the best of luck on your developer journey. Please don't forget to share your feedback to help us improve this course!

Did you find this course helpful?
This site uses cookies We use cookies to understand visitors and create a better experience for you. By clicking on "Accept", you accept its use. To find out more, please see our privacy policy.