Skip to main content

How to Create an Animated Drawing Effect with SVG and React Hooks

Pulblished:

Updated:

Comments: counting...

In this tutorial we will create an SVG image with an animated draw effect in React, using hooks, that will be "drawn" onto the page when scrolled into view

In this tutorial we will create an SVG image with an animated draw effect in React, using hooks, that will be “drawn” onto the page when scrolled into view, it will look something like the signature below.

SVG Image

You’ll need an SVG image to get started, so let’s get that created. I’m going to use Inkscape for this since it’s free and I don’t have a graphics tablet for my computer, you can download Inkscape from their website .

I went ahead and drew my image by hand first, snapped a photo of it, and then traced it. Once you have Inkscape open, you can drag any image file onto the page to import it.

Select the “Draw Bezier curves and straight lines” tool

Draw Bezier curves tool

This part is a bit tricky, but take your time and you’ll get the hang of it pretty quickly. You’ll want to try to plan the drawing so that it is one continuous line, if you have multiple lines that’s ok, they will just both be drawn at once (it looks better as one continuous line, you could make two separate paths if you had to and use CSS animation timing to make them draw one ofter the other).

Click on the starting point of the drawing (over top of your template image if you are using one, I just used a fun font in this example).

Starting point of drawing

If Inkscape starts rendering shapes over your template that get in the way, skip ahead to the steps for turning off the fill and turning on stroke, that should clear up the screen for easier tracing.

Click the next point in the image, but hold the mouse button down, now as you move the mouse it will adjust the curve, once you are happy with it you can let go of the mouse button and continue on to the next point. Here is a straight line (single click):

Straight line

Here is a curved line (click and drag):

Curved line

You may have to get creative to connect the lines:

Drawing outside of the lines

When you get to the end, hit the enter key on your keyboard to finalize the line, you’ll see a dotted outline around it.

Finished line

Select the “Select and transform objects” tool.

Select tool

Click and drag your new line away from the original drawing, I hope yours looks better than mine does here! (Yours may not be solid black yet, we will fix that in the next steps).

Move line

Choose “Fill and Stroke…” from the top menu bar.

Fill and Stroke

Make sure the fill is set to “No paint”.

No fill paint

Set the “Stroke paint” to “Flat color”, and choose a color here if you like.

Stroke paint

Set the “Stroke style” width value to taste, and set the “Cap” to rounded so it looks nice and clean. (You can also round out the “Join” setting if needed).

Stroke settings

Click on the original drawing and hit the delete key on your keyboard to remove it.

Select original image

Select your line drawing and press ctrl + shift + R on your keyboard to shrink the page down to the size of the image, it should look like this:

Adjusted page size

Save the file, then head over to smooth-code.com and utilize their very awesome SVGR tool to convert the SVG image into a React component (You can do this by hand if you wish, most of the property name conversions are just going from kebab-case-like-this to camelCaseLikeThis, it’s just faster and easier to use a tool).

Drag the SVG file into the left side of the page, then copy the output from the right side into your project. Here’s the output for my example:

import React from 'react';

const SvgComponent = (props) => (
  <svg
    width={127.237}
    height={53.457}
    viewBox='0 0 33.665 14.144'
    {...props}
  >
    <path
      d='M5.27 7.113l-1.7-4.158S3.475.593 4.23.215c.756-.378 3.78.567 4.158 3.118.378 2.552-.472 6.237-2.551 8.316-2.08 2.079-6.142.189-5.67-.567.472-.756 3.024-5.292 2.079-6.331C1.302 3.71.263 12.877.64 13.255c.378.378 5.009 1.323 6.426.284 1.417-1.04 0-1.323 1.323-1.323s3.969-1.89 4.158-5.386c.189-3.497 1.606.283 1.228 1.606-.378 1.323-.756 3.024.473 4.063 1.228 1.04 3.023-1.228 3.118-2.55.094-1.324 3.213-4.348 2.362-3.12-.85 1.23-2.646 3.12-2.362 4.725.283 1.607 2.646 1.134 2.835.19.189-.945 1.134-6.71 1.04-4.914-.095 1.795-.284 5.008.472 5.291.756.284 1.512.756 2.834-1.417 1.323-2.173 1.89-6.804 1.512-4.441-.378 2.362-1.7 5.291-.567 5.858 1.134.567 2.174-.283 2.646-2.362.473-2.079.945-7.181.662-3.874-.284 3.307-.756 5.953.472 6.33 1.229.379 1.701.095 1.701-1.794 0-1.89-.661-4.158.095-4.064.756.095 2.55.756 2.55.756'
      fill='none'
      stroke='#000'
      strokeWidth={0.265}
    />
  </svg>
);

export default SvgComponent;

Animated React Component

You’ll want to have styled-components, and react-intersection-observer installed in your project before you proceed.

npm install --save styled-components react-intersection-observer

Let’s add the necessary imports to the component and wrap the functional component body in curly braces to remove the implicit return.

