Hello! Welcome back to part two of our tutorial for excited developers. In this tutorial, We'll use React for our app, utilize the react-colorful library for color manipulation, add smooth animations with framer-motion, ensure type safety with TypeScript, and style our app using Sass.
Let's outline the key topics we'll cover in this tutorial:
Get ready to rock and code!
We're all set to start making the UI parts, beginning with the Header.
/*src/components/Header/index.tsx */
import React from 'react';
import { ReactComponent as Logo } from '@assets/svg/logo.svg';
import './Header.scss';
const Header: React.FC = () => (
<header className="header">
<div>
<Logo className="header__icon" />
<h1 className="header__title">CSS Gradient</h1>
</div>
</header>
);
export default Header;
Add styles
/* src/components/Header/Header.scss */
.header {
width: 100%;
height: 84px;
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
align-items: center;
padding: 0 20px 0 42px;
background-color: var(--background-color);
border: 2px solid $border-color;
border-radius: 40px;
& > div {
display: grid;
grid-template-columns: auto auto;
align-items: center;
grid-column-gap: 12px;
}
&__icon {
border-radius: 10px;
}
&__title {
margin: 0;
font-size: 24px;
font-family: $font-main-bold;
color: $title-color;
line-height: 30px;
}
}
And then add the Home component to our App component
/* src/App.tsx */
import React from 'react';
import Header from '@components/Header';
const App: React.FC = () => (
<main>
<Header />
</main>
);
export default App;
In this code, we create a functional component that renders a switcher with icons for light and dark themes. The classnames library helps apply the appropriate CSS classes based on the current theme.
/* src/components/ThemeModeSwitcher/index.tsx */
import React, { FC } from 'react';
import classnames from 'classnames';
import { ThemeMode } from '@shared/constants';
import { ReactComponent as SunIcon } from '@assets/svg/sun.svg';
import { ReactComponent as MoonIcon } from '@assets/svg/moon.svg';
import './ThemeModeSwitcher.scss';
interface ThemeModeSwitcherProps {
activeThemeMode: ThemeMode | null;
toggleThemeMode: () => void;
}
const ThemeModeSwitcher: FC<ThemeModeSwitcherProps> = ({
activeThemeMode,
toggleThemeMode,
}) => {
return (
<div
className={classnames('theme-mode-switcher', {
dark: activeThemeMode === ThemeMode.DARK,
light: activeThemeMode === ThemeMode.LIGHT,
})}
onClick={() => toggleThemeMode()}
>
<div className="theme-mode-switcher__circle" />
<SunIcon className="theme-mode-switcher__icon sun" />
<MoonIcon className="theme-mode-switcher__icon moon" />
</div>
);
};
export default ThemeModeSwitcher;
Make a style element using SCSS
/* src/components/ThemeModeSwitcher/ThemeModeSwitcher.scss */
@use '@styles/abstracts' as *;
$switcher-animation-time: 700ms;
$switcher-animation-timing-function: linear;
.theme-mode-switcher {
// ...
@include mq(extraSmall) {
scale: 1;
}
&__circle {
// ...
}
&__icon {
width: 22px;
height: 22px;
opacity: 0;
&.moon {
scale: 0.9;
}
&.sun {
transform-origin: 12px 12px;
scale: 1.1;
}
.sun-circle {
transform-origin: 12px 12px;
scale: 0.9;
}
.sun-beam {
opacity: 0;
transform-origin: 12px 12px;
}
}
}
Update .scss file to define the CSS animations for showing and hiding the icons. We use the opacity property to control the visibility of the icons.
/* src/components/ThemeModeSwitcher/ThemeModeSwitcher.scss */
.theme-mode-switcher {
// ...
&.dark {
#{$this}__icon.moon {
animation: moon-appear $switcher-animation-time forwards $switcher-animation-timing-function;
}
#{$this}__icon.sun {
animation: sun-disappear $switcher-animation-time forwards $switcher-animation-timing-function;
.sun-beam {
animation: sun-beam-disappear $switcher-animation-time forwards $switcher-animation-timing-function;
}
}
}
// ...
}
In the SCSS code, we define animations for showing and hiding the icons using @keyframes. The opacity property is animated to control the icon's visibility.
/* src/components/ThemeModeSwitcher/ThemeModeSwitcher.scss */
@keyframes moon-appear {
65% {
opacity: 0;
translate: 0;
rotate: 0;
}
70% {
opacity: 1;
translate: 4px;
rotate: -20deg;
}
80% {
rotate: 0;
}
90% {
rotate: -5deg;
}
100% {
opacity: 1;
translate: 0;
rotate: -10deg;
}
}
@keyframes moon-disappear {
0% {
opacity: 1;
}
20% {
rotate: 20deg;
opacity: 1;
}
22%, 100% {
opacity: 0;
}
}
/* ... */
Finally, make sure to update the toggleThemeMode function in App.tsx file to add or remove the "dark" and "light" classes to trigger the animations.
The main component renders a Header, passing in activeThemeMode and toggleThemeMode as props. This allows the Header component to display the current theme mode and provide a button to toggle it.
/* src/App.tsx */
import React, { useState, useLayoutEffect } from 'react';
import Header from '@components/Header';
import { ThemeMode, themeModeLocalStorageKey } from '@shared/constants';
const App: React.FC = () => {
const [activeThemeMode, setActiveThemeMode] = useState<ThemeMode | null>(null);
const setThemeMode = (modeName) => {
document.documentElement.setAttribute('data-theme', modeName);
localStorage.setItem(themeModeLocalStorageKey, modeName);
setActiveThemeMode(modeName);
};
/* ... */
const toggleThemeMode = () => {
return setThemeMode(
document.documentElement.getAttribute('data-theme') === ThemeMode.LIGHT
? ThemeMode.DARK
: ThemeMode.LIGHT
);
};
return (
<main>
<Header
activeThemeMode={activeThemeMode}
toggleThemeMode={toggleThemeMode}
/>
</main>
);
};
export default App;
useLayoutEffect is used in this context to ensure that theme changes are applied immediately and synchronously, providing a smoother user experience and preventing any unwanted visual artifacts during theme transitions. It's a suitable choice for operations that need to interact with the DOM and affect the UI layout.
/* src/App.tsx */
useLayoutEffect(() => {
const themeFromLocalStorage = localStorage.getItem(
themeModeLocalStorageKey
);
/* ... */
setThemeMode(themeFromLocalStorage || themeFromSystemPreference);
}, []);
In this code window.matchMedia is important for detecting the user's system color scheme preference. By doing so, the application can automatically adapt to the user's preferences and offer a consistent user experience. When the user clicks the toggle button, it changes the colors even if the computer has a preference, so the user can pick their colors.
/* src/App.tsx */
useLayoutEffect(() => {
/* ... */
const themeFromSystemPreference = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches
? ThemeMode.DARK
: ThemeMode.LIGHT;
/* ... */
}, []);
In the <html> tag, include the data-theme="light" attribute. This attribute shows that the webpage is in a light theme, and the JavaScript code manages to change the theme and update the data-theme attribute as needed.
/* public/index.html */
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
/* ... */
</head>
<body>
/* ... */
</body>
</html>
Creating a GradientGenerator component involves several steps. This component is responsible for generating and displaying gradient backgrounds based on user input. We`ll walk you through the key functions and the component itself. Let's start with the details:
We need to set up a slider that allows users to add multiple color stops to a gradient and adjust their positions.
The first step is to define the props for the MultiThumbSlider component.
/* src/components/GradientGenerator/GradientRangeSettings/MultiThumbSlider/index.tsx */
interface MultiThumbSliderProps {
palettes: Palette[];
activePaletteId: string | undefined;
setPalettes: (palettes: Palette[]) => void;
setActivePalette: (palette: Palette) => void;
}
Create the MultiThumbSlider component using a functional component and add styles.
/* src/components/GradientGenerator/GradientRangeSettings/MultiThumbSlider/index.tsx */
import React, { useRef } from 'react';
import { defaultHexColor, maxColorsCount } from '@shared/constants';
//...
const MultiThumbSlider: React.FC<MultiThumbSliderProps> = ({
palettes,
activePaletteId,
setPalettes,
setActivePalette,
}) => {
const sliderContainerRef = useRef<HTMLDivElement | null>(null);
/* ... */
return (
<div
ref={sliderContainerRef}
className={classnames('multi-thumb-slider', {
limit: palettes.length >= maxColorsCount,
})}
>
</div>
);
};
export default MultiThumbSlider;
/* src/components/GradientGenerator/GradientRangeSettings/MultiThumbSlider/MultiThumbSlider.scss */
@use '@styles/abstracts' as *;
.multi-thumb-slider {
position: absolute;
top: 36px;
left: 0;
width: 100%;
height: 24px;
cursor: copy;
&.limit {
pointer-events: none;
}
&__input {
/* ... */
&::-webkit-slider-thumb {
@include clickable-element;
/* ... */
}
&::-moz-range-thumb {
@include clickable-element;
/* ... */
}
}
}
Create @mixin clickable-element that helps keep your CSS DRY (Don't Repeat Yourself) and makes it easier to maintain and update styles across your project.
/* src/styles/abstracts/mixins.scss */
@mixin clickable-element {
cursor: pointer;
user-select: none;
outline: none;
border: none;
}
The sliderContainerRef is used to reference the <div> element containing the slider. It is utilized in the handleAddNewColor function to calculate the mouse click position relative to the slider's width. This helps determine the position of the new color thumb accurately within the slider.
/* src/components/GradientGenerator/GradientRangeSettings/MultiThumbSlider/index.tsx */
import React, { useRef } from 'react';
//...
const MultiThumbSlider: React.FC<MultiThumbSliderProps> = ({
/* ... */
}) => {
const sliderContainerRef = useRef<HTMLDivElement | null>(null);
/* ... */
};
export default MultiThumbSlider;
The handleColorPositionChange function updates the position of a color thumb when the user drags it. It takes the new position value and the ID of the palette being updated as arguments.
/* src/components/GradientGenerator/GradientRangeSettings/MultiThumbSlider/index.tsx */
import React, { useRef } from 'react';
//…
const MultiThumbSlider: React.FC<MultiThumbSliderProps> = ({
palettes,
activePaletteId,
setPalettes,
setActivePalette,
}) => {
/* ... */
const handleColorPositionChange = (value: string, paletteId: string) => {
const newPalettes = [...palettes];
const neededPalette = newPalettes.find(
(palette) => palette.id === paletteId
);
if (neededPalette) {
neededPalette.position = parseInt(value, 10);
setPalettes(newPalettes);
}
};
/* ... */
Alright, get ready to turn hex colors into RGBA magic with our hexToRgbaObject function!
/* src/shared/types/general.ts */
export type KeyNumberValue = {
[key: string]: number;
};
/* src/shared/utils.ts */
import { KeyNumberValue } from './types/general';
import { hexColorRegExp, defaultHexColor } from './constants';
//...
const hexToRgbaObject = (hexColor: string): KeyNumberValue => {
const validHexColor = hexColorRegExp.exec(hexColor);
if (!validHexColor) {
return hexToRgbaObject(defaultHexColor);
}
return {
red: parseInt(validHexColor[1], 16),
green: parseInt(validHexColor[2], 16),
blue: parseInt(validHexColor[3], 16),
alpha: validHexColor[4]
? Math.round((parseInt(validHexColor[4], 16) / 255) * 100) / 100
: 1,
};
};
It first checks whether the provided hexColor is a valid hexadecimal color code using a regular expression pattern called hexColorRegExp.
/* src/shared/constants.ts */
const hexColorRegExp =
/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i;
const defaultHexColor = '#000000ff';
The handleAddNewColor function adds a new color thumb when the user clicks on the slider. It calculates the position and generates a new palette object. Here's how it works:
/* src/styles/abstracts/fonts.scss */
import React, { useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { hexToRgbaObject, removeAlphaFromRgbaColor } from '@shared/utils';
const MultiThumbSlider: React.FC<MultiThumbSliderProps> = ({
palettes,
activePaletteId,
setPalettes,
setActivePalette,
}) => {
/* ... */
const handleAddNewColor = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
if (event.target === sliderContainerRef.current) {
const mousePosition = event.nativeEvent.offsetX;
const positionForInput = Math.round(
(mousePosition * 100) / Number(sliderContainerRef.current?.offsetWidth)
);
const { red, green, blue, alpha } =
hexToRgbaObject(defaultHexColor);
const newPalette = {
id: uuidv4(),
color: `rgba(${red}, ${green}, ${blue}, ${alpha})`,
position: positionForInput,
};
setActivePalette(newPalette);
setPalettes([...palettes, newPalette]);
}
};
return (
<div
//...
onClick={handleAddNewColor}
>
{palettes.map((palette) => (
<input
key={palette.id}
value={palette.position}
onClick={() => setActivePalette(palette)}
//...
/>
))}
</div>
);
};
export default MultiThumbSlider;
The sliderContainerRef is used to reference the <div> element containing the slider. It is utilized in the handleAddNewColor function to calculate the mouse click position relative to the slider's width. This helps determine the position of the new color thumb accurately within the slider.
/* src/components/GradientGenerator/GradientRangeSettings/MultiThumbSlider/index.tsx */
import React, { useRef } from 'react';
//...
const MultiThumbSlider: React.FC<MultiThumbSliderProps> = ({
/* ... */
}) => {
const sliderContainerRef = useRef<HTMLDivElement | null>(null);
/* ... */
};
The removeAlphaFromRgbaColor function removes the alpha channel from an RGBA color string for background.
/* src/shared/utils.ts */
const removeAlphaFromRgbaColor = (rgbaColor: string): string =>
const rgbPoints = rgbaColor
.substring(5, rgbaColor.length - 1)
.replace(/ /g, '')
.split(',');
return `rgb(${rgbPoints[0]}, ${rgbPoints[1]}, ${rgbPoints[2]})`;
};
/* src/components/GradientGenerator/GradientRangeSettings/MultiThumbSlider/MultiThumbSlider.scss */
.multi-thumb-slider {
/* ... */
&::-webkit-slider-thumb {
/* ... */
background-color: var(--gradient-thumb-color, black);
}
&::-moz-range-thumb {
/* ... */
background-color: var(--gradient-thumb-color, black);
}
/* ... */
}
Now, we can use the MultiThumbSlider component in our application. Import it and include it in GradientRangeSettings component (later we will add a swap button to this component)
/* src/components/GradientGenerator/GradientRangeSettings/index.tsx */
import React from 'react';
//...
const GradientRangeSettings: React.FC<GradientRangeSettingsProps> = ({
gradient,
palettes,
activePaletteId,
setPalettes,
setActivePalette,
}) => (
<section className="gradient-range-settings">
<div
className="gradient-range-settings__slider-container"
style={{ background: gradient }}
>
<MultiThumbSlider
palettes={palettes}
activePaletteId={activePaletteId}
setPalettes={setPalettes}
setActivePalette={setActivePalette}
/>
</div>
</section>
);
export default GradientRangeSettings;
/* src/components/GradientGenerator/GradientRangeSettings/GradientSlider.scss */
@use '@styles/abstracts' as *;
$swap-icon-animation-time: 700ms;
$swap-icon-animation-timing-function: ease-in-out;
.gradient-range-settings {
@include theme-transition;
height: 120px;
display: grid;
grid-template-columns: 1fr auto;
grid-column-gap: 24px;
background-color: var(--surface-color);
padding: 32px 32px 36px 32px;
border: 2px solid var(--border-color);
border-top-left-radius: 30px;
border-top-right-radius: 30px;
@include mq(small) {
width: $section-width;
}
&__slider-container {
@include theme-transition;
position: relative;
height: 52px;
border: 2px solid var(--border-color);
border-radius: 40px;
transform-origin: left;
}
}
Let's create the main GradientGenerator.tsx file that uses the GradientRangeSettings
/* src/components/GradientGenerator/index.tsx */
import React, { useCallback, useEffect, useState } from 'react';
//…
const GradientGenerator: React.FC<GradientGeneratorProps> = ({
addNewMessage,
}) => {
const [gradient, setGradient] = useState<string>('');
const [palettes, setPalettes] = useState<Palette[]>([]);
const [activePalette, setActivePalette] = useState<Palette | null>(null);
const [gradientType, setGradientType] = useState<GradientTypes>(
GradientTypes.LINEAR
);
const [gradientPosition, setGradientPosition] = useState<string>('');
const handleGradientTypeChange = (type: GradientTypes, angle: string) => {
if (type === GradientTypes.LINEAR) {
setGradientType(type);
setGradientPosition(angle);
} else {
setGradientType(type);
setGradientPosition(angle);
}
};
const initGradient = useCallback((neededGradient: string) => {
const [
extractedGradientType,
extractedGradientAnglePoint,
gradientPalettes,
] = splitGradientString(neededGradient);
const newGradientPalettes = gradientPalettes.map((palette) => ({
...palette,
id: uuidv4(),
}));
setPalettes(newGradientPalettes);
handleGradientTypeChange(
extractedGradientType as GradientTypes,
extractedGradientAnglePoint
);
setActivePalette(newGradientPalettes[0]);
}, []);
useEffect(() => {
initGradient(defaultGradient);
}, [initGradient]);
const createGradientBackground = useCallback(() => {
const sortedPallets = [...palettes].sort(
(paletteA, paletteB) => paletteA.position - paletteB.position
);
const colorsAndPositionsString = sortedPallets
.map((palette) => `${palette.color} ${palette.position}%`)
.join(', ');
const result = `${gradientType}-gradient(${gradientPosition}, ${colorsAndPositionsString})`;
setGradient(result);
}, [palettes, gradientType, gradientPosition]);
useEffect(() => {
createGradientBackground();
}, [createGradientBackground, palettes, gradientType, gradientPosition]);
return (
<div className="gradient-generator">
<div className="gradient-generator__main">
<div className="gradient-generator-settings">
<div className="gradient-generator-settings__top">
<GradientRangeSettings
gradient={gradient}
palettes={palettes}
activePaletteId={activePalette?.id}
setPalettes={setPalettes}
setActivePalette={setActivePalette}
/>
</div>
</div>
</div>
</div>
);
};
export default GradientGenerator;
let's take a closer look at this component:
Use the useEffect hook to initialize the gradient when the component mounts. In this case, it initializes the gradient with a default value.
useEffect(() => {
initGradient(defaultGradient);
}, [initGradient]);
useEffect(() => {
createGradientBackground();
}, [createGradientBackground, palettes, gradientType, gradientPosition]);
Here, we have a basic structure that includes the GradientRangeSettings component.
Finally, render the component with the appropriate structure in our App.tsx
/* src/App.tsx */
import React, { useState, useLayoutEffect } from 'react';
import GradientGenerator from './components/GradientGenerator';
//...
const App: React.FC = () => {
/* ... */
return (
<main>
/* ... */
<GradientGenerator />
</main>
);
};
export default App;
The GradientActivePalette component is responsible for displaying and managing the active color palette in our app. User can select colors, adjust opacity, and delete palettes if allowed.
/* src/shared/constants.ts */
const defaultHexColor = '#000000ff';
const hexColorRegExp =
/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i;
/* src/components/GradientGenerator/GradientActivePalette/index.tsx */
import React, { useEffect, useState, useRef } from 'react';
import { HexColorPicker as Picker } from 'react-colorful';
//...
interface GradientActivePaletteProps {
activePalette: Palette;
canDeletePalette: boolean;
handleGradientColorChange: (color: string, isRGB?: boolean) => void;
handleDeletePalette: (paletteId: string) => void;
}
const GradientActivePalette: React.FC<GradientActivePaletteProps> = ({
activePalette,
canDeletePalette,
handleGradientColorChange,
handleDeletePalette,
}) => {
const [isShowColorPicker, setIsShowColorPicker] = useState<boolean>(false);
const [hexColor, setHexColor] = useState<string>(defaultHexColor);
const [hexColorInput, setHexColorInput] = useState<string>(defaultHexColor);
const [rgbaObject, setRgbaObject] = useState<KeyNumberValue>({
red: 0,
green: 0,
blue: 0,
alpha: 1,
});
const [colorOpacity, setColorOpacity] = useState<number>(100);
const [colorOpacityInput, setColorOpacityInput] = useState<string>('100%');
const previewRef = useRef<HTMLDivElement>(null);
const pickerWrapperRef = useOutsideClick(
() => setIsShowColorPicker(false),
previewRef?.current
);
const handleHexColorChange = (newHexColor: string) => {
setHexColor(newHexColor);
setHexColorInput(newHexColor);
};
const handleColorOpacityChange = (newColorOpacity: number) => {
setColorOpacity(newColorOpacity);
setColorOpacityInput(`${newColorOpacity}%`);
};
useEffect(() => {
const colorInHex = rgbaToHex(activePalette.color);
const alpha = parseFloat(activePalette.color.split(',')[3] as string);
const opacity = Math.round(alpha * 100);
handleHexColorChange(colorInHex);
handleColorOpacityChange(opacity);
}, [activePalette.id]);
useEffect(() => {
const newRgbaObject = hexToRgbaObject(hexColor);
setRgbaObject(newRgbaObject);
handleColorOpacityChange(newRgbaObject.alpha * 100);
}, [hexColor]);
const handlePickNewHexColor = (newHexColor: string) => {
handleHexColorChange(newHexColor);
handleGradientColorChange(newHexColor);
};
const handleBlurColorInput = (newHexColor: string) => {
if (hexColorRegExp.test(newHexColor)) {
setHexColor(newHexColor);
handleGradientColorChange(newHexColor);
} else {
handleHexColorChange(defaultHexColor);
handleGradientColorChange(defaultHexColor);
}
};
const handleChangeColorOpacity = (opacityValue: string) => {
const { red, green, blue } = rgbaObject;
let opacity = !opacityValue ? 0 : parseInt(opacityValue, 10);
if (opacity > 100) {
opacity = 100;
}
const newRgbaColor = `rgba(${red}, ${green}, ${blue}, ${opacity / 100})`;
const newHexColor = rgbaToHex(newRgbaColor);
handleHexColorChange(newHexColor);
handleColorOpacityChange(opacity);
handleGradientColorChange(newRgbaColor, true);
};
const handleKeyDownInput = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
};
const { red, green, blue } = rgbaObject;
return (
<section className="gradient-active-color">
<h2 className="gradient-generator__subheader">Color</h2>
<div className="gradient-active-color__content">
<div
ref={previewRef}
className="gradient-active-color__preview"
style={{ backgroundColor: `rgb(${red}, ${green}, ${blue}` }}
onClick={() => setIsShowColorPicker((prevState) => !prevState)}
/>
{isShowColorPicker && (
<div
ref={pickerWrapperRef}
className="gradient-active-color__picker-wrapper"
>
<Picker color={hexColor} onChange={handlePickNewHexColor} />
</div>
)}
<div className="gradient-active-color__settings">
<div className="gradient-active-color__inputs-container">
<input
aria-label="color-value"
className="gradient-active-color__input"
placeholder="Color"
value={hexColorInput}
onChange={(event) => setHexColorInput(event.target.value)}
onBlur={(event) => handleBlurColorInput(event.target.value)}
onKeyDown={handleKeyDownInput}
/>
<input
aria-label="color-opacity"
className="gradient-active-color__input"
value={colorOpacityInput}
onChange={(event) => setColorOpacityInput(event.target.value)}
onBlur={(event) => handleChangeColorOpacity(event.target.value)}
onKeyDown={(event) => {
handleKeyDownInput(event);
allowOnlyNumbers(event);
}}
/>
</div>
<div className="gradient-active-color__slider-container">
<input
aria-label="color-opacity"
className="gradient-active-color__slider"
style={{
['--slider-thumb-color' as string]: `rgb(${red}, ${green}, ${blue}`,
background: `linear-gradient(to right, rgba(${red}, ${green}, ${blue}, 0), rgba(${red}, ${green}, ${blue}, 1))`,
}}
type="range"
min="0"
max="100"
step="1"
value={colorOpacity}
onChange={(event) => handleChangeColorOpacity(event.target.value)}
/>
</div>
</div>
{canDeletePalette && (
<button
type="button"
aria-label="delete"
className="gradient-active-color__delete-btn"
disabled={!canDeletePalette}
onClick={() => handleDeletePalette(activePalette.id)}
>
<TrashIcon className="gradient-active-color__delete-icon" />
</button>
)}
</div>
</section>
);
};
export default GradientActivePalette;
The useOutsideClick hook is used to detect clicks outside a specified element (excluding it). The pickerWrapperRef is used to wrap the color picker element, and the useOutsideClick hook is applied to it. This ensures that when a click occurs outside the color picker, the setIsShowColorPicker(false) callback is called, hiding the color picker.
/* src/components/GradientGenerator/GradientActivePalette/index.tsx */
const pickerWrapperRef = useOutsideClick(
() => setIsShowColorPicker(false),
previewRef?.current
);
/* src/shared/hooks/useOutsideClick.tsx */
const useOutsideClick = (
callback: () => void,
excludedElement?: HTMLDivElement | null
) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (excludedElement && event.target === excludedElement) {
return;
}
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [callback, excludedElement]);
return ref;
};
Handles the deletion of the palette in GradientGenerator.tsx
/* src/components/GradientGenerator/GradientActivePalette/index.tsx */
const GradientActivePalette: React.FC<GradientActivePaletteProps> = ({
handleDeletePalette,
}) => {
/* ... */
return (
/* ... */
{canDeletePalette && (
<button
/* ... */
onClick={() => handleDeletePalette(activePalette.id)}
>
<TrashIcon className="gradient-active-color__delete-icon" />
</button>
)}
/* ... */
/* src/components/GradientGenerator/index.tsx */
const GradientGenerator: React.FC<GradientGeneratorProps> = () => {
/* ... */
const handleDeletePalette = (paletteId: string) => {
const filteredPalettes = palettes
.filter((palette) => palette.id !== paletteId)
.sort((paletteA, paletteB) => paletteA.position - paletteB.position);
setPalettes(filteredPalettes);
setActivePalette(filteredPalettes[0]);
};
return (
<div className="gradient-generator">
<div className="gradient-generator__main">
<div className="gradient-generator-settings">
/* ... */
{activePalette && (
<GradientActivePalette
activePalette={activePalette}
canDeletePalette={palettes.length > 2}
handleGradientColorChange={handleGradientColorChange}
handleDeletePalette={handleDeletePalette}
/>
)}
</div>
</div>
</div>
</div>
);
};
Wow, we've done a lot, let's continue!
The GradientPreview component is designed to display a visual preview of the gradient along with its associated palettes. It allows users to select a palette and delete palettes if needed.
/* src/components/GradientGenerator/GradientPreview/index.tsx */
import React from 'react';
import './GradientPreview.scss';
//…
interface GradientPreviewProps {
gradient: string;
palettes: Palette[];
activePaletteId: string | undefined;
setActivePalette: (palette: Palette) => void;
handleDeletePalette: (paletteId: string) => void;
}
const GradientPreview: React.FC<GradientPreviewProps> = ({
gradient,
palettes,
activePaletteId,
setActivePalette,
handleDeletePalette,
}) => (
<section className="gradient-preview">
<div className="gradient-preview__panel">
{[...palettes]
.sort((paletteA, paletteB) => paletteA.position - paletteB.position)
.map((pallet) => (
<div
key={pallet.id}
className={classnames('gradient-preview-pallet', {
active: pallet.id === activePaletteId,
})}
onClick={() => setActivePalette(pallet)}
>
<div
className="gradient-preview-pallet__inner"
style={{
backgroundColor: removeAlphaFromRgbaColor(pallet.color),
}}
/>
<div className={classnames('gradient-preview-pallet__delete-btn', {
canDelete: palettes.length > 2,
})}
onClick={() => handleDeletePalette(pallet.id)}
>
<CloseIcon className="gradient-preview-pallet__delete-icon" />
</div>
</div>
))}
</div>
<div className="gradient-preview__gradient-container">
<div
className="gradient-preview__gradient"
style={{ background: gradient }}
/>
</div>
</section>
);
export default GradientPreview;
/* src/components/GradientGenerator/GradientPreview/GradientPreview.scss */
@use '@styles/abstracts' as *;
.gradient-preview {
@include theme-transition;
min-height: 496px;
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: 1fr;
grid-row-gap: 22px;
padding: 34px 32px 32px;
background-color: var(--surface-color);
border: 2px solid var(--border-color);
border-radius: 30px;
/* ... */
}
In the the parent component (GradientGenerator.tsx), the GradientPreview component is used as follows
/* src/components/GradientGenerator/index.tsx */
/* ... */
const GradientGenerator: React.FC<GradientGeneratorProps> = ({
/* ... */
return (
<div className="gradient-generator">
<div className="gradient-generator__main">
<GradientPreview
gradient={gradient}
palettes={palettes}
activePaletteId={activePalette?.id}
setActivePalette={setActivePalette}
handleDeletePalette={handleDeletePalette}
/>
/* ... */
The GradientTypeAndAngle component allows users to select a gradient type (linear or radial) and adjust gradient angle or position interactively. It is implemented with various UI elements and event handlers to manage user interactions.
/* src/components/GradientGenerator/GradientTypeAndAngle/index.tsx */
import React, { useEffect, useRef, useState } from 'react';
//...
interface GradientTypeAndAngleProps {
gradientType: GradientTypes;
gradientPosition: string;
handleGradientTypeChange: (type: GradientTypes, position: string) => void;
setGradientPosition: (position: string) => void;
}
const radianPickZoneDimension = 62;
const GradientTypeAndAngle: React.FC<GradientTypeAndAngleProps> = ({
gradientType,
gradientPosition,
handleGradientTypeChange,
setGradientPosition,
}) => {
const pickZoneRef = useRef<HTMLDivElement | null>(null);
const [isMouseDown, setIsMouseDown] = useState<boolean>(false);
const [angleInDegree, setAngleInDegree] = useState<string>('0\xB0');
const [radialXPosition, setRadialXPosition] = useState<number>(50);
const [radialYPosition, setRadialYPosition] = useState<number>(50);
useEffect(() => {
document.addEventListener('mousedown', () => setIsMouseDown(true));
document.addEventListener('mouseup', () => setIsMouseDown(false));
return () => {
document.removeEventListener('mousedown', () => setIsMouseDown(true));
document.removeEventListener('mouseup', () => setIsMouseDown(false));
};
}, []);
useEffect(() => {
if (gradientPosition) {
if (gradientType === GradientTypes.LINEAR) {
setAngleInDegree(`${parseInt(gradientPosition, 10)}\xB0`);
}
if (gradientType === GradientTypes.RADIAL) {
const splitRadialPosition = gradientPosition.split(' ');
const xPosition = parseInt(splitRadialPosition[2], 10);
const yPosition = parseInt(splitRadialPosition[3], 10);
setRadialXPosition(xPosition);
setRadialYPosition(yPosition);
}
}
}, [gradientPosition, gradientType]);
const handleLinearCircleClick = (
event:
| React.TouchEvent<HTMLDivElement>
| React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
const rect = pickZoneRef.current?.getBoundingClientRect();
if (rect) {
const { top, bottom, left, right } = rect;
const xCircleCenter = (left + right) / 2;
const yCircleCenter = (top + bottom) / 2;
let xPoint = (event as React.MouseEvent<HTMLDivElement, MouseEvent>)
.clientX;
let yPoint = (event as React.MouseEvent<HTMLDivElement, MouseEvent>)
.clientY;
if (event.type === 'touchmove') {
xPoint = (event as React.TouchEvent<HTMLDivElement>).touches[0].clientX;
yPoint = (event as React.TouchEvent<HTMLDivElement>).touches[0].clientY;
}
const deltaX = xCircleCenter - xPoint;
const deltaY = yCircleCenter - yPoint;
const rad = Math.atan2(deltaY, deltaX);
let deg = Math.round(rad * (180 / Math.PI)) - 90;
if (deg < 0) {
deg = (deg + 360) % 360;
}
setGradientPosition(`${deg}deg`);
}
};
const handleRadialSquareClick = (
event:
| React.TouchEvent<HTMLDivElement>
| React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
const rect = pickZoneRef.current?.getBoundingClientRect();
if (rect) {
const { right, bottom } = rect;
let xPoint = (event as React.MouseEvent<HTMLDivElement, MouseEvent>)
.clientX;
let yPoint = (event as React.MouseEvent<HTMLDivElement, MouseEvent>)
.clientY;
if (event.type === 'touchmove') {
xPoint = (event as React.TouchEvent<HTMLDivElement>).touches[0].clientX;
yPoint = (event as React.TouchEvent<HTMLDivElement>).touches[0].clientY;
}
const deltaX = right - xPoint;
const deltaY = bottom - yPoint;
const percentageDeltaX = Math.round(
100 - (deltaX * 100) / radianPickZoneDimension
);
const percentageDeltaY = Math.round(
100 - (deltaY * 100) / radianPickZoneDimension
);
let finaleXPosition;
let finaleYPosition;
if (percentageDeltaX < 0) {
finaleXPosition = 0;
} else if (percentageDeltaX > 100) {
finaleXPosition = 100;
} else {
finaleXPosition = percentageDeltaX;
}
if (percentageDeltaY < 0) {
finaleYPosition = 0;
} else if (percentageDeltaY > 100) {
finaleYPosition = 100;
} else {
finaleYPosition = percentageDeltaY;
}
setGradientPosition(`circle at ${finaleXPosition}% ${finaleYPosition}%`);
}
};
const handleDegreeChange = (value: string) => {
setAngleInDegree(value);
};
const handleDegreeBlur = (value: string) => {
let degree = !value ? 0 : parseInt(value, 10);
if (degree > 360) {
degree = 360;
}
setAngleInDegree(`${degree}\xB0`);
setGradientPosition(`${degree}deg`);
};
const handleDegreeKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
allowOnlyNumbers(event);
};
return (
<section className="gradient-type-and-angle">
<div className="gradient-type">
<h2 className="gradient-generator__subheader">Type</h2>
<div className="gradient-type__buttons-container">
<button
type="button"
className={classnames('gradient-type__btn', {
active: gradientType === GradientTypes.LINEAR,
})}
onClick={() =>
handleGradientTypeChange(
GradientTypes.LINEAR,
`${parseInt(angleInDegree as string, 10)}deg`
)
}
>
Linear
</button>
<button
type="button"
className={classnames('gradient-type__btn', {
active: gradientType === GradientTypes.RADIAL,
})}
onClick={() =>
handleGradientTypeChange(
GradientTypes.RADIAL,
`circle at ${radialXPosition}% ${radialYPosition}%`
)
}
>
Radial
</button>
</div>
</div>
{gradientType === GradientTypes.LINEAR ? (
<div className="gradient-angle-linear">
<h2 className="gradient-generator__subheader">Angle</h2>
<div className="gradient-angle-linear__content">
<div
ref={pickZoneRef}
className="gradient-angle-linear__circle"
style={{
rotate: `${parseInt(angleInDegree as string, 10)}deg`,
}}
onClick={handleLinearCircleClick}
onMouseMove={isMouseDown ? handleLinearCircleClick : undefined}
onTouchMove={handleLinearCircleClick}
>
<AngleCircleIcon className="gradient-angle-linear__icon" />
<div className="gradient-angle-linear__dot" />
</div>
<input
aria-label="angle"
className="gradient-angle-linear__input"
value={angleInDegree}
onChange={(event) => handleDegreeChange(event.target.value)}
onBlur={(event) => handleDegreeBlur(event.target.value)}
onKeyDown={handleDegreeKeyDown}
/>
</div>
</div>
) : (
<div className="gradient-angle-radial">
<h2 className="gradient-generator__subheader">Position</h2>
<div className="gradient-angle-radial__content">
<div
className="gradient-angle-radial__square-wrapper"
style={{
width: `${radianPickZoneDimension}px`,
height: `${radianPickZoneDimension}px`,
}}
onClick={handleRadialSquareClick}
onMouseMove={isMouseDown ? handleRadialSquareClick : undefined}
onTouchMove={handleRadialSquareClick}
>
<div
ref={pickZoneRef}
className="gradient-angle-radial__square"
style={{
width: `${radianPickZoneDimension}px`,
height: `${radianPickZoneDimension}px`,
}}
>
<div
className="gradient-angle-radial__dot"
style={{
top: `${radialYPosition}%`,
left: `${radialXPosition}%`,
}}
/>
</div>
</div>
</div>
</div>
)}
</section>
);
};
export default GradientTypeAndAngle;
Let's make our gradient maker better. We'll add a button that, when you click it, lets you switch the colors in the gradient and change their order.
/* src/components/GradientGenerator/GradientRangeSettings/index.tsx */
import React from 'react';
//...
const GradientRangeSettings: React.FC<GradientRangeSettingsProps> = ({
gradient,
palettes,
/* ... */
}) => (
const handleSwapColors = () => {
const sortedPallets = [...palettes].sort(
(paletteA, paletteB) => paletteA.position - paletteB.position
);
const reversePositions = sortedPallets
.map((palette) => palette.position)
.reverse();
const swappedPalettes = sortedPallets
.map((palette, paletteIndex) => ({
...palette,
position: reversePositions[paletteIndex],
}))
.sort((paletteA, paletteB) => paletteA.position - paletteB.position);
setPalettes(swappedPalettes);
};
return (
<section className="gradient-range-settings">
<div
className="gradient-range-settings__slider-container"
style={{ background: gradient }}
>
<MultiThumbSlider
palettes={palettes}
activePaletteId={activePaletteId}
setPalettes={setPalettes}
setActivePalette={setActivePalette}
/>
</div>
<button
type="button"
aria-label="swap"
className="gradient-range-settings__swap-btn"
onClick={handleSwapColors}
>
<SwapIcon className="gradient-range-settings__swap-icon" />
</button>
</section>
);
};
export default GradientRangeSettings;
That's it! We've created the Gradient Generator component for our app!
In the second part of the tutorial, the focus is on creating a GradientGenerator component. This component is designed to generate and display gradient backgrounds according to user preferences.
The tutorial takes you through several key steps, starting with establishing a basic layout that includes a header and a logo, as well as implementing a theme mode switcher. The core of the tutorial is dedicated to the process of implementing gradient generation, enabling users to easily swap colors within the gradient.
24.12.2023
Agile vs Waterfall Software Development Methods: 7 Key DifferencesAgile and Waterfall are the two most prominent methods in the software development industry today. Although they are both solid and offer the most practical way to finish a project as quickly as possible, they're different in many ways. Here are 7 of them.Read more22.12.2023
How to Build a Tool for Generating Linear and Radial Gradients with React: Complete Tutorial, Part 3Hello! Welcome back to part three of our tutorial for excited developers. In this tutorial, We'll use React for our app, utilize the react-colorful library for color manipulation, add smooth animations with framer-motion, ensure type safety with TypeScript, and style our app using Sass.Read more22.12.2023
How to Build a Tool for Generating Linear and Radial Gradients with React: Complete Tutorial, Part 1Hey fellow developers and welcome to an exciting journey into the colorful world of gradient generation! In this tutorial, we'll use React, utilize the react-colorful library for color manipulation, add smooth animations with framer-motion, ensure type safety with TypeScript, and style our app using Sass.Read more