Compare commits

...
Author SHA1 Message Date
Ken c0f635459d fix: Add print and forced-color support 2026-11-02 15:52:08 +01:00
Ken 1fcf982453 feat: Added css-imports for every icon 2026-11-02 15:33:01 +01:00
7 changed files with 231 additions and 3 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/aksel-icons": minor
---
Icons: All icons are now available as CSS custom properties. Import `@navikt/aksel-icons/css/<IconName>.css` for a single icon (e.g. `--aksel-icon-magnifying-glass`), or `@navikt/aksel-icons/css/index.css` for every icon.
+31
View File
@@ -59,6 +59,37 @@ declare module "*.svg" {
}
```
### CSS custom properties
All icons are available as CSS custom properties behind `/css`, one file per icon. Each file declares a `--aksel-icon-<kebab-case-name>` variable on `:root`, containing the icon as an SVG data URI.
```css
@import "@navikt/aksel-icons/css/MagnifyingGlass.css";
.search-button::before {
content: "";
display: inline-block;
width: 1.5rem;
height: 1.5rem;
background-color: currentColor;
mask: var(--aksel-icon-magnifying-glass) center / contain no-repeat;
}
@media (forced-colors: active) {
.search-button::before {
background-color: CanvasText;
}
}
```
To get every icon, import `@navikt/aksel-icons/css/index.css`. This file contains all ~1000 icons (≈700 kB, ≈135 kB gzipped), so prefer importing only the icons you use.
Import icon files once in a global stylesheet (e.g. your root layout or `globals.css`), not in CSS modules or per-component stylesheets. The variables are global, and the SVG is inlined in each file, so importing the same icon in several places duplicates it in every CSS chunk.
- Icons added with CSS are decorative only. Give the parent element an accessible name, e.g. with visible text or `aria-label`.
- In forced colors mode (e.g. Windows High Contrast), `background-color` is forced to the page background, which hides the icon. Use `background-color: CanvasText` inside `@media (forced-colors: active)` as shown above, so the icon follows the user's text color.
- If you use a Content Security Policy, `img-src` must allow `data:`.
## [Learn more](https://aksel.nav.no/ikoner)
## License
@@ -0,0 +1,44 @@
import fastglob from "fast-glob";
import path from "node:path";
import { describe, expect, test } from "vitest";
import {
optimizeSvg,
svgToDataUri,
toCssVariableName,
// @ts-expect-error -- css-icons is JavaScript without a declaration file.
} from "../config/css-icons";
describe("toCssVariableName", () => {
test.each([
["MagnifyingGlass", "--aksel-icon-magnifying-glass"],
["Buildings2Fill", "--aksel-icon-buildings-2-fill"],
["ClipboardXMark", "--aksel-icon-clipboard-x-mark"],
["XMark", "--aksel-icon-x-mark"],
])("%s -> %s", (iconName, expected) => {
expect(toCssVariableName(iconName)).toBe(expected);
});
test("is unique for every icon", () => {
const names = fastglob
.sync("*.svg", { cwd: path.resolve(__dirname, "../icons") })
.map((file) => toCssVariableName(path.basename(file, ".svg")));
expect(new Set(names).size).toBe(names.length);
});
});
describe("svgToDataUri", () => {
const svg = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 3.25 4.75 10.5Z" fill="#202733"/>
</svg>`;
test("keeps viewBox, uses currentColor and escapes unsafe characters", () => {
const uri = svgToDataUri(optimizeSvg(svg));
const data = uri.slice('url("'.length, -'")'.length);
expect(uri.startsWith('url("data:image/svg+xml,')).toBe(true);
expect(data).toContain("viewBox='0 0 24 24'");
expect(data).toContain("fill='currentColor'");
expect(data).not.toMatch(/["#<>]|width=|height=/);
});
});
+95
View File
@@ -0,0 +1,95 @@
const fastglob = require("fast-glob");
const kebabCase = require("lodash/kebabCase");
const path = require("node:path");
const {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} = require("node:fs");
const { optimize } = require("svgo");
/**
* @param {string} iconName e.g. "MagnifyingGlass"
* @returns {string} e.g. "--aksel-icon-magnifying-glass"
*/
function toCssVariableName(iconName) {
return `--aksel-icon-${kebabCase(iconName)}`;
}
/**
* @param {string} svgString
* @returns {string} Minified SVG with currentColor fill and preserved viewBox
*/
function optimizeSvg(svgString) {
return optimize(svgString.replaceAll("#202733", "currentColor"), {
multipass: true,
plugins: [
{
name: "preset-default",
params: { overrides: { removeViewBox: false } },
},
{ name: "removeAttrs", params: { attrs: "svg:(width|height)" } },
],
}).data;
}
/**
* Encodes SVG for use in a CSS `url()`. Keeps readable characters unescaped for smaller output.
* @param {string} svgString
* @returns {string}
*/
function svgToDataUri(svgString) {
const encoded = svgString
.replaceAll('"', "'")
.replace(/\s+/g, " ")
.trim()
.replace(/[^\x20-\x7e]|[%#<>{}"\\]/g, encodeURIComponent);
return `url("data:image/svg+xml,${encoded}")`;
}
/**
* @param {string[]} declarations
* @returns {string}
*/
function createCssFile(declarations) {
return `:root {\n${declarations.map((d) => ` ${d}`).join("\n")}\n}\n`;
}
function main() {
const basePath = path.resolve(__dirname, "../icons");
const cssFolder = path.resolve(__dirname, "../dist/css");
const svgList = fastglob.sync("*.svg", { cwd: basePath }).sort();
if (existsSync(cssFolder)) {
rmSync(cssFolder, { recursive: true, force: true });
}
mkdirSync(cssFolder, { recursive: true });
const declarations = [];
for (const svg of svgList) {
const iconName = path.basename(svg, ".svg");
const svgString = readFileSync(path.join(basePath, svg)).toString();
const declaration = `${toCssVariableName(iconName)}: ${svgToDataUri(optimizeSvg(svgString))};`;
declarations.push(declaration);
writeFileSync(
path.join(cssFolder, `${iconName}.css`),
createCssFile([declaration]),
);
}
writeFileSync(path.join(cssFolder, "index.css"), createCssFile(declarations));
}
module.exports = {
createCssFile,
main,
optimizeSvg,
svgToDataUri,
toCssVariableName,
};
+47
View File
@@ -2,6 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import * as AkselIcons from "@navikt/aksel-icons";
import type { StarFillIcon } from "@navikt/aksel-icons";
import "@navikt/aksel-icons/css/Airplane.css";
import "@navikt/aksel-icons/css/Buildings2Fill.css";
import "@navikt/aksel-icons/css/MagnifyingGlass.css";
import "@navikt/aksel-icons/css/StarFill.css";
import "@navikt/aksel-icons/css/XMarkOctagonFill.css";
const meta: Meta<typeof StarFillIcon> = {
title: "aksel-icons/Icons",
@@ -25,3 +30,45 @@ export const IconsInverted: Story = {
render: Icons.render,
globals: { theme: "dark" },
};
const cssIconVariables = [
"--aksel-icon-airplane",
"--aksel-icon-buildings-2-fill",
"--aksel-icon-magnifying-glass",
"--aksel-icon-star-fill",
"--aksel-icon-x-mark-octagon-fill",
];
export const CssIcons: Story = {
render: () => (
<div
style={{ display: "flex", gap: "1rem", color: "var(--ax-text-accent)" }}
>
<style>
{`.css-icon {
display: inline-block;
width: 3rem;
height: 3rem;
background-color: currentColor;
print-color-adjust: exact;
}
@media (forced-colors: active) {
.css-icon { background-color: CanvasText; }
}`}
</style>
{cssIconVariables.map((variable) => (
<span
key={variable}
title={variable}
className="css-icon"
style={{ mask: `var(${variable}) center / contain no-repeat` }}
/>
))}
</div>
),
};
export const CssIconsInverted: Story = {
render: CssIcons.render,
globals: { theme: "dark" },
};
+7 -2
View File
@@ -23,7 +23,9 @@
"access": "public",
"provenance": true
},
"sideEffects": false,
"sideEffects": [
"*.css"
],
"license": "MIT",
"main": "./dist/react/cjs/index.js",
"module": "./dist/react/esm/index.js",
@@ -44,6 +46,7 @@
"default": "./dist/metadata.js"
},
"./svg/*.svg": "./dist/svg/*.svg",
"./css/*.css": "./dist/css/*.css",
"./package.json": "./package.json"
},
"typesVersions": {
@@ -62,10 +65,11 @@
"create-icons": "svgr --no-prettier --silent --index-template config/index-template.js --out-dir src icons && yarn prettify-icons",
"prettify-icons": "yarn prettier ./src --write --ignore-path nonexisting-path --log-level silent --no-config",
"parse-svg": "node -e 'require(\"./config/parse-svg.js\").main()'",
"css-icons": "node -e 'require(\"./config/css-icons.js\").main()'",
"copy": "copyfiles util/* src",
"update-metadata": "node config/metadata.js",
"write-packagejson": "echo '{\"type\": \"module\", \"sideEffects\": false}' > dist/react/esm/package.json",
"build": "yarn copy && yarn create-icons && concurrently \"tsc\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && node config/cleanTypes.js && yarn update-metadata && yarn parse-svg",
"build": "yarn copy && yarn create-icons && concurrently \"tsc\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && node config/cleanTypes.js && yarn update-metadata && yarn parse-svg && yarn css-icons",
"fetch-new:icons": "tsx --env-file=.env config/figma/index.ts",
"zip:icons": "node config/zip.js",
"test": "vitest run",
@@ -87,6 +91,7 @@
"prettier": "3.9.6",
"react": "19.3.1",
"rehype-parse": "9.0.1",
"svgo": "3.3.5",
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
"typescript": "6.0.3",
+2 -1
View File
@@ -4899,6 +4899,7 @@ __metadata:
prettier: "npm:3.9.6"
react: "npm:19.3.1"
rehype-parse: "npm:9.0.1"
svgo: "npm:3.3.5"
tsc-alias: "npm:1.9.7"
tsx: "npm:^4.23.15"
typescript: "npm:6.0.3"
@@ -23284,7 +23285,7 @@ __metadata:
languageName: node
linkType: hard
"svgo@npm:^3.0.2":
"svgo@npm:3.3.5, svgo@npm:^3.0.2":
version: 3.3.5
resolution: "svgo@npm:3.3.5"
dependencies: