DEV Community

Cover image for Create generative social images using SVG!
George Francis
George Francis

Posted on • Updated on

Create generative social images using SVG!

So... it's 2021. The web is an endless sea of beautiful, weird, terrifying stuff. How do you make sure your site cuts through the noise?

Well, alongside some great content, I think an awesome generative social image (just like the one used for this tutorial!) could be a good step along the way ✨

Let’s make some!


The end result

First things first, let’s skip to the end. Here is the final result of this tutorial:

It's a scalable, editable, self-generating social image! If you click on the buttons in the CodePen above or change the text content, you should see the image magically re-design itself 🔮


But... what's it actually for?

A "social image" as I call them, or "meta image", is the little preview that shows up in Slack / Twitter / Facebook whenever you paste a link.

Here are some social image examples found in the wild...

An awesome textured design from Stephanie Eckles:

A custom, textured social image

The much-loved social image from DEV + Ben Halpern:

DEV community social image

Some very cool 3D vibes from Josh Comeau:

A custom 3d style social image on Twitter

Although all of my examples are from Twitter, it's important to remember (and a huge benefit to creating your social images with SVG) that different sites can require different dimensions.

Luckily, through the power of SVG + viewBox, the images we are going to create in this tutorial can be simply resized to any dimensions/aspect ratio. Nice!


Blast off 🚀

OK, I think that's enough preamble. We are ready to start building. Overalls on, folks!

HTML Markup

First things first let's add some HTML for our page:



<div class="wrapper">
  <div class="social-image-wrapper">
  <!-- Save a space for our SVG! -->
  </div>
  <div class="controls">
    <div class="controls__randomize">
      <p class="controls__label">Randomize:</p>
      <button class="controls__btn controls__btn--alignment">Alignment</button>
      <button class="controls__btn controls__btn--colors">Colors</button>
      <button class="controls__btn controls__btn--shapes">Shapes</button>
    </div>
    <button class="controls__btn controls__btn--save">Save</button>
  </div>
</div>


Enter fullscreen mode Exit fullscreen mode

In this code snippet, we are adding the HTML markup we need for our UI and popping everything inside a nice little wrapper div.


SVG Markup

Once we have added the HTML for the user interface it's for the main markup event. I mentioned earlier that our social images are going to be created using the <svg> element, so let's add one to our social-image-wrapper:



<div class="social-image-wrapper">
  <svg
    viewBox="0 0 1200 630"
    xmlns="http://www.w3.org/2000/svg"
    class="social-image"
  >
    <foreignObject x="0" y="0" width="1200" height="630">
      <div class="social-image__html">
        <div class="social-image__text">
          <h1
            xmlns="http://www.w3.org/1999/xhtml"
            class="social-image__title"
            contenteditable
          >
            All of this text is editable... click on it and start typing!
          </h1>
          <h2
            xmlns="http://www.w3.org/1999/xhtml"
            class="social-image__meta"
            contenteditable
          >
            As you type, the background will adapt itself to the text, making
            sure the shapes never overlap.
          </h2>
        </div>
      </div>
    </foreignObject>
  </svg>
</div>


Enter fullscreen mode Exit fullscreen mode

There is quite a bit to unpack here but don't worry! We can work through it together 🤝

viewBox

First off, we are creating our <svg> element and defining a viewBox:



<svg
  viewBox="0 0 1200 630"
  xmlns="http://www.w3.org/2000/svg"
  class="social-image"
>
  ...
</svg>


Enter fullscreen mode Exit fullscreen mode

The viewBox attribute defines the coordinate space in which all of the contents of our <svg> will be drawn. In our case, this is 1200x630px.

Through the power of viewBox, we can position/scale everything relative to a fixed coordinate space, whilst the <svg> itself will be able to scale to any size. Powerful stuff ⚡

foreignObject

Next, we add a foreignObject tag filled with some HTML to our <svg> element:



<foreignObject x="0" y="0" width="1200" height="630">
  ...
</foreignObject>


Enter fullscreen mode Exit fullscreen mode

This is where things start getting interesting! foreignObject can be used to add content from another XML namespace (in our case, HTML) to an <svg> element.

Once added, this HTML will automatically scale to the viewBox just like regular SVG content. This is incredibly powerful, as it allows us to style the contents of our social image using CSS whilst retaining the fluidity and rendering power of SVG.

