Material Design Icons in SASS

I’ve always felt like when it comes to using icons in CSS, we can do better than font icons and sprite sheets. Wouldn’t it be better if we could just specify the icons we want to use in our SASS directly, and have them inlined when compiled?

With the native SASS compiler there aren’t really any options to extend the functionality, but with the newer Dart SASS compiler, we can specify our own custom functions for use in our SASS code right in our webpack configuration. Those functions could be used to generate SVG data URI’s in the resulting CSS.

When I was developing my updated website, I decided to do just that with Material Design Icons, and take it to the next level by adding rotation and scaling options. This post is about how I accomplished all that.

Solution

First we create a test HTML page to load the CSS.

test.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>test</title>
<link rel="stylesheet" href="dist/main.css">
</head>
<body>
<div class="icon icon-a"></div>
<div class="icon icon-b"></div>
<div class="icon icon-c"></div>
<div class="icon icon-d"></div>
</body>
</html>

Now we need to install some dependencies. To use the same versions I used, create a package.json file with the following.

Some notes on some of the packages used:

  • @mdi/js: Contains the path data for all the icons.
  • mini-svg-data-uri: For encoding the smallest data URI’s.
package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"private": true,
"scripts": {
"build": "webpack"
},
"devDependencies": {
"@mdi/js": "^5.3.45",
"css-loader": "^3.6.0",
"he": "^1.2.0",
"mini-css-extract-plugin": "^0.9.0",
"mini-svg-data-uri": "^1.2.3",
"sass": "^1.26.10",
"sass-loader": "^9.0.2",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.12"
}
}

Now run npm i to install the dependencies.

Next create the webpack config file. Checkout the custom sassFunctions for where the magic happens.

webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
'use strict';

const path = require('path');

const sass = require('sass');
const mdiIcons = require('@mdi/js');
const he = require('he');
const svgToTinyDataUri = require('mini-svg-data-uri');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

function camelCase(s) {
return s.replace(/-([a-z])/gi, ($0, $1) => $1.toUpperCase());
}

function xmlAttrs(attrs) {
return Object.entries(attrs)
.filter(([p, v]) => v !== null && typeof v !== 'undefined')
.map(([p, v]) => ` ${p}="${he.encode(String(v))}"`)
.join('');
}

function mdiSvg(name, attrsXml = {}, attrsPath = {}) {
attrsXml = {
xmlns: 'http://www.w3.org/2000/svg',
width: 24,
height: 24,
...attrsXml
};
const path = mdiIcons[camelCase(`mdi-${name}`)];
if (!path) {
throw new Error(`Unknown mdi icon: ${name}`);
}
attrsPath = {
...attrsPath,
d: path
};
return `<svg${xmlAttrs(attrsXml)}><path${xmlAttrs(attrsPath)}/></svg>`;
}

const sassFunctions = {
'custom-mdi-icon($name, $color)': (name, color) => {
if (!(name instanceof sass.types.String)) {
throw new Error('$name: Expected string.');
}
if (!(color instanceof sass.types.Color)) {
throw new Error('$color: Expected color.');
}

// Parse the name and get any extra parameters.
const nameValue = name.getValue();
const [named, ...params] = nameValue.split(/\s+/);

// Parse any extra parameters.
const width = 24;
const height = 24;
const transforms = [];
const props = {
rotate: (deg, x = 0.5, y = 0.5) => {
const xo = width * +x;
const yo = height * +y;
transforms.push(
`rotate(${deg} ${xo} ${yo})`
);
},
scale: (x, y = null) => {
y = y === null ? x : y;
const xo = (1 - +x) * width * 0.5;
const yo = (1 - +y) * height * 0.5;
transforms.push(
`translate(${xo} ${yo})`,
x === y ? `scale(${x})` : `scale(${x} ${y})`
);
}
};
while (params.length) {
const [type, ...args] = params.shift().split(':');
const prop = props[type];
if (!prop) {
throw new Error(`Unknown type: ${type} in ${nameValue}`);
}
prop(...args);
}

// Create the XML code and the data URI url code.
const xml = mdiSvg(
named,
{
width,
height,
fill: color.toString()
},
{
transform: transforms.length ? transforms : null
}
);
return new sass.types.String(`url("${svgToTinyDataUri(xml)}")`);
}
};

