Working with SVG Icons
Author: Mateusz Pałka
Problem context
We've all been there. A new icon is to be added to the project. What approach should I take? If you google the "svg icon in react" there are several different solutions. So let's discuss some of them so that we could reach the best solution that would suit your project.
Solutions
Using an external library
If you don't require any custom created icons or tailored library, but rather use one of the existing icon libraries like Font Awesome or Feather you can use React Icons with a very simple usage and a massive libraries of selectable icons.
Ok, but what if you need a custom made icons. Here is where the fun begins. So let's dive deeper in available solutions.
To get things started one important thing to mention is to change *.svg file's fill, stroke etc. from specific color to currentColor, unless it is multicolor icon that should always be in the initial color.
fill="currentColor"
Why? The currentColor will automatically make the icon to be in the text color and it is easy to manipulate, otherwise you may end up with editing color like
.someComponent {
color: black;
& > svg {
path {
fill: black;
stroke: black;
}
rect {
fill: black;
stroke: black;
}
}
}
what seems redundant with the view that it could be just
.someComponent {
color: black;
}
taking care of all SVG coloring or with tailwind text-black class.
Ok now to the essence. First you can see few different ways the custom SVG icons could be coded and are spread across various google search results. To see the solution that we believe is best, scroll to the Reusable Icon Component.
Using SVG as a source in <img/> element
I think this is one of the easiest solutions to implement, you don't need any plugins and a greater thought on how this should work. It is really good if you have a single svg image in the app for, say, a 404 page. But could be really troublesome if you use it in multiple places. Why? That default import could be named in various different ways by developers so if you ever want to change that to something else, it has a great potential to be a headache. Plus, for each developer may have a different idea about styling that svg.
usage:
import reactLogo from './assets/react.svg'
...
<img src={reactLogo} alt="React logo" />
Importing SVG as ReactComponent
To get started with this approach you need an SVGR Plugin in your project. For example in the vite app you need to update the config
// vite.config.ts
...
import svgr from "vite-plugin-svgr";
...
export default defineConfig({
plugins: [svgr(), react()], // svgr() added do the default config
});
With that done you can easily use SVG files as react components. However this approach have the similar downsides as the previous one, regarding naming the imports and styling.
usage:
import { ReactComponent as BackpackSolid } from './assets/backpack-solid.svg'
...
<div>
<BackpackSolid />
</div>
Creating ReactComponent returning svg
Without the svgr you can always create the the component that returns the svg.
// ./icons/balloon-icon.tsx
export const BalloonIcon = () => {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g clip-path='url(#clip0_406_2166)'>
<path
d='M14 7C14 5.14349 13.2625 3.36301 11.9497 2.05025C10.637 0.737498 8.85652 0 7 0C5.14349 0 3.36301 0.737498 2.05025 2.05025C0.737498 3.36301 0 5.14349 0 7C0 10.971 2.257 15.485 5.39 16.685C5.17323 17.046 5.03997 17.4509 5 17.87C5 19.441 6.489 20.137 7.576 20.646C8.176 20.926 9 21.311 9 21.609C8.91853 21.9195 8.77272 22.2095 8.572 22.46C8.50109 22.5706 8.45265 22.694 8.42945 22.8233C8.40624 22.9526 8.40874 23.0852 8.43678 23.2135C8.46482 23.3419 8.51787 23.4634 8.59289 23.5713C8.66791 23.6791 8.76343 23.7711 8.874 23.842C9.09731 23.9852 9.36837 24.0339 9.62755 23.9772C9.75588 23.9492 9.87743 23.8961 9.98526 23.8211C10.0931 23.7461 10.1851 23.6506 10.256 23.54C10.6804 22.979 10.9383 22.3098 11 21.609C11 20.038 9.511 19.342 8.424 18.834C7.824 18.553 7 18.168 6.991 17.969C7.14303 17.6108 7.33347 17.2701 7.559 16.953C11.227 16.475 14 11.4 14 7ZM9.176 10.47C9.6368 9.72092 9.91877 8.87571 10 8C10 7.73478 10.1054 7.48043 10.2929 7.29289C10.4804 7.10536 10.7348 7 11 7C11.2652 7 11.5196 7.10536 11.7071 7.29289C11.8946 7.48043 12 7.73478 12 8C11.9182 9.25133 11.5305 10.4635 10.871 11.53C10.8014 11.6416 10.7105 11.7384 10.6035 11.8149C10.4965 11.8914 10.3754 11.9461 10.2473 11.9758C10.1191 12.0055 9.98639 12.0097 9.85663 11.9881C9.72686 11.9665 9.60262 11.9196 9.491 11.85C9.37938 11.7804 9.28256 11.6895 9.20606 11.5825C9.12957 11.4755 9.07491 11.3544 9.04519 11.2263C9.01548 11.0981 9.01129 10.9654 9.03288 10.8356C9.05447 10.7059 9.1014 10.5816 9.171 10.47H9.176ZM19.5 18.589C18.818 18.227 18.114 17.852 18.016 17.427C17.97 17.234 17.991 16.799 18.531 15.954C21.7 15.5 24 11.025 24 7C24.0279 6.20454 23.8919 5.41185 23.6002 4.67126C23.3086 3.93067 22.8676 3.25803 22.3048 2.69521C21.742 2.13239 21.0693 1.69143 20.3287 1.39979C19.5882 1.10815 18.7955 0.972063 18 1C16.7554 0.983245 15.535 1.34449 14.5 2.036C15.4793 3.50618 16.0013 5.23349 16 7C15.9509 9.37959 15.3396 11.7138 14.216 13.812C14.7877 14.5962 15.5431 15.228 16.416 15.652C16.0469 16.3328 15.9226 17.1196 16.064 17.881C16.37 19.191 17.583 19.836 18.558 20.355C19.174 20.683 19.871 21.055 19.977 21.423C20.0033 21.8083 19.8916 22.1904 19.662 22.501C19.5953 22.6148 19.5518 22.7406 19.5339 22.8713C19.516 23.0019 19.5242 23.1348 19.5579 23.2623C19.5916 23.3898 19.6502 23.5094 19.7303 23.6142C19.8104 23.7189 19.9105 23.8068 20.0247 23.8728C20.1389 23.9387 20.265 23.9814 20.3958 23.9984C20.5266 24.0154 20.6594 24.0064 20.7867 23.9718C20.914 23.9373 21.0331 23.8779 21.1374 23.7971C21.2416 23.7163 21.3288 23.6156 21.394 23.501C21.6554 23.1219 21.8354 22.6927 21.9225 22.2406C22.0096 21.7884 22.0019 21.3231 21.9 20.874C21.6776 20.3492 21.3498 19.8756 20.937 19.4826C20.5242 19.0896 20.0351 18.7854 19.5 18.589ZM19.175 10.47C19.6362 9.72101 19.9185 8.87579 20 8C20 7.73478 20.1054 7.48043 20.2929 7.29289C20.4804 7.10536 20.7348 7 21 7C21.2652 7 21.5196 7.10536 21.7071 7.29289C21.8946 7.48043 22 7.73478 22 8C21.9182 9.25133 21.5305 10.4635 20.871 11.53C20.7304 11.7554 20.5061 11.9158 20.2473 11.9758C19.9885 12.0358 19.7164 11.9906 19.491 11.85C19.2656 11.7094 19.1052 11.4851 19.0452 11.2263C18.9852 10.9675 19.0304 10.6954 19.171 10.47H19.175Z'
fill='#B7121F'
/>
</g>
<defs>
<clipPath id='clip0_406_2166'>
<rect width='24' height='24' fill='white' />
</clipPath>
</defs>
</svg>
);
};
usage:
import { BalloonIcon } from './icons/balloon-icon'
...
<BalloonIcon />
however you'd still have to "guess" what sort of icons are there already created and you'd have no intellisense to help you with that. So let's move a one step up
Creating a reusable <Icon /> component
Let's start with the folder structure for this component
|- icon
| |- assets // separate to regular assets it's worth having the svg icon close to the component
| | |- add.svg
| | |- user.svg
| | |- calendar.svg
| |- icons.ts
| |- icon.tsx
| |- icon.test.tsx
| |- index.ts // just an prettier export
now let's dive to the files
// index.ts
export { Icon } from './icon';
export type { IconName } from './icon';
You create a file with all the icons that are in your assets so that later on they are mega easy to use.
// icons.ts
import { ReactComponent as Add } from './assets/add.svg';
import { ReactComponent as User } from './assets/user.svg';
import { ReactComponent as AddressBookSolid } from './assets/calendar.svg';
export const icons = {
Add,
User,
Calendar,
};
The icon file has the sauce of the reusable icon, with that approach it's mega simple to reuse the icon, have specific styling applied straight from the get go or to use some variants that are allowed in the design system. Just put a required prop.
// icon.ts
import type { HTMLAttributes } from 'react';
import { createElement } from 'react';
import { icons } from './icons';
export type IconName = keyof typeof icons;
// Props can vary from project to project, some will require to have some specific variant passed for styling,
// others will extend base css classes with custom prop class etc
interface Props extends HTMLAttributes<HTMLDivElement> {
icon: IconName;
className?: string;
// These props make styling component easier than creating new classes
rotate?: number;
}
/**
*
* @param icon string key icon name
* @param className string classes for styling
* @param rotate optional number rotation of the icon
* @returns Icon react component
*/
export const Icon = ({ icon, className, rotate, ...rest }: Props) => {
return (
<div
className={className}
aria-label={icon}
role='img'
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: rotate ? `rotate(${rotate}deg)` : undefined,
}}
{...rest}
>
{createElement(icons[icon], {
style: { width: '100%', height: '100%' },
})}
</div>
);
};
Now with that you always import a single <Icon /> component and with the icon prop you select the required icon with the help of intellisense.
usage:
import { Icon } from './icon';
function App() {
return (
<div className='App'>
<Icon icon='User' />
</div>
);
}
But there is still one major problem
What if there are many icons that you want to include in your library? At the moment every time you use a single icon, all of them are loaded. That is really unnecessary, so let's edit some files.
First let's lazy load our icons. In order to do that we need to create our own lazy function as the one exported from React expect the default export and we don't have that with svg.
// icons.ts
import { lazy as _lazy } from 'react';
function lazy(importFn: Function) {
return _lazy(async () => {
const m = await importFn();
return { default: m.ReactComponent };
});
}
export const icons = {
Add: lazy(async () => import('./assets/add.svg')),
User: lazy(async () => import('./assets/user.svg')),
Calendar: lazy(async () => import('./assets/calendar.svg')),
};
now our icons are loaded as they are needed. So in order for them to work properly in the icon.tsx we need to add <Suspense> the element. Additionally, we'd like to save already loaded icon if the parent component rerenders, to don't want to refetch and recreate those icons. For this we use useMemo.
// icon.tsx
...
export const Icon = ({ icon, className, rotate, color, ...rest }: Props) => {
const SvgIcon = useMemo(() => icons[icon], [icon]);
if (!SvgIcon) return null;
return (
<div
className={className}
aria-label={icon}
role="img"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
transform: rotate ? `rotate(${rotate}deg)` : undefined,
}}
{...rest}
>
<Suspense fallback={null}>
<SvgIcon style={{ width: "100%", height: "100%" }} />
</Suspense>
</div>
);
and there you have it. You just created your own library of icons that is easily reused.
But now I've got a lot of errors in my tests! Yes, you do. Reason for this is you need to await the loading of the icon. In general tests should look like this.
// icon.test.tsx
import { render, screen } from '@testing-library/react';
import { Icon, IconName } from './icon';
import { icons } from './icons';
import { axe, toHaveNoViolations } from 'jest-axe';
describe('<Icon />', () => {
expect.extend(toHaveNoViolations);
it.each(Object.keys(icons) as IconName[])(
'renders correct %s icon without violations',
async (iconName: IconName) => {
const { container } = render(<Icon icon={iconName} />);
await screen.findByText(/svg/i); // THE IMPORTANT LINE
const results = await axe(container);
expect(results).toHaveNoViolations();
expect(screen.getByRole('img')).toHaveAttribute('aria-label', iconName);
}
);
});
but again you can make that reusable for bigger components that use the icon just create a wrapper for render function like that.
// with-icon.tsx
import type { RenderResult } from '@testing-library/react';
import { screen } from '@testing-library/react';
export const withIcon = async (view: RenderResult) => {
await screen.findAllByText(/svg/i); // await for all, as there may be many icons in a single component
return view;
};
so the above test with that new helper function would look like this
import { withIcon } from './with-icon'
...
async (iconName: IconName) => {
const { container } = await withIcon(render(<Icon icon={iconName} />));
const results = await axe(container);
expect(results).toHaveNoViolations();
expect(screen.getByRole("img")).toHaveAttribute("aria-label", iconName);
}
...