If you’ve spent time on social media, you’ve probably seen one of your friends change their Facebook profile picture to include a frame supporting their favorite sports team, or a colleague's LinkedIn profile with a frame about openness to work. In this article, we’ll build our own profile picture frame generator in React using React Konva. With a high level of customization, you can build a profile picture frame generator for your online event participants to use. Our final output will look like the image below:
The HTML5 <canvas> element is a powerful tool for incorporating dynamic, performant visuals in web apps. <canvas> supports a wide range of browsers and includes built-in APIs for adding shapes, pictures, and text. A variety of utility libraries like Konva.js and p5.js have been created on top of Canvas to assist with event handling and animations.
I choose Konva.js over the many other canvas libraries available. The Canvas API is essentially imperative, and it was built to interact with vanilla JavaScript with methods like ctx.rect(x, y, width, height). This syntax is completely alien to React developers and is incompatible with modern React patterns. React Konva, a React wrapper for Konva.js, allows you to use React component interfaces to connect with the Konva API.
Before you begin, this article assumes that you are familiar with the following concepts.
Familiarity with React and its basic usage. You can check out getting started with Create React App to brush up on your React skills before we start.
Understanding of a state management tool such as Redux or Context API. This guide on using redux in react apps will get you covered. Alternatively, you can also check out this guide on How to use React Context effectively
Prior knowledge about react-konva would be beneficial. You can take a look at this guide to canvas manipulation using react-konva
Here is a look at all the technologies and libraries we'll be using
react create-react-app styled-components react Context API react-konva netlify
First, we'll install Yarn to make our development process much smoother. To run this command, you'll need to have Node.js installed. You can check whether Node.js is installed using the command node --version. Installing Node.js will automatically enable the npm and npx commands:
Install yarn. Yarn is a package manager just like npm but faster. Using yarn would make your development process much smoother.
# Globally install yarnnpm install -g yarn
Create a basic react app using CRA. To run this command, you need to have node.js installed which you can check if you have Node using the command node --version. You can find the links to download on Node.js's website. Installing node.js will automatically enable the npm and npx commands.
# Using npmnpx create-react-app profile-frames# Using yarnyarn create react-app profile-frames
Setting up a clean directory structure. Delete all the files that come with CRA and create the required folders and files as shown below.
Running your React app. Create an index.js and App.jsx files in the src directory and add the following code.
// App.jsximport React from 'react';const App = () => {return (<div><h1>Hello World</h1></div>);};export default App;// index.jsimport React from 'react';import ReactDOM from 'react-dom';import App from './App';ReactDOM.render(<React.StrictMode><App /></React.StrictMode>,document.querySelector('#root'),);
Installing the required packages and libraries. We will be using styled-components for css styling and react-konva for canvas manipulations
yarn add styled-components react-konva konva
In this article, we won’t focus on CSS styling in great detail. You can find the details of specific implementation in the repository linked below.
Before you begin coding, you must first make a detailed list of the features we intend to include, based on which you would build your components. The features you'll be implementing are listed below.
Depending upon the above list of required features, the following is a component architecture that that would be suitable for the project.
Note: This article focuses on react-konva usage and will not focus much on styling and css. You can find the details of specific implementation in the repository linked below.
import React, {useRef} from 'react';// Librariesimport {Layer, Stage, Image, Text} from 'react-konva';// Assetsimport frame1 from '../assets/frame1';import profile from '../assets/frame1';const Canvas = () => {/*** A list of variables containing the various details that we* would require for all the different elements.** It is better to separate them now as we will be shifting* them to their global states later*/const stageRef = useRef();const stageDetails = {width: 350,height: 350,x: 0,y: 0,};const imageDetails = {originalDimensions: {width: 0,height: 0,},renderDimensions: {width: 300,height: 300,},position: {x: 0,y: 0,},};const textDetails = {name: {value: 'name field',id: 'user-name',dimensions: {width: 100,height: 50,},position: {x: 50,y: 50,},},guild: {value: 'guild field',id: 'user-guild',dimensions: {x: 100,y: 50,},position: {x: 100,y: 100,},},};return (<Stageref={stageRef}width={stageDetails.width}height={stageDetails.height}style={{position: 'relative'}}><Layer><Imageimage={profile}width={imageDetails.renderDimensions.width}height={imageDetails.renderDimensions.height}x={imageDetails.position.x}y={imageDetails.position.y}/><Texttext={textDetails.name.value}width={textDetails.name.dimensions.width}height={textDetails.name.dimensions.height}x={textDetails.name.position.x}y={textDetails.name.position.y}/><Texttext={textDetails.guild.value}width={textDetails.guild.dimensions.width}height={textDetails.guild.dimensions.height}x={textDetails.guild.position.x}y={textDetails.guild.position.y}/><Imageimage={frame1}width={stageDetails.width}height={stageDetails.height}x={0}y={0}style={{position: 'absolute', top: 0, left: 0, zIndex: 100}}/></Layer></Stage>);};export default Canvas;
import React from 'react';// Librariesimport {Rect, Text, Group} from 'react-konva';const CustomText = ({dimensions, position, name}) => {const shapeRef = React.useRef(null);/*** As with other konva components, group also takes in* width, height and positions x and y.** In addition to this, the properities of offsetX and offsetY* prop which shifts its coordinate system origin to the center instead* of the top-left corner are also added.** This would help in positioning both the rectangle and the* text element at the center of the group.*/const groupProps = {width: dimensions.width,height: dimensions.height,offsetX: dimensions.width / 2,offsetY: dimensions.height / 2,x: position.x,y: position.y,};return (<Group ref={shapeRef} {...groupProps}>{/* The width of both the elements are kept same */}{/* Not passing any positions defaults them to x=0 and y=0 */}<Rectwidth={dimensions.width}height={dimensions.height}cornerRadius={[7, 7, 7, 7]}fill="lightblue"/><Textwidth={dimensions.width}height={dimensions.height}align="center"verticalAlign="middle"text={name}fontSize={20}/></Group>);};export default CustomText;
import profile from '../assets/frame1';const Canvas = () => {/* Existing Code *//*** Okay so the imageDetails variables are removed and* shifted to a state. Not only this but also 2 new properties of* scale defaulted to 1 which would determine* the size of our shape/element and id are added** In addition to that, a new state called selectedElement is also* selectedElement. This element stores an id or unique field which* showcases which element is currently selected.*/const [selectedElement, setSelectedElement] = useState(null);const [imageDetails, setImageDetails] = useState({originalDimensions: {width: 0,height: 0,},renderDimensions: {width: 300,height: 300,},position: {x: 0,y: 0,},scale: 1,id: 'user-profile-image',image: profile,});/* Existing code */};export default Canvas;
import React from 'react';// Librariesimport useImage from 'use-image';// Componentsimport {Image, Transformer} from 'react-konva';const CustomImage = ({imageDetails, setImageDetails, isSelected, onSelect}) => {/*** Create references to the shape which needs to be transformed* and to the transformer component itself.*/const shapeRef = React.useRef();const transformerRef = React.useRef();const {renderDimensions: {width, height},position: {x, y},image: rawImage,} = imageDetails;/*** Before we pass the image to react-konva element, we need to* preprocess it as per the requirements. Luckily, you don't have* to worry about it as a handy-package 'use-image' does it for you.*/const image = useImage(rawImage);/*** This effect runs whenever the isSelected variable is toggled* The isSelected variable is set from the parent element which indicates* that the current element is selected and is to be transformed.*/React.useEffect(() => {if (isSelected) {/*** Here you are instructing the transformer component via its ref to* enable the specified component i.e. the image is to be transformed* and then create the transformer box around it.* This code will run everytime the isSelected variable is updated.*/transformerRef.current?.nodes([shapeRef.current]);transformerRef.current?.getLayer().batchDraw();}}, [isSelected]);/*** The most important handler functions for transformations* You need to handle 2 things -* Change in Dimensions on transform and* Change in Positions on drag*//*** This function handles the dimension changes of the shape* If you recall, you have set a property named scale equal to 1 on* initialisation.* Using this handler, you need to update the scale property of this* shape which can be obtained from the shapeRef*/const onTransformEnd = () => {if (shapeRef.current) {const node = shapeRef.current;setImageDetails(current => ({...current, scale: node.scale()}));}};/*** This function handles the positional changes of the shape* You have positions (x and y) properties in the state which you* will update through this handler, similar to the onTransformEnd* function.*/const onDragEnd = () => {if (shapeRef.current) {const node = shapeRef.current;setImageDetails(current => ({...current, x: node.x(), y: node.y()}));}};return (<><Imageref={shapeRef}image={image}width={width}height={height}x={x}y={y}/**onSelect is a function that toggles the isSelectedvariable. This function is called when image isclicked or tapped.*/onClick={onSelect}onTap={onSelect}/** Transformation Handlers Explained above */onTransformEnd={onTransformEnd}onDragEnd={onDragEnd}/>{isSelected && (<Transformerref={transformerRef}boundBoxFunc={(oldBox, newBox) => {/**this function handles the sizing of the boxEssentially what it does is adding a checkto avoid reduction of size to 0if the newBox dimensions are less than 5 units,it returns the oldBox dimensions*/if (newBox.width < 5 || newBox.height < 5) {return oldBox;}return newBox;}}/>)}</>);};export default CustomImage;
Now that you have a transformable image component set up, let’s update the canvas stage with the new component. In the Canvas.jsx file, replace the Image component with the following code:
NOTE: Replace only the Image component used for the user profile image as you don't want the frame to be transformable.
<CustomImageimageDetails={imageDetails}setImageDetails={setImageDetails}isSelected={selectedElement === imageDetails.id}onSelect={() => setSelectedElement(imageDetails.id)}/>
With the current setup, we have a lot of state-related items stored in the component itself, like image details, text details, and stage details. Handler functions are in the same component. At this rate, your code will quickly grow messy and unreadable.
Additionally, with just three components in our code, we have a lot of prop drilling occurring, which isn’t good practice. We’ll need to uplift a few states here that are required by components like the inputs and the upload button.
Let’s set up global state management. We’ll use the Context API along with the useReducer Hook. I believe that at its core, React is a state management library, and therefore any external libraries like Redux aren’t necessary.
Kent C. Dodds has a phenomenal article explaining the Context API and useReducer pattern for state management.
Under the store/actions directory, create a new file - frames.action.js and add the following code.
/*** the useReducer hook from react takes the initialState as* one of its parameters. If no param is passed, the initial state* would be considered as null which not necessarily wrong but not at* all a better practice. It can lead to unknown undefined errors during* build time.* As defined below, this is the initial state structure considering all* the required fields related to the user profile image.*/export const initialState = {imageDetails: {originalDimensions: {width: 0,height: 0,},renderDimensions: {width: 0,height: 0,},position: {x: 0,y: 0,},scale: 1,id: 'user-profile-image',image: null,},};/*** Similar to redux, define all the different types of* actions related to image state changes to avoid any errors down* the line.*/export const CANVAS_ACTIONS = Object.freeze({UPLOAD_IMAGE: 'IMAGE/UPDATE_IMAGE_DETAILS',UPDATE_IMAGE_DIMENSIONS: 'IMAGE/UPDATE_IMAGE_RENDER_DIMENSIONS',UPDATE_IMAGE_POSITION: 'IMAGE/UPDATE_IMAGE_POSITIONS',});
import {CANVAS_ACTIONS} from '../actions/compose.action';/*** Similar to Redux, canvasReducer handles all the different* actions and the changes to be made to the state depending* on the action type.** For now, each case returns the default state. You'll start* writing cases after the context API is setup*/export default function canvasReducer(state, action) {switch (action.type) {case CANVAS_ACTIONS.UPLOAD_IMAGE:return state;case CANVAS_ACTIONS.UPDATE_IMAGE_DIMENSIONS:return state;case CANVAS_ACTIONS.UPDATE_IMAGE_POSITIONS:return state;default:return state;}}
import React, {useReducer, useMemo, createContext, useContext} from 'react';// Reducer, Initial State, Typesimport canvasReducer from '../reducers/frames.reducer';import {initialState} from '../actions/frames.action';/*** use the createContext function from react to create a context component*/const FramesContext = createContext(initialState);export function FramesCtxProvider(props) {/*** The useReducer hook provided by React enables you to create* global states. Similar to the useState hook, useReducer provides* access to the state through is first destructured variable and a* function - dispatch to which you pass an object consisting of 2 properites -** dispatch({* type: one of the types from CANVAS_ACTIONS,* payload: data that would be sent to reducer function to update the state,* })*/const [state, dispatch] = useReducer(canvasReducer, initialState);const value = useMemo(() => [state, dispatch], [state]);return <FramesContext.Provider value={value} {...props} />;}/*** A very handy custom hook to easily get access to the state and dispatch functions* in any component** This avoids quite a few steps where you would have to import the above context,* useContext hook from react and bunch of other steps.** Instead, all you have to do now is import this hook and call it inside a component!*/export function useFrames() {const context = useContext(FramesContext);if (!context)throw new Error('useFrames must be used within a FramesCtxProvider');const [state, dispatch] = context;return [state, dispatch];}
import React from "react";// Componentsimport { FramesCtxProvider } from "./store/contexts/frames.context";import Frames from "./pages/Frame";const App = () => {return (<FramesCtxProvider><Frames /></FramesCtxProvider>);};export default App;/* ==================== Inside pages/Frames.jsx ==================== */import React from "react";// Componentsimport Canvas from "../components/Canvas";const Frames = () => {return (<div><Canvas /></div>);};export default Frames;
/* Previous imports */import { FramesContext, useFrames } from "../store/contexts/frames.context";const Canvas = () => {/* remaining code */return (<FramesContext.Consumer>{(value) => (<Stageref={stageRef}width={stageDetails.width}height={stageDetails.height}style={{ position: "relative" }}><FramesContext.Provider value={value}><Layer>{/* remaining code */}</Layer></FramesContext.Provider value={value}></Stage>)}</FramesContext.Consumer>)}
export default function canvasReducer(state, action) {switch (action.type) {case CANVAS_ACTIONS.UPLOAD_IMAGE:return {...state,originalDimensions: {width: action.payload.width,height: action.payload.height,},image: action.payload.image,};case CANVAS_ACTIONS.UPDATE_IMAGE_DIMENSIONS:return {...state,scale: action.payload.scale,};case CANVAS_ACTIONS.UPDATE_IMAGE_POSITIONS:return {...state,position: {x: action.payload.x,y: action.payload.y,},};default:return state;}}
// State Handlersimport {useFrames} from '../store/contexts/frames.context';import {CANVAS_ACTIONS} from '../store/actions/frames.action';/* Remove the rest of the destructured props */const CustomImage = ({isSelected, onSelect}) => {/* Rest of code */const [state, dispatch] = useFrames();/* Update the destructured element to use the state */const {renderDimensions: {width, height},position: {x, y},image,} = state.imageDetails;/* Replace the setImageDetails with the following dispatch code */const onTransformEnd = () => {if (shapeRef.current) {const node = shapeRef.current;dispatch({type: CANVAS_ACTIONS.UPDATE_IMAGE_DIMENSIONS,payload: {scale: node.scale(),},});}};/* Replace the setImageDetails with the following dispatch code */const onDragEnd = () => {if (shapeRef.current) {const node = shapeRef.current;dispatch({type: CANVAS_ACTIONS.UPDATE_IMAGE_POSITIONS,payload: {x: node.x(),y: node.y(),},});}};/* Rest of code */};
import React from 'react';// State Handlersimport {CANVAS_ACTIONS} from '../store/actions/frames.action';import {useFrames} from '../store/contexts/frames.context';const UploadImage = () => {/*** Following is a destructuring way to get only dispatch*/const [, dispatch] = useFrames();const handleInputChange = e => {/*** The following code is to get the image data and* the dimensions of the uploaded image. In order to get this* use the FileReader class.*/if (e.target.files.length > 0) {const file = e.target.files[0];const i = new Image();i.src = URL.createObjectURL(file);i.onload = () => {const reader = new FileReader();reader.readAsDataURL(file);reader.onload = () => {dispatch({type: CANVAS_ACTIONS.UPLOAD_IMAGE,payload: {image: i.src,originalDimensions: {width: i.width,height: i.height,},},});};};}};return (<div><label htmlFor="contained-button-file"><button>Upload Image</button></label><inputtype="file"accept="image/*"style={{display: 'none'}}id="contained-button-file"maxFiles={1}onChange={handleInputChange}/></div>);};export default UploadImage;
const aspectRatio = i.width / i.height;const stageHeight = state.stageDetails.height;const stageWidth = state.stageDetails.width;dispatch({type: CANVAS_ACTIONS.UPDATE_IMAGE_RENDERED_DIMENSIONS,payload: {width: aspectRatio > 1 ? stageWidth : stageHeight * aspectRatio,height: aspectRatio > 1 ? stageWidth / aspectRatio : stageHeight,},});
const downloadURI = (uri, name) => {const link = document.createElement('a');link.download = name;link.href = uri;document.body.appendChild(link);link.click();document.body.removeChild(link);};const handleDownload = () => {if (stageRef.current) {const uri = stageRef.current.toDataURL();downloadURI(uri, 'certificate.png');}};
Here are a few links that can help you to get a deeper understanding of the different patterns and technologies that we used in building this application.
Through this article, you were able to create a React and React Konva project with a sophisticated state management pattern without using any external packages. You learned how to set up a canvas environment and how to manipulate different aspects of a canvas in a React based single page application. From here, you can integrate the various other react-konva components to make your project more interactive.