How to Create a Scroll Animated Portfolio With React
Getting Started with CSS animations in React
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 usingh2
, 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.
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.
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:
JavaScript | TypeScript |
---|---|
vanilla | vanilla-ts |
vue | vue-ts |
react | react-ts |
preact | preact-ts |
lit | lit-ts |
svelte | svelte-ts |
solid | solid-ts |
qwik | qwik-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:
npm create vite@latest my-app -- --template react-ts
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
: Thepublic
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 thesrc
folder.tsconfig.json
: Since this is a TypeScript project, we have atsconfig.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:
src
ββ components
β ββ Contact
β β ββ contact.scss
β β ββ Contact.tsx
β β ββ index.ts
β ββ Hero
β β ββ hero.scss
β β ββ Hero.tsx
β β ββ index.ts
β ββ Projects
β β ββ projects.scss
β β ββ Projects.tsx
β β ββ index.ts
By following this project structure, we can keep components and their styles together. As you can see, we're using Sass in this setup.
npm i sass
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:
export const Hero = () => {
return (
<h1>Hero</h1>
)
}
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:
export * from './Hero'
// Import without index.ts:
import { Hero } from './components/Hero/Hero'
// Import with index.ts:
import { Hero } from './components/Hero'
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:
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>
)
}
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:
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',
}
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.
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:
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>
)
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 themax-width
of the container to1200px
. - Lines 5-10: In Vite, we can reference static assets from the root of the
public
folder. For images, always provide analt
tag to improve accessibility, as well aswidth
andheight
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 anh1
tag. As thetitle
variable that is exported from the config file contains HTML tags, we need to use thedangerouslySetInnerHTML
prop to correctly render the HTML elements inside theh1
tag. - Line 13: The subtitle is wrapped in a
strong
tag. We'll useh2
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.
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.
We can leave the scss
file empty for now. For the Button
component, add the following lines of code:
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>
)
}
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 theButton
component will be displayed within the button element. In React, we can typechildren
asReact.ReactNode
.onClick
: Buttons will trigger specific actions, and for this, we can pass anonClick
prop. Click events can be typed usingReact.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
:
export * from './Button'
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.
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.
src
ββ scss
β ββ config
β β ββ color-palette.scss
β β ββ mixins.scss
β β ββ typography.scss
β β ββ variables.scss
β ββ resets.scss
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:
$black: #000;
$white: #FFF;
$off-white: #777;
$primary: #6A59FF;
$primary-dark: #0B0063;
$error: #e74c3c;
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:
$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
);
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:
@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 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 themap-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.
- Note that we need to use the
fontType
: We can follow a similar pattern for font types. This mixin relies on the variables defined intypography.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 thefontType
mixin, but it requires passing the$fontSizes
map to themap-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:
$radius: 4px;
$easing: cubic-bezier(.55, 0, .1, 1);
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:
@import './config/color-palette';
@import './config/mixins';
@import './config/typography';
@import './config/variables';
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:
@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;
}
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:
// 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;
}
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:
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;
}
}
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:
@import '../../scss/config';
button {
@include fontType('bold');
@include fontSize('md');
border: 0;
background: $primary;
color: $white;
padding: 15px 20px;
border-radius: $radius;
cursor: pointer;
}
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
:
import './hero.scss'
Next, open hero.scss
, import the Sass config file, and add the following rules to style the main elements:
@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;
}
}
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
tocover
and position the image at the bottom of the container by settingbackground-position
tobottom
. - 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
tohidden
and useanimation-fill-mode: forwards
to ensure the animation stops after completion. We'll create theLogoAnimation
in a moment. - Line 26: The
h1
can acceptb
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 usetranslateX(-50%)
to offset it by 50% of its width.
To create the LogoAnimation
, create a new file named animations.scss
at the root of the scss
folder and add the following:
@keyframes LogoAnimation {
0% { width: 0; }
100% { width: 75px; }
}
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.
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.
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:
.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;
}
}
}
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:
&.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);
}
}
- 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:
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>
)
}
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:
export const classNames = (
arr: Array<string | null | boolean | undefined>
): string | undefined => arr?.filter(Boolean).join(' ')
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:
@keyframes MenuSlideIn {
0% {
opacity: 0;
transform: translateX(20px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
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:
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:
.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;
}
}
}
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:
- <ul className="menu">
+ <ul className={menuOpen ? 'menu visible' : 'menu'}>
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:
<Button onClick={() => {}}>
{heroCTA}
</Button>
<div className="curtain" />
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:
We can achieve this by animating its height from 100% to 0. Open hero.scss
and add the following styles for the .curtain
element:
.curtain {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: $black;
transition: height .3s $easing;
animation: CurtainSlideUp .9s $easing forwards .6s;
}
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.
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
:
export const scrollTo = (element: string, index?: number) => index
? document
.querySelectorAll(element)?.[index]
?.scrollIntoView({ behavior: 'smooth' })
: document
.querySelector(element)
?.scrollIntoView({ behavior: 'smooth' })
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:
...
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>
)
}
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:
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',
}
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 theicons
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.
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:
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>
)
}
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
toproject.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 thewidth
andheight
attributes to avoid layout shifts. As these icons are below the fold, we also need to lazy load them by setting theloading
attribute tolazy
. - 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, thestyle
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.
Inside the components
folder, create a new folder called Section
and replicate the same structure as currently exists for the other components.
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.
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>
)
}
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:
@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;
}
}
Inside the Projects
component, import the Section
component and include it before the ul
element while passing projectsTitle
and projectsDescription
as props.
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:
@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);
}
}
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.
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:
- Create a state for keeping track of styles, such as
opacity
. - Attach a scroll event listener to the
window
. - Update the state inside the event listener based on the position of elements.
- 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:
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>
)
}
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.
// 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
})
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:
const top = window.scrollY - sectionRef.current!.offsetTop
if (top <= 100) {
console.log(top)
}
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:
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:
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:
// 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])
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
:
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))
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. Thelerp
function returns the value betweenoutput[0]
andoutput[1]
based on the passed decimal as the third parameter. - The third parameter of the
lerp
function is generated by aninvlerp
usinginput[0]
andinput[1]
, as well as thevalue
. This returns a decimal value that is passed over to thelerp
function. Thelerp
function then maps the input range to the correct output range.
// 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
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:
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))
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:
// 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]),
}
}
For each property inside the state, we've created a new interpolate
function with different configurations. For example:
heading
: Animate theopacity
between 0 and 1 when the scroll position is between -400 and 0.description
: Animate theopacity
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:
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])
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:
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
}
})
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:
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>
)
}
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:
<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)`
}}
/>
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:
<li
key={index}
style={{
background: `linear-gradient(...)`,
opacity: animation.projects[index].opacity,
transform: `translateY(${animation.projects[index].transform}px)`
}}
>
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.
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:
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}
>
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:
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
)
`;
}
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
andevent.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
androtate3d
effect to animate the card when the mouse moves. Notice that thex
andy
values are switched.rotate3d
expects thex
value first, but we pass they
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:
.projects {
display: grid;
gap: 20px;
margin-top: 40px;
perspective: 5000px;
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.
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:
Add a new empty div
after the .texture
element with a className
of glow
:
<div className="texture" />
<div className="glow" />
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:
#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%;
+ }
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.
(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)
)
`
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.
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:
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:
...
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'
]
}
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:
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>
)
}
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
andtextarea
, 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 calledgetSocialType
. 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:
// Social URL to icon name
'https://facebook.com' -> 'facebook'
'https://instagram.com' -> 'instagram'
'https://twitter.com' -> 'twitter'
This of course requires us to name the social icon files accordingly. To grab the domain, add the following code to the getSocialType
function:
const getSocialType = (link: string) => {
const domain = link
.split('.')[0]
.replace('https://', '')
return domain
}
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:
import React, { useRef } from 'react'
...
export const Contact = () => {
const emailRef = useRef<null | HTMLInputElement>(null)
const messageRef = useRef<null | HTMLTextAreaElement>(null)
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.
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:
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:
// 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)
}
}
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:
@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;
}
}
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
.
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:
.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;
}
}
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:
@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:
"paths": {
"@components/*": ["src/components/*"],
"@config": ["config.js"],
"@utils": ["src/utils.js"],
}
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
:
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' }
]
}
})
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:
- Gain comprehensive knowledge of React
- Learn JavaScript and Semantic HTML
- Build various projects
- Prepare for technical interviews
- Tackle coding challenges
- 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.
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!