DEV Community

Cover image for ⚛️React.js + 🌈Gradients = Gradientti
Eva Raymond
Eva Raymond

Posted on

⚛️React.js + 🌈Gradients = Gradientti

As a newbie frontend developer, you might be looking for a portfolio project to show case your front-end skills. Gradientti is a simple app which does this - you can view css color gradients, make your own and also copy the generated css code.

Details about the App: Live Link | Github Link

The Gradientti app is built using React.js and Tailwindcss to make development faster and easier.

Note: this guide only focuses on the core logic / features of the app and leaves the setup and css styles out (as there are many articles on the dev stack).

Section Guide


1. The App Structure

The App is divided into four main components namely:

  • The Header
    Gradientti Header The Header component consists of the App Logo, a color panel displaying the gradient colors, a button to copy the css gradient code, a button to add new gradients and a button to view all gradients.

  • The Background
    Gradientti Background The Background which is the main body of the app has the css gradient background and button controls to cycle forwards or backwards through the list of available gradients.

  • The Sidebar
    Gradientti Sidebar The Sidebar's visibility is controlled by a button. It is like a select menu to view all the gradient swatches and for quick navigation.

  • The Footer
    Gradientti Footer The Footer which is optional but nice to have.

2. The useSequence Hook

The useSequence hook is the engine behind changing the css background gradients. It provides functions to cycle through the list of gradients backwards / forwards or going to a particular index. It uses React's useReducer state pattern to organize the logic.

// reducer actions
const INC = 'INCREMENT'
const DEC = 'DECREMENT'
const GOTO = 'GOTO'
const SYNC = 'SYNC'

// hook to provide cycling logic through the list
const useSequence = ({ count, direction = 0, start = 0, end = 4 }) => {
  const defaultCount = count || start
  const initialState = { count: defaultCount, defaultCount, direction, start, end }

  const [state, dispatch] = useReducer(reducer, initialState)

  const increment = useCallback(() => dispatch({ type: INC }), [dispatch])
  const decrement = useCallback(() => dispatch({ type: DEC }), [dispatch])
  const goto = useCallback(index => dispatch({ type: GOTO, index }), [dispatch])
  const sync = useCallback((index, bounds = 'end') => dispatch({ type: SYNC, bounds, index }), [dispatch])

  return {
    ...state,
    increment,
    decrement,
    goto,
    sync,
  }
}
Enter fullscreen mode Exit fullscreen mode

As part of the reducer state,

  • the start and end state defines the min and max bounds the reducer will cycle through. If start = 0 and end = 4, the cycle will be 0 -> 1 -> 2 -> 3 -> 4 and repeats.
  • count represents the current position / index of the cycle.
  • direction - could be -1, 0 or 1 to determine if we are moving backwards, static or forwards.

The below reusable functions will dispatch actions that will update our state if necessary,

  • increment() will cycle forwards while decrement() will cycle backwards
  • goto(index) will cycle to a particular index
  • sync(index, bounds: end | start) will update our start or end bounds to the particular index. This will be useful when dynamically setting the bounds as in the case of adding a new gradient to our predefined gradient list.
