Building a simple profile picture generator in React

Few graphic elements found on social media these days convey first impressions as effectively as profile pictures do. These tiny, predominantly circular images often serve as a means of getting a rough idea of the outer appearance of a certain person. So why not put critical information in places that most of us take a look at first?

The small tool discussed in this post allows users to quickly add text label overlays to their profile pictures. Written in TypeScript using the React library, it runs locally in your browser and without any server-side processing of the user’s data. All image manipulations and compositions are implemented using the HTML Canvas API.

In the following, we will mainly focus on the intricacies of integrating the canvas element with functional React components.

You can try out the application here.

Wiring up the canvas

To render the image according to the current parameters, an HTML canvas element is used, whose contents may then be captured into an image file for the user to save. In order to get to the desired visual result, most users will want to tweak these parameters until they see something they like. This, however, leads to frequent re-drawing of the canvas and thus asks for special treatment in the code to reduce possible lag to a minimum:

export const BadgeForge = () => {

	[ ... ]

  const image = useMemo(() => new Image(), []);
  image.src = selectedFile ? URL.createObjectURL(selectedFile) : placeholder;

  useEffect(() => {
    const context = canvasRef.current?.getContext("2d");
    function drawAll() {
      if (context) {
        context.clearRect(0, 0, canvasHeight, canvasWidth);
        drawImage(context, image, 0, 0);
        drawDonut(context, canvasHeight, donutStroke, angle, donutColor);
        drawLabel([ ... ]);
      }
    }

    image.addEventListener("load", drawAll);

    return () => image.removeEventListener("load", drawAll);
  }, [ ... ]);
}

As you can see by looking at the code above, the drawing of all canvas elements is blocked until the selected image file has finished loading. This prevents drawImage(…) from being called before the referring image file has become available, ensuring that the elements are drawn in the right order.

Also note that, in order to maintain a reasonable level of responsiveness, we need to detach the event listener on every unmount and re-render of our React component. We can achieve this by having our useEffect() hook return a cleanup callback that removes the previously attached event listener.

As mentioned earlier, the Canvas API provides a method called toDataURL(…) to get the current state as a Data URL which returns the data of the desired file format in a Base64 encoded string. In our case, we choose the PNG format as it preserves the transparency surrounding the output of our circular canvas. Finally, enabling the user to actually download this file involves one more step: We employ a makeshift anchor element with its href property set to the image file we just created. Since this anchor is not part of the HTML DOM, the download action can be triggered by emulating an anchor click whenever the “Save image” button is clicked by the user:

export const RenderButton = () => {
  const { canvasRef } = useContext(BadgeForgeContext);

  const handleClick = () => {
    if (canvasRef.current) {
      const anchor = document.createElement("a");
      anchor.href = canvasRef.current.toDataURL("image/png");
      anchor.download = `profile-${new Date()
        .toISOString()
        .substring(0, 10)}.png`;
      anchor.click();
    }
  };

  return (
    <Button onClick={handleClick} title="Download as PNG file">
      <span>Save image</span>
      <ButtonIcon>
        <Download />
      </ButtonIcon>
    </Button>
  );
};

We use a React Context Provider to make references to the canvas element and parameters such as the label text accessible to other components throughout the application. The reference itself is set up via useRef(…) in the context provider component alongside all other canvas parameters:

export const BadgeForgeContextProvider = ({children}: [ ... ]) => {
  const { colors } = useTheme();
  const canvasWidth = 800;
  const canvasHeight = canvasWidth;
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [label, setLabel] = useState<string>("");
  const [donutColor, setDonutColor] = useState<string>(colors.purple400);
  const [donutStroke, setDonutStroke] = useState<number>(0.175 * canvasWidth);
  const [labelColor, setLabelColor] = useState<string>(colors.gray50);
  const [angle, setAngle] = useState<number>(-1);

  return (
    <BadgeForgeContext.Provider
      value={{
        canvasRef,
        canvasHeight,
        canvasWidth,
        donutColor,
        donutStroke,
        setDonutColor,
	      [ ... ]
      }}
    >
      {children}
    </BadgeForgeContext.Provider>
  );
};

Drawing the label text