More on that shortly.

Note: any HTML elements added within foreignObject must posses an xmlns="http://www.w3.org/1999/xhtml" attribute.

The contenteditable attribute

The last thing to check out here is the contenteditable attribute added to our h1 and h2 tags:



<h1
  xmlns="http://www.w3.org/1999/xhtml"
  class="social-image__title"
  contenteditable
>
  All of this text is editable... click on it and start typing!
</h1>


Enter fullscreen mode Exit fullscreen mode

contenteditable simply allows the user to edit the text within HTML tags. This is perfect for us, as it means users will easily be able to add their own content and instantly preview the result.


Style time 💅

OK, so, we now have all the markup we need to create beautiful social images. Things are probably looking a little bit sad though. We should really fix that.

Page styles

First up, let's add some styles for our UI:



* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

:root {
  --black: hsl(0, 0%, 10%);
}

body {
  width: 100vw;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 2rem;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
    Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  color: var(--black);
  line-height: 1;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.wrapper {
  width: 100%;
  max-width: 60rem;
  min-width: 20rem;
  margin: 0 auto;
  overflow: hidden;
}

.controls {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  margin: 2rem 0;
}

.controls__label {
  margin-right: 1rem;
  font-weight: 500;
  font-size: 1rem;
}

.controls__randomize {
  display: flex;
  justify-content: flex-start;
  align-items: center;
  flex-wrap: wrap;
}

.controls__btn {
  width: 8rem;
  height: 2.25rem;
  margin-right: 1rem;
  background: #fff;
  border-radius: 0;
  border: none;
  border: 2px solid var(--black);
  font-family: inherit;
  color: var(--black);
  font-size: 1rem;
  font-weight: 500;
  cursor: pointer;
}

.controls__btn:hover {
  background: var(--black);
  color: #fff;
}

.controls__btn--save {
  position: relative;
  margin-left: auto;
  margin-right: 0;
  background: var(--black);
  color: #fff;
}

.controls__btn--save:hover {
  background: #fff;
  color: var(--black);
}

.controls__saving-disabled {
  font-size: 0.875rem;
  margin-top: 2rem;
  font-weight: 500;
  display: none;
  font-style: italic;
}

@media only screen and (max-width: 800px) {
  body {
    padding: 0.75rem;
  }

  .controls__btn {
    width: 6rem;
    height: 2rem;
    font-size: 0.875rem;
    margin-top: 0.75rem;
  }

  .controls__label {
    font-size: 0.875rem;
    margin-right: 0.5rem;
    width: 100%;
  }
  .controls__btn--save {
    width: 100%;
    margin-top: 1.25rem;
  }
}

@media only screen and (max-width: 480px) {
  .controls__btn {
    margin-right: 0.5rem;
  }
}


Enter fullscreen mode Exit fullscreen mode

I won't go too deep on this CSS, as it's not the main feature here. If you do have any questions about these styles, though, do feel free to drop me a message.

Social image styles

Next, let's add an internal <style> tag to our <svg> element. This will contain all the styles for the social image itself:



<svg
  viewBox="0 0 1200 630"
  xmlns="http://www.w3.org/2000/svg"
  class="social-image"
>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    .social-image {
      --align-text-x: flex-start;
      --align-text-y: flex-end;

      width: 100%;
      background: #f5f7fa;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
        "Segoe UI Symbol";
      line-height: 1;
    }

    .social-image__html {
      display: flex;
      height: 100%;
      justify-content: var(--align-text-x);
      align-items: var(--align-text-y);
      padding: 72px;
    }

    .social-image__text {
      max-width: 700px;
    }

    .social-image__title {
      font-size: 56px;
      line-height: 68px;
      font-weight: 800;
      margin-bottom: 24px;
      letter-spacing: -0.0125em;
      outline: none;
    }

    .social-image__meta {
      font-weight: 500;
      font-size: 24px;
      line-height: 36px;
      outline: none;
      letter-spacing: -0.0125em;
    }
  </style>
  ...
</svg>


Enter fullscreen mode Exit fullscreen mode

We are adding this CSS to an internal <style> tag as I had some issues with html2canvas not rendering as expected with the styles living outside of the <svg>. It's also nice to keep things contained.

Again, I won't go into too much detail with the CSS here, but the key effects of this stylesheet are:

  • Set up some CSS Custom Properties to handle the positioning of our text within the social image, in combination with flexbox. We can modify these custom properties later using JavaScript.

  • Add some typographic style to the text content. We are using system fonts here. It is possible to use custom fonts, but doing so adds a little complexity as the fonts need to be embedded within the <svg>. Maybe next time!

Our progress so far

Now that both of these stylesheets have been added in their respective positions, you should hopefully be seeing something like this in your browser:

A simple social image design being resized

Pretty cool eh! As you resize your browser, check out how our HTML magically scales along with our <svg> element ✨

At this point, we are all set up and ready to make things beautiful. Let's head over to JS-town and make that happen 🎨


Next stop, JavaScript central 🚂

Package installation

Let's get the boring stuff out of the way first and install the packages we need for this project. The packages we will be using are:

  • svg.js - Used to simplify SVG scripting (creating and updating SVG elements such as <circle>)
  • html2canvas - Used to take a screenshot of our <svg> social image so that it can be downloaded
  • file-saver - Used to handle the saving of our social image once it has been captured by html2canvas
  • resize-observer-polyfill - Adds a polyfill for ResizeObserver to browsers that do not support it

If you are following along on CodePen, you can simply add these imports to your JS file:



import { SVG } from "https://cdn.skypack.dev/@svgdotjs/svg.js";
import html2canvas from "https://cdn.skypack.dev/html2canvas@1.0.0-rc.7";
import ResizeObserver from "https://cdn.skypack.dev/resize-observer-polyfill@1.5.1";
import FileSaver from "https://cdn.skypack.dev/file-saver@2.0.5";


Enter fullscreen mode Exit fullscreen mode

If you are working in your own environment, you can install the packages you need with:



npm i svgjs html2canvas resize-observer-polyfill file-saver 


Enter fullscreen mode Exit fullscreen mode

The packages can then be imported like so:



import { SVG } from "svg.js";
import html2canvas from "html2canvas";
import ResizeObserver from "resize-observer-polyfill";
import FileSaver from "file-saver";


Enter fullscreen mode Exit fullscreen mode

Note: If you are working in your own environment, you will need a bundler such as Webpack or Parcel to handle these imports.

DOM Element references

Now that we have all the packages we need for this project, we should add some variables that reference our various DOM elements (buttons, the social image svg, etc)

To do so we can add:



const socialImageSVG = document.querySelector(".social-image");
const socialImageTitle = document.querySelector(".social-image__title");
const socialImageMeta = document.querySelector(".social-image__meta");

const saveBtn = document.querySelector(".controls__btn--save");
const alignmentBtn = document.querySelector(".controls__btn--alignment");
const colorBtn = document.querySelector(".controls__btn--colors");
const shapesBtn = document.querySelector(".controls__btn--shapes");


Enter fullscreen mode Exit fullscreen mode

Colors

Next up on the list is defining some color variables. These will store a bunch of HSL colors that we will define a little later and eventually use to color our social image:



let baseColor;
let baseColorWhite;
let baseColorBlack;

let complimentaryColor1;
let complimentaryColor2;

let shapeColors;


Enter fullscreen mode Exit fullscreen mode

Nice. All our colors are empty right now, but that's fine.

Alignment options

In addition to random colors, our social image will also allow random alignment of its text. To facilitate this a little further down the track, let's store the flex properties we want to use to control alignment in an array:



const alignmentOpts = ["flex-start", "flex-end", "center"];


Enter fullscreen mode Exit fullscreen mode

Lovely. We will use these values shortly.

Set up an svg.js instance

We are going to use svg.js here to allow for quick, easy SVG scripting. Without svg.js, creating and updating SVG elements can get very wordy.

We can create a new svg.js instance like so:



const shapes = SVG(socialImageSVG).group();


Enter fullscreen mode Exit fullscreen mode

What this line is saying is - Create me a new SVG <group> element, inside our root <svg> that I can easily draw into with methods such as shapes.rect(...).

Adding the random() utility function

Before we go any further, lets quickly add a small utility function random, which generates a random number within a range:



function random(min, max) {
  return Math.random() * (max - min) + min;
}


Enter fullscreen mode Exit fullscreen mode

This is a super handy utility. Definitely save it for later if you fancy trying your hand at some more generative stuff! I use it all the time.

Choose some random colors

Often in my tutorials, I hold the colors until right at the end, but I think for this one we should define them early. They are such an integral part of the end result and having them set will make following the code in the upcoming steps a bit easier.

To generate some random colors, we can add the following setColors function:



function setColors() {
  const baseHue = random(0, 360);
  const saturation = random(60, 90);

  baseColor = `hsl(${baseHue}, ${saturation}%, 60%)`;
  baseColorWhite = `hsl(${baseHue}, ${saturation}%, 97%)`;
  baseColorBlack = `hsl(${baseHue}, 95%, 3%)`;

  complimentaryColor1 = `hsl(${baseHue + 90}, ${saturation}%, 60%)`;
  complimentaryColor2 = `hsl(${baseHue + 180}, ${saturation}%, 60%)`;

  shapeColors = [complimentaryColor1, complimentaryColor2, baseColor];

  socialImageSVG.style.background = baseColorWhite;
  socialImageSVG.style.color = baseColorBlack;
}


Enter fullscreen mode Exit fullscreen mode

Here's what this function is doing:

  1. Pick a random hue, somewhere between 0 and 360
  2. Pick a random saturation, somewhere between 60 and 90
  3. Define a base color, a very dark color, and a very light color all based on the same hue. This is a great way to create simple color palettes and keep things consistent
  4. Choose two complementary colors, each with a hue 90 degrees away from the previous, with the same saturation and lightness. This is another great, simple way of finding colors that work together
  5. Store the complimentary and base colors in our shapeColors array. We will use these later to fill in our shapes
  6. Set the background of our social image to the very light color, and it's text color to the very dark color

Now if we call setColors(), we should see the background and text colors of our social image change. It will be very subtle. Hopefully, something like this:

A colorful social image

Looking good. Onwards!

Creating random shape positions

Next on our list is to generate some random, non-overlapping rectangles to position our shapes within. We want these rectangles to not only avoid overlapping each other but also avoid overlapping our text.

A small problem

To avoid overlapping our text when creating random rectangles, we need to know each text element's dimensions relative to our <svg>'s viewBox.

Often for this purpose we would use getBBox however getBBox is only available for SVG elements, and our text is HTML.

This isn't so bad, we can create our own relativeBounds function that will solve this for us in no time!

Here it is:



function relativeBounds(svg, HTMLElement) {
  const { x, y, width, height } = HTMLElement.getBoundingClientRect();

  const startPoint = svg.createSVGPoint();
  startPoint.x = x;
  startPoint.y = y;

  const endPoint = svg.createSVGPoint();
  endPoint.x = x + width;
  endPoint.y = y + height;

  const startPointTransformed = startPoint.matrixTransform(
    svg.getScreenCTM().inverse()
  );
  const endPointTransformed = endPoint.matrixTransform(
    svg.getScreenCTM().inverse()
  );

  return {
    x: startPointTransformed.x,
    y: startPointTransformed.y,
    width: endPointTransformed.x - startPointTransformed.x,
    height: endPointTransformed.y - startPointTransformed.y
  };
}


Enter fullscreen mode Exit fullscreen mode

Cool! I won't go too deep on this function as I appreciate it is rather dry, but it essentially gives us getBBox functionality for HTML elements within an SVG.

Now that we have our relativeBounds function, we can generate our shape positions.

Lets add a generateRandomRects and a detectRectCollision function:



function generateRandomRects(existing) {
  const rects = [...existing];
  const tries = 250;
  const maxShapes = 6;

  for (let i = 0; i < tries; i++) {
    if (rects.length === maxShapes + existing.length) break;

    const size = random(100, 600);

    const rect = {
      x: random(-size, 1200),
      y: random(-size, 630),
      width: size,
      height: size
    };

    if (!rects.some((r) => detectRectCollision(r, rect))) {
      rects.push(rect);
    }
  }

  return rects;
}

function detectRectCollision(rect1, rect2, padding = 32) {
  return (
    rect1.x < rect2.x + rect2.width + padding &&
    rect1.x + rect1.width + padding > rect2.x &&
    rect1.y < rect2.y + rect2.height + padding &&
    rect1.y + rect1.height + padding > rect2.y
  );
}


Enter fullscreen mode Exit fullscreen mode

To break this down:

  1. Store some existing rectangles in an array (in our case, the surrounding rectangles, or bounds, of our text elements)
  2. For a certain amount of tries: create a randomly sized rectangle. If this new rectangle does not overlap with any of the other rectangles, store it.
  3. Once all of the tries are used up, or the maximum number of shapes reached, return the random rectangles that we managed to generate

You may notice a funny looking padding option in our rectangle collision code. This defines the minimum distance between rectangles. I found it helped make things look a little neater.

A note on imperfection

This is far from a perfect function. It is rather slow as a result of using brute force to place our rectangles, and there is no guarantee maxShapes will be reached with our number of tries.

Does that mean it's bad, though? No way.

We are worried more about visual results than algorithmic efficiency right now, and these values seem to produce pretty aesthetic looking results. The real challenge of generative design lies the tweaking of values like this.

You should experiment with changing these parameters. Try changing the maximum number of shapes, maybe tweak the size of our the or increase the maximum number of tries. Check out the results. Repeat. There are no right answers here!

Drawing our shapes

Alright, so we have some code ready to generate the non-overlapping rectangles. Let's bring them to life!

First, let's add a new generate function:



function generate() {
  shapes.clear();

  const htmlRects = [
    relativeBounds(socialImageSVG, socialImageTitle),
    relativeBounds(socialImageSVG, socialImageMeta)
  ];

  const rects = generateRandomRects(htmlRects);

  for (const rect of rects.slice(2, rects.length)) {
    drawRandomShape(rect);
  }
}


Enter fullscreen mode Exit fullscreen mode

This is actually quite a small block of code. generateRandomRects is doing most of the heavy lifting here. We are saying:

  1. Clear out any shapes that already exist (this will be useful later when dynamically re-generating the image)
  2. Store the bounds of our two text elements, relative to the viewBox, in an array
  3. Generate a bunch of random, non-overlapping rectangles
  4. For every random rectangle (apart from the first two text rectangles) draw a random shape within it.

Now, we don't actually have a drawRandomShape function right now. Let's add one. As a simple start, try this:



function drawRandomShape(rect) {
  const { x, y, width, height } = rect;
  shapes.rect(width, height).x(x).y(y);
}


Enter fullscreen mode Exit fullscreen mode

Once you have added drawRandomShape, you can safely call generate without your browser getting mad at you:



generate();


Enter fullscreen mode Exit fullscreen mode

If you check out the browser now, you should see something like this:

Social image with random rectangles

Pretty nice! These are the random rectangles we generated earlier, presented in a very simple way.

We can expand, though. Let's updatedrawRandomShape and add a small randomColor utility function:



function randomColor() {
  // ~~ === shorthand for Math.floor()
  return shapeColors[~~random(0, shapeColors.length)];
}

function drawRandomShape({ x, y, width, height }) {
  const shapeChoices = ["rect", "ellipse", "triangle"];
  let shape;

  switch (shapeChoices[~~random(0, shapeChoices.length)]) {
    case "ellipse":
      shape = shapes.ellipse(width, height).x(x).y(y);
      break;
    case "triangle":
      shape = shapes
        .polygon(`0 ${height}, ${width / 2} 0, ${width} ${height}`)
        .x(x)
        .y(y);
      break;
    default:
      shape = shapes.rect(width, height).x(x).y(y);
  }

  const color = randomColor();

  if (random(0, 1) > 0.25) {
    shape.fill(color);
  } else {
    shape
      .stroke({
        color,
        width: 16
      })
      .fill("transparent");
  }

  shape.node.classList.add("shape");
  shape.rotate(random(0, 90)).scale(0.825);
  shape.opacity(random(0.5, 1));
}


Enter fullscreen mode Exit fullscreen mode

Here's a breakdown of what's happening here:

  1. Pick a random shape type
  2. Use svg.js to render a different SVG element based on our shape choice
  3. Pick a random color from the choices we defined earlier
  4. 25% of the time, apply this color to the shape outline. The other 75% fill the shape with this color
  5. Add a class of shape to the element so that we can quickly reference it later
  6. Rotate the shape by some random value and reduce its opacity by a random amount

Phew! Things are getting pretty intense. Let's have a break and marvel at our wonderful generative creation!

Social image with shapes and colors added

Woah! 🤩 Looking good folks. We are pretty much there. As you refresh your browser you should see something different each time.

Interactivity

The last step in this tutorial is to make things interactive. This is mostly going to involve attaching event listeners to stuff and running functionality that we have already defined.

To keep things brief, I have commented this code inline. If you do require any more detail or have any questions on this stuff just let me know!

Hooking up the buttons



// regenerate our shapes and shape positions
shapesBtn.addEventListener("click", () => {
  generate();
});

// set new random color values and update the existing shapes with these colors
colorBtn.addEventListener("click", () => {
  setColors();

  // find all the shapes in our svg and update their fill / stroke
  socialImageSVG.querySelectorAll(".shape").forEach((node) => {
    if (node.getAttribute("stroke")) {
      node.setAttribute("stroke", randomColor());
    } else {
      node.setAttribute("fill", randomColor());
    }
  });
});

// choose random new alignment options and update the CSS custom properties, regenerate the shapes
alignmentBtn.addEventListener("click", () => {
  socialImageSVG.style.setProperty("--align-text-x", alignmentOpts[~~random(0, alignmentOpts.length)]);
  socialImageSVG.style.setProperty("--align-text-y", alignmentOpts[~~random(0, alignmentOpts.length)]);
  generate();
});

// save our social image as a .png file
saveBtn.addEventListener("click", () => {
  const bounds = socialImageSVG.getBoundingClientRect();

  // on save, update the dimensions of our social image so that it exports as expected
  socialImageSVG.style.width = "1200px";
  socialImageSVG.style.height = "630px";
  socialImageSVG.setAttribute("width", 1200);
  socialImageSVG.setAttribute("height", 630);
  // this fixes an odd visual "cut off" bug when exporting
  window.scrollTo(0, 0);

  html2canvas(document.querySelector(".social-image-wrapper"), {
    width: 1200,
    height: 630,
    scale: 2 // export our image at 2x resolution so it is nice and crisp on retina devices
  }).then((canvas) => {
    canvas.toBlob(function (blob) {
      // restore the social image styles
      socialImageSVG.style.width = "100%";
      socialImageSVG.style.height = "auto";
      socialImageSVG.setAttribute("width", "");
      socialImageSVG.setAttribute("height", "");

      FileSaver.saveAs(blob, "generative-social-image.png");
    });
  });
});


Enter fullscreen mode Exit fullscreen mode

Handling new text input

Ok, so all our buttons are hooked up and that's great. There is one last feature to add though. As the user types, we want to update our shape positions. To do this we can use ResizeObserver to run a function each time the width/height dimensions of our text elements change.

Check it out:



const resizeObserver = new ResizeObserver(() => {
  generate();
});

resizeObserver.observe(socialImageTitle);
resizeObserver.observe(socialImageMeta);


Enter fullscreen mode Exit fullscreen mode

Now as you type you should see your social image update just like the CodePen example.


We made it!

Blimey, that was quite something! The good news is, we are all done. I hope you learned something about generative design here and maybe even picked up some handy little SVG tips.

I think there are lots of places you could take this and would love to hear from you if you create something cool based on this tutorial 😎

If you did enjoy this post, do follow me on Twitter @georgedoescode for a steady stream of creative coding fun.

You can also support my tutorials by buying me a coffee ☕

Thank you very much for reading! I'll catch you next time ❤️

Top comments (8)

Collapse
 
maxwell_dev profile image
Max Antonucci

I just started getting into SVG and generative code, and this helped me see a lot of the potential. Especially with exporting SVG code as images, I'd been struggling to find a good example of that for a while. Thanks for taking the time to write this!

Collapse
 
georgedoescode profile image
George Francis

Ah that's great to hear! Thank you for reading!

Collapse
 
cassidoo profile image
Cassidy Williams

This is really clever, thanks for sharing your approach!

Collapse
 
georgedoescode profile image
George Francis

Thank you so much Cassidy! Cheers for reading! 🙌

Collapse
 
horrorofpartybeach profile image
Emma

I really enjoyed this, thanks for writing it! I've been wanting to learn more about SVGs but I didn't realise how fun they could be to work with until I read this 😄

Collapse
 
georgedoescode profile image
George Francis

Ah, amazing! I'm glad you liked it and good luck on your SVG journey!

Collapse
 
jsifontez profile image
Juan Sifontez

This is AMAZING!! thanks for sharing this.

Collapse
 
georgedoescode profile image
George Francis

Thank you so much! I'm glad you enjoyed it!