LANARS

How to Build a Tool for Generating Linear and Radial Gradients with React: Complete Tutorial, Part 2

How to Build a Tool for Generating Linear and Radial Gradients with React: Complete Tutorial, Part 2
Time to read
58 min
Section
Share
Subscribe
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

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:

  • Start the project and set up React and Vite, install necessary dependencies
  • Add custom fonts and other media
  • Include types and constants.
  • Create a basic layout with a header and logo.
  • Add a feature to switch between light and dark themes.
  • Make gradients using a Gradient Generator.
  • Create the Color Picker Component using the react-colorful library
  • Let users copy gradient CSS code.
  • Add animation to your gradients using framer-motion.

Get ready to rock and code!

Basic layout: header + logo

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;

 

Theme mode switcher

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>

 

Implementing Gradient Generation

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:

1. Create a slider component to manage color positions

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:

  • Check if the click target is the slider container (sliderContainerRef.current).
  • Calculate the position for the new color thumb based on the mouse click position relative to the slider width.
  • Create a new palette object with a unique ID, RGBA color, and calculated position.
  • Set the active palette to the new palette.
  • Update the state with the new palette added to the palettes array.
/* 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;
  }
}

2. Create the main Gradient Generator file

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:

  • The handleGradientTypeChange function is used to update the gradient type and position when the user changes it. This function takes two parameters: type (of type GradientTypes) and angle (of type string).
  • The initGradient function initializes the gradient based on a given string. It uses the splitGradientString function to extract information from the string and set the state accordingly.
  • The createGradientBackground function generates the CSS background property value for the gradient based on the palettes and gradient settings. It uses the sorted palettes to create the gradient string and updates the gradient state.

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]);

 

  • Use another useEffect hook to update the gradient whenever there are changes in the palettes, gradient type, or gradient position.
  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;

 

3. Create the Gradient Active Palette component for handling color selection and opacity, enabling palette removal

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!

4. Create a preview of a gradient with associated palettes

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}
        />
/* ... */

 

5. Create a component for handle both linear and radial gradient types with angle or position controls

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;

6. Swap the colors in the gradient

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!

Summary

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.