// state reducer
const reducer = (state, { type, bounds, index }) => {
  const { count, start, end } = state
  const total = end - start + 1

  switch (type) {
    case INC:
      return {
        ...state,
        direction: 1,
        count: (count + 1 + total) % total,
      }
    case DEC:
      return {
        ...state,
        direction: -1,
        count: (count - 1 + total) % total,
      }
    case GOTO:
      return { ...state, direction: 0, count: clamp(index, start, end) }
    case SYNC:
      return {
        ...state,
        [bounds]: index,
      }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

The clamp utility function is an extra check / guard to prevent our sequencer from cycling out of the start and end bounds.

export const clamp = (num, lower, upper) => (upper ? Math.min(Math.max(num, lower), upper) : Math.min(num, lower))

Enter fullscreen mode Exit fullscreen mode

3. Putting It Together

The list of gradients is stored as an array of objects in javascript. For example,

const gradients = [
 {
  name: 'Cosmic Tail',
  start: '#780206',
  end: '#061161',
 },
 {
  name: 'Berry Bloom',
  start: '#FBD3E9',
  end: '#BB377D',
 },
 ...
]
Enter fullscreen mode Exit fullscreen mode

This will be stored in React's state so that we can iterate through it and update our app's UI whenever changes are made to it.

The last gradient in our list will serve as the end bounds of our useSequence hook which generates our current index (count) in the gradient list. The increment() and decrement() functions will be assigned to the onClick event handler of our navigation buttons.

import gradients from './gradients'

const [gradientList, setGradientList] = useState(gradients)
const { count, increment, decrement } = useSequence({
    end: gradientList.length - 1,
  })
const { name, start, end } = gradientList[count]

// pseudo code
return (
  ...

  <PrevButton onClick={decrement} />
  <NextButton onClick={increment} />
  <GradientBackground style={{ backgroundImage: `linear-gradient(to right, ${start}, ${end})` }}>{name}</GradientBackground>

  ...
)

Enter fullscreen mode Exit fullscreen mode

4. Displaying the gradientList in the Sidebar

In this part, we want a button to toggle open the sidebar which displays our list of available gradients in a grid. Whenever a gradient swatch is selected, our gradient background should update to the selected gradient.

Luckily, we can use the Listbox and Transition components from the @headlessui/react package to create an animated sidebar.

Because the Listbox manages the selection internally, we make it a controlled component by providing it value and onChange props so that the selected gradient can be available to our app's state and UI.

// pseudo code
const { count, goto } = useSequence({
    end: gradientList.length - 1,
  })
const gradient = gradientList[count]

const handleChange = useCallback(
  selected => {
    goto(selected)
  },
  [goto]
)

<ListBox value={count} onChange={handleChange}>
  ...
<ListBox>
Enter fullscreen mode Exit fullscreen mode

We pass the current index (count) as the value of the ListBox. Whenever the internal value changes, the handleChange() callback is called and passed the new value. We utilize the goto() function from the useSequencer hook to navigate to the new value which becomes the current index in our gradient list.

We also map through the gradient list and wrap each gradient swatch with the Listbox.Options component. The index of each child is used as the value of the component.

// pseudo code from GradientsView.js
<Listbox value={count} onChange={handleChange}>
    <Lisbox.Button />
    <Listbox.Options>
        {gradientList.map(({name,start,end}, i) => {
            <Listbox.Option key={name} value={i}>
                <GradientSwatch style={{backgroundImage: `linear-gradient(to right, ${start}, ${end})`}}>
                    {name}
                </GradientSwatch>
            </Listbox.Option>
        })}
    </Listbox.Options>
</Listbox.Options>
Enter fullscreen mode Exit fullscreen mode

5. Adding a new Gradient Swatch

A nice feature to have in our gradient background changing app is the ability to add new gradient swatches to the default gradient list. The add button in our Header component will be responsible for toggling open a modal to display a form which can be used to add new gradients.

Add New Gradient Form Modal

To help build this feature, we will use the Dialog component from @headlessui/react package to create the modal, the hex color input and picker from react-colorful package and the @tailwindcss/forms plugin to reset html form input styles.

We have our formGradientState which holds the values for each form input - start, end and name and the errorsState for validation and submission errors.

// initialGradient from the App's State to initialize form inputs
const [newGradient, setNewGradient] = useState(initialGradient)
const [errors, setErrors] = useState({})

const { start, end, name } = newGradient
const { gradient: errGradient, name: errName } = errors
Enter fullscreen mode Exit fullscreen mode

The HexColorPicker and HexColorInput from react-colorful have an internally managed state in which we can access the selected color through their onChange callback function. The components have in-built validation (prevent empty inputs, incorrect hex colors etc) hence error handling is done only for form submission.

// pseudo code
const color: start | end

// handler for both start and end colors
const handleColorChange = useCallback(
    key => color => {
      setNewGradient(_gradient => ({ ..._gradient, [key]: color 
    }))
      // clear errors on color input change
      setErrors(_errors => ({ ..._errors, gradient: '' }))
    },
    []
)
...
<HexColorInput color={color} onChange={handleColorChange('start')} />
<HexColorPicker color={color} onChange={handleColorChange('start')} />
...
{errGradient && <ErrorText text="Gradient already exists."/>
Enter fullscreen mode Exit fullscreen mode

In contrast, the form input for the gradient name uses the html client-side validation to guard against errors.

const handleNameChange = useCallback(({ target }) => {
    target.setCustomValidity('')
    setNewGradient(_gradient => ({ ..._gradient, name: 
    target.value }))
    setErrors(_errors => ({ ..._errors, name: '' }))
}, [])

const handleNameValidity = useCallback(({ target }) => {
    if (target.validity.valueMissing) {
      target.setCustomValidity('Name is required.')
    } else if (target.validity.patternMismatch) {
      target.setCustomValidity('Name is invalid. Use 2 or more 
      letters.')
    }
}, [])

...
<input
    value={name}
    onChange={handleNameChange}
    onInvalid={handleNameValidity}
    type="text"
    pattern="[a-zA-Z]+[\s]?[A-Za-z]+"
    required
/>
{errName && <ErrorText text="Name already exists."/>
...

Enter fullscreen mode Exit fullscreen mode

The ErrorText component displays errors from the form submission. When we want to add a new gradient, we first check if that gradient swatch does not exist in our gradient list before submitting.

// utility to check if new gradient is in our gradient list
const checkIfExist = (list, item) => {
  const nameExist = list.some(_item => _item.name === item.name)
  const gradientExist = list.some(_item => _item.start === 
  item.start && _item.end === item.end)
  return { gradientExist, nameExist }
}

const handleSubmit = useCallback(
    e => {
      e.preventDefault()
      const { gradientExist = '', nameExist = '' } = 
      checkIfExist(gradientList, newGradient)
      setErrors({ name: nameExist, gradient: gradientExist })
      if (!(gradientExist || nameExist)) {
        onMake?.(newGradient)
      }
    },
    [gradientList, newGradient, onMake]
)

Enter fullscreen mode Exit fullscreen mode

The onMake callback sends the new gradient to our App when all validation has been done successfully during submission.

In our App, we use the handleGradientAdd callback to retrieve the new gradient and add it to our gradient list. The new gradient is inserted at the index after our currently displayed gradient. We also update our end bounds to accommodate the additional gradient and then navigate to the new gradient using increment().

const lastGradientIndex = gradientList.length - 1
const lastGradientIndexRef = useRef(lastGradientIndex)
const { count, increment, sync } = useSequence({
    end: lastGradientIndex,
})

const handleGradientAdd = useCallback(
    newGradient => {
      setGradientList(list => {
        lastGradientIndexRef.current = list.length
        return insertAt(list, count + 1, newGradient)
      })
      sync(lastGradientIndexRef.current)
      increment()
    },
    [count, increment, sync]
)
Enter fullscreen mode Exit fullscreen mode

6. Copying the CSS Gradient Code

Another useful feature to have in the app is for anyone to copy the css gradient code easily. When we click the copy button in the Header component, we open the modal with the css gradient code block for the currently displayed gradient background.

CSS Copy Modal

In the modal, there's a vendor prefix checkbox to toggle browser compatibility code and the button to copy the css code. This is easily achieved using the useClipboard hook from the use-clipboard-copy package and the prismjs package for css syntax highlighting.

import { useEffect } from 'react'
import { highlightAll } from 'prismjs/components/prism-core'
import 'prismjs/components/prism-css'
import 'prismjs/plugins/line-numbers/prism-line-numbers'
import 'prismjs/plugins/line-numbers/prism-line-numbers.css'
import 'prismjs/plugins/normalize-whitespace/prism-normalize-whitespace'

function CodeBlock({ code }) {
  useEffect(() => {
    highlightAll()
  }, [code])

  return (
    <div className="overflow-auto">
      <pre className="language-css line-numbers">
        <code>{code}</code>
      </pre>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode
import { useState, useCallback } from 'react'
import { useClipboard } from 'use-clipboard-copy'

// pseudo code
const code: prefixedCss | normalCss

const [prefix, setPrefix] = useState(true)
const { copy, copied } = useClipboard({
    copiedTimeout: 2000,
})

const handleChange = useCallback(e => {
    setPrefix(e.target.checked)
}, [])
const handleCopy = useCallback(() => {
    copy(code)
}, [copy, code])

return (
  ...
  <Codeblock code={code} />
  ...
  <input type="checkbox" checked={prefix} onChange= 
  {handleChange}/>
  <button onClick={handleCopy}>Copy CSS</button>
  ...
)
Enter fullscreen mode Exit fullscreen mode

In conclusion, we've highlighted the logic and features found in our gradient changing background app. The full code is accessible at the github repo. Please don't forget to star the repo if you found it useful.

Top comments (0)