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, you may have seen 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.
As an example, we’ll use a frame generator that I helped to build and maintain for my university’s Hackathon. At the end of this tutorial, you’ll be able to build a profile frame generator for your online event participants to use. Our final output will look like the image below:
You can also check out the live example. Let’s get started!
The HTML5 Canvas API 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.
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.
To follow along with this article, you should be familiar with React and Create React App. You should also have an understanding of state management tools like Redux or the React Context API. Additionally, prior knowledge about React Konva is beneficial.
In this tutorial, we’ll use the following technologies: React, Create React App, styled-components, the React Context API, React Konva, and Netlify. Let’s get started!
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:
# Globally install yarn npm install -g yarn
Next, we’ll create a basic React app using Create React App.
# Using npm npx create-react-app profile-frames # Using yarn yarn create react-app profile-frames
Let’s set up a clean directory structure; delete all the files that come with Create React App and create the required folders and files as shown below:
public
: Files in this directory will be directly accessible to the userssrc
: Contains the files that we’ll upload and build
assets
: Contains all images and framescomponents
: Contains componentspages
: Contains different screens for our applicationstore
: Contains files handling the app’s global state managementTo run your React app, create index.js
and App.jsx
files in the src
directory, then add the following code:
// App.jsx import React from "react"; const App = () => { return ( <div> <h1>Hello World</h1> </div> ); }; export default App; // index.js import React from "react"; import ReactDOM from "react-dom"; import App from "./App"; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.querySelector("#root") );
Finally, we’ll install styled-components and React Konva for CSS styling and canvas manipulations, respectively:
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 above.
Before we begin coding, it’s good practice to make a detailed list of the features we want to include. Our app will include the following features, and we’ll build our components based on this list:
Stage
component where the user can interact with different Konva elementsupload image
buttonName
and Guild
inputsDownload
buttonThe component architecture below will be suitable for our project:
Canvas
componentReact Konva has a component called Stage
that you can use to render various shapes and items. The immediate child component of Stage
needs to be a Layer
component, which can then house different components like images, texts, shapes, etc. In our demonstration, a single layer would get the job done.
Create a new file named Canvas.jsx
in the components
directory and add the following code:
import React, { useRef } from "react"; // Libraries import { Layer, Stage, Image, Text } from "react-konva"; // Assets import 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 ( <Stage ref={stageRef} width={stageDetails.width} height={stageDetails.height} style={{ position: "relative" }} > <Layer> <Image image={profile} width={imageDetails.renderDimensions.width} height={imageDetails.renderDimensions.height} x={imageDetails.position.x} y={imageDetails.position.y} /> <Text text={textDetails.name.value} width={textDetails.name.dimensions.width} height={textDetails.name.dimensions.height} x={textDetails.name.position.x} y={textDetails.name.position.y} /> <Text text={textDetails.guild.value} width={textDetails.guild.dimensions.width} height={textDetails.guild.dimensions.height} x={textDetails.guild.position.x} y={textDetails.guild.position.y} /> <Image image={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;
There is a lot going on in this code snippet. Let’s review the different props that are being passed to all the elements.
Each canvas
element takes the following props:
width
and height
, specifying the dimensions of the particular componentx
and y
, specifying the position of the componentx
and y
are relative to the Stage
component, starting from the top-left corner of the Stage
component. The Stage
component requires a reference, which is created using the useRef
Hook.
There are two image components, one for the profile picture and one for the frame. These images should be high resolution, so we’ll need to calculate the render dimensions for each image to fit inside the Stage
component. Additionally, there are two text components that both have a text
prop that renders the content, i.e., the text that we provide.
At this point, your next step would be to get the Stage
component ready, but you’ll notice a few issues. For one, the text that appears doesn’t have a background. We’ll need to fix it so it looks distinct from the images. Additionally, none of our elements are transformable, meaning we can’t change the sizes or rotate the elements. Finally, our app is static, but we want to make it dynamic and interactive.
text
componentThe built-in text
component doesn’t include any background. We’ll create our own custom component by using a text
component placed on top of a Rect
, Rectangle, component. React Konva provides a group
component, which provides a single set of props, like dimensions and positions, to just one component instead of multiple.
Create a new file called CustomText.jsx
and add the following code:
import React from "react"; // Libraries import { 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 */} <Rect width={dimensions.width} height={dimensions.height} cornerRadius={[7, 7, 7, 7]} fill="lightblue" /> <Text width={dimensions.width} height={dimensions.height} align='center' verticalAlign='middle' text={name} fontSize={20} /> </Group> ); }; export default CustomText;
Now, we have a custom text
component that is more legible and easier to distinguish from the background image.
Transformer
At this point, we’ve prepared the majority of our components. However, we’re missing a key feature that will bring a whole new level of customization to our application.
Let’s start with images. Before we can create our custom image
component, we need to refactor the Canvas
component to include states, allowing us to shift to a more dynamic application with interactions:
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;
With Stage
set up to include states, let’s build our transformable image component. To reduce the code in a single component and avoid any sort of repetition, create a new file called CustomImage.jsx
.
React Konva provides a Transformer
component that creates a box around the shape, so the user can easily resize or rotate it. Add the following code to the CustomImage.jsx
file:
import React from "react"; // Components import { 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(); /** * 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]); const { renderDimensions: { width, height }, position: { x, y }, image, } = imageDetails; /** * 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 ( <> <Image ref={shapeRef} image={image} width={width} height={height} x={x} y={y} /** onSelect is a function that toggles the isSelected variable. This function is called when image is clicked or tapped. */ onClick={onSelect} onTap={onSelect} /** Transformation Handlers Explained above */ onTransformEnd={onTransformEnd} onDragEnd={onDragEnd} /> {isSelected && ( <Transformer ref={transformerRef} boundBoxFunc={(oldBox, newBox) => { /** this function handles the sizing of the box Essentially what it does is adding a check to avoid reduction of size to 0 if 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 our code with the new component. In the Canvas.jsx
file, replace the image component with the following code:
Note: Since you don’t want the frame to be transformable, replace only the image component used for the user profile.
<CustomImage imageDetails={imageDetails} setImageDetails={setImageDetails} isSelected={selectedElement === imageDetails.id} onSelect={() => setSelectedElement(imageDetails.id)} />
You’ll also want to refactor your CustomText
component to make it transformable.
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, as well as handler functions. At this rate, your code will quickly grow messy and unreadable.
Additionally, with just three components in our code, there is a lot of prop drilling occurring, which isn’t good practice. We’ll need to uplift a few states 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.
In the store/actions
directory, create a new file called 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", });
With our action types and initial state set up, let’s do the same for the reducer. Create a file called frames.reducer.js
under the store/reducers
directory and add the following code:
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; } }
In a standard React application, props are used to send data top-down from parent to child. However, this data flow could be inconvenient for certain types of props that are required by multiple components within an application. The Context API makes it possible to share values like these between components without having to explicitly pass a prop through each level of the component tree.
Create a new file called canvas.context.js
under the store/contexts
directory and add the following code:
import React, { useReducer, useMemo, createContext, useContext } from "react"; // Reducer, Initial State, Types import 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]; }
Before you proceed with reducers, you’ll need to wrap your application with the context provider. Head to App.jsx
and update it with the following code:
import React from "react"; // Components import { 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"; // Components import Canvas from "../components/Canvas"; const Frames = () => { return ( <div> <Canvas /> </div> ); }; export default Frames;
Ideally, for well-structured state management, you’d only need the code above. However, at the time of writing, React Konva doesn’t support the Context API. Therefore, we’ll need to set up our canvas
component to make it work. In your Canvas.jsx
file, update the following code:
/* Previous imports */ import { FramesContext, useFrames } from "../store/contexts/frames.context"; const Canvas = () => { /* remaining code */ return ( <FramesContext.Consumer> {(value) => ( <Stage ref={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> ) }
Now, we’ll update our reducer
function to handle the different updates and call the dispatch function with the appropriate action types. Refactor the frames.reducer.js
file with the following code:
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; } }
In our Image
component, we need to update the image scale and image position when onTransformEnd
and onDragEnd
are triggered, respectively:
// State Handlers import { 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 */ }
We also need to update the image details when an image is uploaded, but we’ll do this later.
upload image
componentTo upload images to our profile frame generator, we’ll create a fairly simple component with an input of type image
and an onChange
handler with the dispatch function of type upload image
.
Create a file called UploadImage.jsx
in the components directory:
import React from "react"; // State Handlers import { 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> <input type='file' accept='image/*' style={{ display: "none" }} id='contained-button-file' maxFiles={1} onChange={handleInputChange} /> </div> ); }; export default UploadImage;
Now, the upload functionality is ready, but we have another small problem to deal with. Presently, we have incorrectly hardcoded the dimensions of the CustomImage
component. What if the uploaded image is of an aspect ratio other than 1:1?
The renderedDimensions
property is in the image’s initial state, and you are getting the original dimensions of the image at the time of upload. Instead, we’ll calculate the aspect ratio of the image, then depending on the size of the stage
, we’ll calculate the rendered dimensions of the image.
Add the following code to the handleImageInput
function right after the first dispatch:
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, }, });
React Konva provides a method to convert the canvas to the image via the reference that we passed to the Stage
component:
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'); } };
You need to get the input components ready and show the value in the text
component of the stage, the CustomText
component. The methodology for achieving this is exactly the same as the steps we followed for the images. Here is a recap:
In this article, we learned how to build a profile picture frame generator using React Konva. We followed a sophisticated state management pattern without needing any external packages. We also learned how to set up a canvas environment and manipulate different aspects of a canvas in a React-based single-page application.
If you want to make your project more interactive, you can integrate other React Konva components into your application. Be sure to check out the official documentation for more details. Happy coding!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
One Reply to "Build a profile frame generator with React Konva"
Can you please share your source code ?