module.exports = {
entry: './src/main.js',
output: {
filename: '[name].js',
path: path.resolve('dist')
},
mode: 'development',
devtool: '',
module: {
rules: [
{
test: /\.(sass|scss|css)$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader'
},
{
loader: 'sass-loader',
options: {
implementation: sass,
sassOptions: {
functions: sassFunctions
}
}
}
]
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[id].css'
})
]
};

Now create the main JS and SCSS files. In the SCSS file we create a thin wrapper around the custom function which helps with autocomplete in some editors.

src/main.js
1
import './main.scss'
src/main.scss
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@function mdi-icon($name, $color) {
@return custom-mdi-icon($name, $color);
}

.icon {
display: inline-block;
width: 100px;
height: 100px;
border: 1px solid black;
background-size: cover;
}

.icon-a {
background-image: mdi-icon('account', blue);
}
.icon-b {
background-image: mdi-icon('account rotate:90', blue);
}
.icon-c {
background-image: mdi-icon('account scale:0.5', blue);
}
.icon-d {
background-image: mdi-icon('account rotate:180 scale:0.5:1', blue);
}

Now run npm run build to build the JS and CSS, and open test.html in a browser to see the results.

Screenshot

Screenshot

If you checkout dist/main.css to see the output contains our customized SVG data for each individual icon inline.

dist/main.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.icon {
display: inline-block;
width: 100px;
height: 100px;
border: 1px solid black;
background-size: cover;
}

.icon-a {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='blue'%3e%3cpath d='M12%2c4A4%2c4 0 0%2c1 16%2c8A4%2c4 0 0%2c1 12%2c12A4%2c4 0 0%2c1 8%2c8A4%2c4 0 0%2c1 12%2c4M12%2c14C16.42%2c14 20%2c15.79 20%2c18V20H4V18C4%2c15.79 7.58%2c14 12%2c14Z'/%3e%3c/svg%3e");
}

.icon-b {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='blue'%3e%3cpath transform='rotate(90 12 12)' d='M12%2c4A4%2c4 0 0%2c1 16%2c8A4%2c4 0 0%2c1 12%2c12A4%2c4 0 0%2c1 8%2c8A4%2c4 0 0%2c1 12%2c4M12%2c14C16.42%2c14 20%2c15.79 20%2c18V20H4V18C4%2c15.79 7.58%2c14 12%2c14Z'/%3e%3c/svg%3e");
}

.icon-c {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='blue'%3e%3cpath transform='translate(6 6)%2cscale(0.5)' d='M12%2c4A4%2c4 0 0%2c1 16%2c8A4%2c4 0 0%2c1 12%2c12A4%2c4 0 0%2c1 8%2c8A4%2c4 0 0%2c1 12%2c4M12%2c14C16.42%2c14 20%2c15.79 20%2c18V20H4V18C4%2c15.79 7.58%2c14 12%2c14Z'/%3e%3c/svg%3e");
}

.icon-d {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='blue'%3e%3cpath transform='rotate(180 12 12)%2ctranslate(6 0)%2cscale(0.5 1)' d='M12%2c4A4%2c4 0 0%2c1 16%2c8A4%2c4 0 0%2c1 12%2c12A4%2c4 0 0%2c1 8%2c8A4%2c4 0 0%2c1 12%2c4M12%2c14C16.42%2c14 20%2c15.79 20%2c18V20H4V18C4%2c15.79 7.58%2c14 12%2c14Z'/%3e%3c/svg%3e");
}

Notes

Every use of the function outputs a copy of the SVG data, optionally with some extra transform data. Gzip is good at compressing repeating strings, so this is not a huge issue, however defining the icons in reusable classes can keep the code even smaller.

Comments