Unlike SVG elements, the HTML canvas API doesn’t offer an out-of-the-box solution for drawing text along predefined paths such as circles or segments thereof. Left to our own devices, we define a bespoke function drawLabel(…) that operates in the fashion of turtle graphics:

export const drawLabel = (
  context: CanvasRenderingContext2D,
  label: string,
  size: number,
  radius: number,
  angle: number,
  color: string
) => {
  label = label.length === 0 ? "............" : label;

  let len = label.length,
    glyph,
    letterAngle,
    totalWidth = 0,
    totalAngle = 0,
    letterSpacing = 0.65;

  context.save();
  context.textAlign = "center";
  context.font = [ ... ];
  context.fillStyle = color;
  context.translate(size / 2, size / 2);
  context.rotate(angle + Math.PI / 2);

  totalWidth = label
    .split("")
    .map((char) => context.measureText(char).width)
    .reduce((a, b) => a + b, 0);
  totalWidth = 2 * letterSpacing * totalWidth;
  totalAngle = totalWidth / radius;
  context.rotate(-totalAngle / 2);

  for (var n = 0; n < len; n++) {
    glyph = label[n];
    let letterWidth = context.measureText(glyph).width;
    letterAngle = letterSpacing * (letterWidth / radius);

    context.rotate(letterAngle);
    context.save();

    context.translate(0, -radius);
    context.fillText(glyph, 0, 0);
    context.restore();

    context.rotate(letterAngle);
  }
  context.restore();
};

The overall idea is to draw a single line of the specified label text by printing one character at a time to the canvas. In order to keep the text centered at all times (and lengths), we first need to determine the total width of the label, including potential letter spacing. Using this information, we can offset the beginning of our label by half of its length to get to the desired center alignment. The corresponding fillText(…) calls for each individual character are then interspersed with rotations and translations of the canvas origin along the text direction.

As we are successively drawing on top of the graphics already on the canvas, it is important to reset the canvas context to its state from before the translation of the origin, using context.save() and context.restore().

Drawing the Donut

The colored gradient border (”Donut”) offers a visually pleasing way to separate the label text from the uploaded background image. The user can opt against this border altogether by setting the width to zero. Additionally, both the gradient center and its base color can be customized.

The corresponding function drawDonut(…) thus takes various arguments:

export const drawDonut = (
  context: CanvasRenderingContext2D,
  size: number,
  donutStroke: number,
  angle: number,
  color: string
) => {
  var gradient = context.createLinearGradient(0, 0, size, 0);
  gradient.addColorStop(0, `${color}FF`);
  gradient.addColorStop(0.6, `${color}09`);
  gradient.addColorStop(0.75, `${color}00`);

  context.save();

  context.translate(size / 2, size / 2);
  context.rotate(angle);
  context.translate(-size / 2, -size / 2);

  context.lineWidth = donutStroke * 2;
  context.strokeStyle = gradient;
  context.beginPath();
  context.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
  context.stroke();

  context.restore();
};

Given that the outline stroke of any canvas element is aligned to the center of the referring path, we double the user-specified stroke width and draw the path along the circular border of the canvas. This way, half of the stroke’s effective width will be on the inside of our canvas, while the other half will be clipped away by a clipping path defined inside the drawImage(…) function:

export const drawImage = ([ ... ]) => {

	[ ... ]

  // crop to circle
  context.beginPath();
  context.arc(
    context.canvas.width / 2,
    context.canvas.height / 2,
    context.canvas.width / 2,
    0,
    Math.PI * 2
  );
  context.clip();

  [ ... ]

};

Since drawImage(…) is always the first function to be called in our useEffect() hook, the clipping mask stays in place for all subsequent canvas manipulations. Hence, note the absence of context.save() and context.restore() in this function.

In retrospect, building a canvas-centered app subject to frequent re-drawing greatly benefits from the React Context API, as it allows developers to selectively expose parameters to multiple components. Consequently, individual re-renders are mostly limited to components that either consume or modify values influencing the canvas content. In this rather small application, essentially all components share the same set of variables, so the Context Provider encases all of them. When it comes to projects of greater scope, however, the positive impact that using Context has on the overall performance grows more tangible.

Some parts of the code snippets have been omitted for brevity by means of [ ... ]. If you wish to take a closer look at these details as well as other components and UI elements touched upon in this post, you can find the full source code here.

Interested in working with and for us?

Career