import React, { useState, createRef, useEffect } from 'react';
import styled from 'styled-components';
import { useInView } from 'react-intersection-observer';

const SvgComponent = (props) => {
  return (
    <svg
      width={127.237}
      height={53.457}
      viewBox='0 0 33.665 14.144'
      {...props}
    >
      <path
        d='M5.27 7.113l-1.7-4.158S3.475.593 4.23.215c.756-.378 3.78.567 4.158 3.118.378 2.552-.472 6.237-2.551 8.316-2.08 2.079-6.142.189-5.67-.567.472-.756 3.024-5.292 2.079-6.331C1.302 3.71.263 12.877.64 13.255c.378.378 5.009 1.323 6.426.284 1.417-1.04 0-1.323 1.323-1.323s3.969-1.89 4.158-5.386c.189-3.497 1.606.283 1.228 1.606-.378 1.323-.756 3.024.473 4.063 1.228 1.04 3.023-1.228 3.118-2.55.094-1.324 3.213-4.348 2.362-3.12-.85 1.23-2.646 3.12-2.362 4.725.283 1.607 2.646 1.134 2.835.19.189-.945 1.134-6.71 1.04-4.914-.095 1.795-.284 5.008.472 5.291.756.284 1.512.756 2.834-1.417 1.323-2.173 1.89-6.804 1.512-4.441-.378 2.362-1.7 5.291-.567 5.858 1.134.567 2.174-.283 2.646-2.362.473-2.079.945-7.181.662-3.874-.284 3.307-.756 5.953.472 6.33 1.229.379 1.701.095 1.701-1.794 0-1.89-.661-4.158.095-4.064.756.095 2.55.756 2.55.756'
        fill='none'
        stroke='#000'
        strokeWidth={0.265}
      />
    </svg>
  );
};

export default SvgComponent;

We need to set up useInView to determine if the component is in the viewport or not, a reference for the actual SVG path to determine it’s length, and a state variable for the length value.

