Say you have a set of icons in Figma and a project in React/Vue/whatever else. No surprise, you will need to use those icons. How can we import them?
If you're stubborn enough, you could download them one by one and add them as assets. But that’s a sad way. No likey.
Let’s stop being sad and be awesome instead. Here’s what we’ll need:
*no further ado provided*
We’ll need a Figma token. I don’t think it’s necessary to explain how to do this, so here’s a help section instead: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens.
The default settings are good enough: we need read-only access to the file content.
I’ll use Material Design Icons (Community) as an example.
We’ll need a file key and the main node id:
Now that we have enough prerequisites, let’s start coding. I like my scripts separated from the main project code, so I’ll create a scripts
folder in the root and a fetch-icons.ts
in it. There’s no need to make it in TypeScript, but it makes this article kinda fancy, so I’ll go with it.
However, this will require a couple more dependencies:
yarn add -D tsx dotenv axios @figma/rest-api-spec
dotenv
is here to use the .env
variables, tsx
— to run the TS script (fancy, remember?), @figma/rest-api-spec
is for type safety, and axios
is just because I’m used to it. The only one required is dotenv
(unless you’re ok with adding the API key to the code), and I’ll add the “pure” version at the end. For now, we will have this setup, and here’s what we start with:
import * as dotenv from "dotenv";
dotenv.config();
const PERSONAL_ACCESS_TOKEN = String(
process.env.VITE_FIGMA_PERSONAL_ACCESS_TOKEN
); // I’ve added my Figma token to the .env file, and so should you
const FIGMA_API_URL = "https://api.figma.com/v1";
const FILE_KEY = "v50KJO82W9bBJUppE8intT"; // this one is from the URL
const NODE_ID = "2402-2207"; // node-id query param, also from the URL
At first, we’ll get the metadata for the main frame:
import {
GetFileNodesResponse,
GetImagesResponse,
HasChildrenTrait,
} from "@figma/rest-api-spec";
// ...
const fetchFrameData = async (
nodeId: string
): Promise<HasChildrenTrait> => {
const { data } = await axios.get<GetFileNodesResponse>(
`${FIGMA_API_URL}/files/${FILE_KEY}/nodes?ids=${nodeId}`,
getConfig()
);
return data.nodes[nodeId].document as HasChildrenTrait;
};
It will return the information about its children. Those can be images (their type will be INSTANCE
) or other frames that we will parse recursively:
const getImageNodes = (
frameData: HasChildrenTrait
): Record<string, string> => {
const nodes: Record<string, string> = {};
for (const image of frameData.children.filter(
(node) => node.type === 'INSTANCE'
)) {
// normalizeName simply converts 'check-box' to 'CheckBox'
const name = normalizeName(image.name);
const id = image.id;
nodes[id] = name;
}
for (const frame of frameData.children.filter(
(node) => node.type === 'FRAME'
)) {
Object.assign(nodes, getImageNodes(frame));
}
return nodes;
};
Finally, when we have a bunch of image IDs, we will fetch SVG file URLs:
const fetchImageUrls = async (
nodeIds: string[]
): Promise<GetImagesResponse> => {
const { data } = await axios.get<GetImagesResponse>(
`${FIGMA_API_URL}/images/${FILE_KEY}?ids=${nodeIds.join(",")}&format=svg`,
getConfig()
);
return data;
};
And we will end up with an array of such objects:
{
name: 'CheckBox',
url: 'https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/7dd5bc31-4f26-4701-b155-7bf7dea45824'
}
As you’ve already guessed, we’ll download them all, but that’s not the most interesting part.
Every time we fetch an SVG, we get something like that:
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 6H7C3.69 6 1 8.69 [...] 9 17 9Z" fill="black" />
</svg>
We will remove the svg
tags since they are identical for each icon (I assume they are the same size for simplicity). But what’s more interesting is that we’ll replace all the fill=”black”
with a placeholder in a template literal, and as a result, we’ll have such a function:
export const IconToggleOn = (color: string) =>
`<path d="M17 6H7C3.69 [...] 9 17 9Z" fill="${color}"/>`;
Here’s how we get it:
const cleanupSvg = (data: string): string => {
const svgTags = /<\/?svg.*?>/g;
const colorFills = /fill=["'].+?["']/g;
return data
.replace(svgTags, "")
.replace(colorFills, 'fill="${color}"')
.replace(/\n/g, "");
};
Obviously, you won’t need to replace the colors if you need to import some logos, but making a different logic for them will be your homework.
Okay, here’s our plan:
I won’t recite the whole file—you can check it on GitHub. Let’s just take a look at the downloading itself:
const FILE_BATCH_SIZE = 10;
const fileContent: string[] = [];
const getPromise = async (item: IconData) => {
try {
const { data } = await axios.get<string>(
item.url,
getConfig("image/svg+xml") // returns necessary options and headers
);
return { name: item.name, data };
} catch (err) {
console.error(err);
}
};
for (let i = 0; i < iconsData.length; i += FILE_BATCH_SIZE) {
const batch = await Promise.allSettled(
iconsData.slice(i, i + FILE_BATCH_SIZE).map(getPromise)
);
for (const icon of batch) {
if (icon?.status === "fulfilled" && icon.value) {
const iconName = `Icon${icon.value.name}`;
const svgData = cleanupSvg(icon.value.data);
fileContent.push(
`export const ${iconName} = (color: string) =>\n \`${svgData}\`;\n`
);
}
}
}
Batching is important here because otherwise, Figma won’t like you if you decide to download a couple of thousands of files in parallel.
If you need to get an icon by the name you receive from the backend, you can also create a dictionary:
const iconArray: string[] = [];
// ...
for (const icon of batch) {
if (icon?.status === "fulfilled" && icon.value) {
const iconName = `Icon${icon.value.name}`;
const svgData = cleanupSvg(icon.value.data);
fileContent.push(
`export const ${iconName} = (color: string) =>\n \`${svgData}\`;\n`
);
iconArray.push(iconName); // <-- we added this
}
}
// ... when all files are processed
fileContent.push(
`\r\nexport const iconDictionary: Record<string, (color: string) => string> = {\r\n ${iconArray.join(
",\r\n "
)}\r\n};\r\n`
);
This approach is worse since the whole set will be added to the bundle even if you use just one icon via dictionary, but sometimes, you have to make something unpleasant. Also, all the \r\n
and spaces are here to make my ESLint happy: you might need to adjust their amount.
Here comes the last step; let’s make a new file:
import path from "path";
import { fileURLToPath } from "url";
// ...
const PATH_TO_ICONS = "../src/assets/icons";
const currentPath = fileURLToPath(import.meta.url);
const currentDirectory = path.dirname(currentPath);
const fileName = "index.ts";
const filePath = path.join(
path.resolve(currentDirectory, PATH_TO_ICONS),
fileName
);
// ...
fs.writeFileSync(filePath, fileContent.join(""));
It’s a good idea to sort icons after fetching the URLs, which is as simple as this:
iconsData.sort((a, b) => a.name.localeCompare(b.name));
A sorted list will be easier to read, and, what’s more important, we’ll have a readable diff whenever we get new icons. Otherwise, the order is not guaranteed, and adding one icon can mess everything up.
We need the component that accepts the icon string, color, and size as props and draws something sweet:
import React, { useMemo } from "react";
import { IconSvg } from "../types/IconSvg";
interface IconProps {
svg: IconSvg; // type IconSvg = (color: string) => string;
color?: string;
size?: number | string;
}
const refine = (x: string | number) => {
return typeof x === "number" || !/\D+/.test(x) ? `${x}px` : x;
};
const MyIcon: React.FC<IconProps> = ({
svg,
color,
size,
}) => {
const iconColor = useMemo(() => color ?? "black", [color]);
const refinedSize = useMemo(() => refine(size ?? 24), [size]);
const currentIcon = useMemo(() => svg(currentColor), [svg, currentColor]);
return (
<svg
viewBox="0 0 24 24" // make sure it's the same as in Figma
dangerouslySetInnerHTML={{ __html: currentIcon }}
style={{
width: refinedSize,
minWidth: refinedSize,
height: refinedSize,
minHeight: refinedSize,
}}
></svg>
);
};
export default MyIcon;
I believe it’s pretty straightforward, except for the size refining: when I implemented it for the first time, Chrome accepted numbers as width/height CSS properties, but Firefox didn’t, so I added px
. Even if it’s not a case anymore, let’s keep it as it’s supposed to be according to standards.
And here’s the autocomplete that I promised:
This is how I cook it. I hope you find it useful.
The drawback of this approach is that the icons will be inlined and not cached because of that. However, I don’t think it’s a big deal for a few bytes. Moreover, you can add rotating, changing the color on hovering, whatever else, and you’ll have a tiny Swiss-army knife of those icons.
Also, it’ll require an organized file in Figma. But hey, if you can write all this code, you can also persuade your designers to sort it out.
You can find the full code, including React and Vue components, and a Node.js version with fewer dependencies here: https://github.com/Smileek/fetch-icons