...
const SvgComponent = props => {
  const [inViewRef, inView] = useInView({
    triggerOnce: true
  })
  const pathRef = createRef()
  const [pathLength, setPathLength] = useState()

  return (
...

We also need to calculate the length of the SVG path, since the useEffect hook will run before the SVG is loaded we’ll use the useEffect hook to make sure it updates when the SVG does get loaded.

...
const SvgComponent = props => {
  const [inViewRef, inView] = useInView({
    triggerOnce: true
  })
  const pathRef = createRef()
  const [pathLength, setPathLength] = useState()

  useEffect(
    () => {
      if (pathRef.current) {
        setPathLength(pathRef.current.getTotalLength())
      }
    },
    [pathRef]
  )

  return (
...

Let’s wrap our svg in a Wrapper styled component that we will create down below, we will pass some properties to the Wrapper, svg, and path elements:

  • <Wrapper>
    • ref={inViewRef}: This will make Wrapper the target of intersection observer, so the inView property will change from false to true when the Wrapper is scrolled into view
    • pathLength={pathLength}: We need to know the length of the SVG path in the CSS for the styled component, so we’ll pass that property in here for that purpose, this could also be written as {...{pathLength}} if the destructured declaration looks cleaner to you.
  • <svg>
    • className={inView ? 'animated visible' : 'animated'}: We will add the visible class name to this element when the inView property becomes true using a ternary operator
  • <path>
    • ref={pathRef}: Makes this path the target of the useEffect hook above to determine it’s length
...
  return (
    <Wrapper ref={inViewRef} pathLength={pathLength}>
      <svg
        className={inView ? 'animated visible' : 'animated'}
        width={127.237}
        height={53.457}
        viewBox='0 0 33.665 14.144'
        {...props}
      >
        <path
          ref={pathRef}
          d='M5.27 7.113l-1.7-4.158S3.475.593 4.23.215c.756-.378 3.78.567 4.158 3.118.378 2.552-.472 6.237-2.551 8.316-2.08 2.079-6.142.189-5.67-.567.472-.756 3.024-5.292 2.079-6.331C1.302 3.71.263 12.877.64 13.255c.378.378 5.009 1.323 6.426.284 1.417-1.04 0-1.323 1.323-1.323s3.969-1.89 4.158-5.386c.189-3.497 1.606.283 1.228 1.606-.378 1.323-.756 3.024.473 4.063 1.228 1.04 3.023-1.228 3.118-2.55.094-1.324 3.213-4.348 2.362-3.12-.85 1.23-2.646 3.12-2.362 4.725.283 1.607 2.646 1.134 2.835.19.189-.945 1.134-6.71 1.04-4.914-.095 1.795-.284 5.008.472 5.291.756.284 1.512.756 2.834-1.417 1.323-2.173 1.89-6.804 1.512-4.441-.378 2.362-1.7 5.291-.567 5.858 1.134.567 2.174-.283 2.646-2.362.473-2.079.945-7.181.662-3.874-.284 3.307-.756 5.953.472 6.33 1.229.379 1.701.095 1.701-1.794 0-1.89-.661-4.158.095-4.064.756.095 2.55.756 2.55.756'
          fill='none'
          stroke='#000'
          strokeWidth={0.265}
        />
      </svg>
    </Wrapper>
  )
}

export default SvgComponent

To make the SVG image responsive, we need to create or modify the viewBox property in the svg element, this may or may not be there already depending on the original SVG image. The viewBox attribute takes four values, min-x min-y width height, you’ll want to leave the first two (min-x and min-y) as 0, and take the width and height values from the actual width and height attributes, and then remove those attributes. This is so you can set the width and height of the image in CSS and it will scale the image rather than crop it.

So we will go from this:

<svg
    className={inView ? 'animated visible' : 'animated'}
    width={127.237}
    height={53.457}
    viewBox='0 0 33.665 14.144'
    {...props}
>

To this:

<svg
    className={inView ? 'animated visible' : 'animated'}
    viewBox='0 0 127.237 53.457'
    {...props}
>

Now we just need the Wrapper styled component, we’ll use 100% for the width and height here, and set max-width to 300px to make it responsive, you can play with the max-width as needed.

The stroke-dasharray property will transform the stroke into a dashed line, by setting this to the length of the SVG path we will get one dash that is the full length of the path and one blank section that is the same length.

Setting the stroke-dashoffset to the same value will shift the blank part of the dashed stroke into view, and setting it to 0 in the keyframes will move the blank part back out of view thereby exposing the single dash (i.e. our SVG image), pretty simple, right? Feel free to adjust the timing to taste here as well, it is set to six seconds here in the line animation: draw 6s linear forwards;.

...
const Wrapper = styled.div`
  .animated {
    max-width: 300px;
    width: 100%;
    height: 100%;
    stroke-dasharray: ${props => props.pathLength};
    stroke-dashoffset: ${props => props.pathLength};
  }
  .animated.visible {
    animation: draw 6s linear forwards;
  }
  @keyframes draw {
    from {
      stroke-dashoffset: ${props => props.pathLength};
    }
    to {
      stroke-dashoffset: 0;
    }
  }
`

export default SvgComponent

Here’s the whole thing all together for reference.

import React, { useState, createRef, useEffect } from 'react';
import styled from 'styled-components';
import { useInView } from 'react-intersection-observer';

const SvgComponent = (props) => {
  const [inViewRef, inView] = useInView({
    triggerOnce: true,
  });
  const pathRef = createRef();
  const [pathLength, setPathLength] = useState();

  useEffect(() => {
    if (pathRef.current) {
      setPathLength(pathRef.current.getTotalLength());
    }
  }, [pathRef]);

  return (
    <Wrapper
      ref={inViewRef}
      pathLength={pathLength}
    >
      <svg
        className={inView ? 'animated visible' : 'animated'}
        viewBox='0 0 127.237 53.457'
        {...props}
      >
        <path
          ref={pathRef}
          d='M5.27 7.113l-1.7-4.158S3.475.593 4.23.215c.756-.378 3.78.567 4.158 3.118.378 2.552-.472 6.237-2.551 8.316-2.08 2.079-6.142.189-5.67-.567.472-.756 3.024-5.292 2.079-6.331C1.302 3.71.263 12.877.64 13.255c.378.378 5.009 1.323 6.426.284 1.417-1.04 0-1.323 1.323-1.323s3.969-1.89 4.158-5.386c.189-3.497 1.606.283 1.228 1.606-.378 1.323-.756 3.024.473 4.063 1.228 1.04 3.023-1.228 3.118-2.55.094-1.324 3.213-4.348 2.362-3.12-.85 1.23-2.646 3.12-2.362 4.725.283 1.607 2.646 1.134 2.835.19.189-.945 1.134-6.71 1.04-4.914-.095 1.795-.284 5.008.472 5.291.756.284 1.512.756 2.834-1.417 1.323-2.173 1.89-6.804 1.512-4.441-.378 2.362-1.7 5.291-.567 5.858 1.134.567 2.174-.283 2.646-2.362.473-2.079.945-7.181.662-3.874-.284 3.307-.756 5.953.472 6.33 1.229.379 1.701.095 1.701-1.794 0-1.89-.661-4.158.095-4.064.756.095 2.55.756 2.55.756'
          fill='none'
          stroke='#000'
          strokeWidth={0.265}
        />
      </svg>
    </Wrapper>
  );
};

const Wrapper = styled.div`
  .animated {
    max-width: 300px;
    width: 100%;
    height: 100%;
    stroke-dasharray: ${(props) => props.pathLength};
    stroke-dashoffset: ${(props) => props.pathLength};
  }
  .animated.visible {
    animation: draw 6s linear forwards;
  }
  @keyframes draw {
    from {
      stroke-dashoffset: ${(props) => props.pathLength};
    }
    to {
      stroke-dashoffset: 0;
    }
  }
`;

export default SvgComponent;