feat(tailwind): Tw v4 config (#5036)

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
This commit is contained in:
KenandHalvor Haugan authored and GitHub committed 2026-07-01 13:43:13 +02:00
1 parent 69f137e01e
commit e6dd922d8c
10 files changed
+220 -9

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-tailwind": minor
---
Tailwind v4 config now available at `@navikt/ds-tailwind/v4`
+2 -1
View File
@@ -1,3 +1,4 @@
src/tokens.ts
tailwind.config.js
tailwind.darkside.config.js
tailwind.darkside.config.js
tailwind4.css
+9 -3
View File
@@ -9,7 +9,7 @@ Custom tailwind-preset based on `@navikt/ds-tokens`.
npm install @navikt/ds-tailwind
```
## Usage
## Use with tailwind v3
```javascript
// tailwind.config.js
@@ -19,10 +19,16 @@ module.exports = {
};
```
## Use with tailwind v4
```css
@import "tailwindcss";
@import "@navikt/ds-tailwind/v4";
```
## Learn more
- [Docs](https://aksel.nav.no/komponenter)
- [Storybook](https://aksel.nav.no/storybook/)
- [Docs](https://aksel.nav.no/grunnleggende/kode/tailwind)
## License
+5 -2
View File
@@ -10,10 +10,11 @@
"license": "MIT",
"files": [
"tailwind.config.js",
"tailwind.config.d.ts"
"tailwind.config.d.ts",
"tailwind4.css"
],
"scripts": {
"build": "yarn tsx ./src/build.ts",
"build": "yarn tsx ./src/build-v3.ts && yarn tsx ./src/build-v4.ts",
"test": "vitest run",
"test:watch": "vitest watch"
},
@@ -28,6 +29,7 @@
"homepage": "https://aksel.nav.no/grunnleggende/kode/tailwind/",
"devDependencies": {
"@navikt/ds-tokens": "^8.14.0",
"@types/node": "^24.10.9",
"tailwindcss": "^3.3.3",
"tsx": "^4.20.6",
"typescript": "6.0.3",
@@ -50,6 +52,7 @@
"default": "./tailwind.config.js"
}
},
"./v4": "./tailwind4.css",
"./package.json": "./package.json"
}
}
File renamed without changes.
+115
View File
@@ -0,0 +1,115 @@
import { writeFileSync } from "node:fs";
import * as TokensBuild from "@navikt/ds-tokens/js";
import { kebabCaseForAlpha } from "../../tokens/config/kebabCase";
import { breakpointTokenConfig } from "../../tokens/src/tokens/breakpoint";
const transformedTokens = Object.fromEntries(
Object.entries(TokensBuild)
.map(([key, value]) => {
return [kebabCaseForAlpha(key), value];
})
.filter(([key]) => key !== "default"),
);
const nonColorTokens = [
"space",
"shadow",
"font-weight",
"font-size",
"font-line-height",
"font-family",
"border-radius",
"breakpoint",
"opacity",
"radius",
];
const colorTokensEntries = Object.entries(transformedTokens).filter(([key]) => {
return !nonColorTokens.find((prefix) => key.toLowerCase().includes(prefix));
});
const colors = Object.fromEntries(colorTokensEntries) as Record<string, string>;
export const v4Config = {
colors,
shadows: extractTokensForCategory("shadow"),
fontSizes: extractTokensForCategory("font-size"),
fontWeights: extractTokensForCategory("font-weight"),
lineHeights: extractTokensForCategory("font-line-height"),
fontFamilies: extractTokensForCategory("font-family"),
opacity: extractTokensForCategory("opacity"),
radius: extractTokensForCategory("radius"),
breakpoints: {
sm: breakpointTokenConfig.breakpoint.sm.value,
md: breakpointTokenConfig.breakpoint.md.value,
lg: breakpointTokenConfig.breakpoint.lg.value,
xl: breakpointTokenConfig.breakpoint.xl.value,
"2xl": breakpointTokenConfig.breakpoint["2xl"].value,
},
};
function generateThemeCSS(): string {
const lines: string[] = ["@theme {"];
for (const [key, value] of Object.entries(v4Config.colors)) {
lines.push(` --color-ax-${key}: ${value};`);
}
lines.push("");
for (const [key, value] of Object.entries(v4Config.shadows)) {
lines.push(` --shadow-ax-${key}: ${value};`);
}
lines.push("");
for (const [key, value] of Object.entries(v4Config.fontSizes)) {
lines.push(` --font-size-ax-${key}: ${value};`);
}
lines.push("");
for (const [key, value] of Object.entries(v4Config.fontWeights)) {
lines.push(` --font-weight-ax-${key}: ${value};`);
}
lines.push("");
for (const [key, value] of Object.entries(v4Config.lineHeights)) {
lines.push(` --leading-ax-${key}: ${value};`);
}
lines.push("");
for (const [key, value] of Object.entries(v4Config.fontFamilies)) {
lines.push(` --font-family-ax-${key}: ${value};`);
}
lines.push("");
for (const [key, value] of Object.entries(v4Config.opacity)) {
lines.push(` --opacity-ax-${key}: ${value};`);
}
lines.push("");
for (const [key, value] of Object.entries(v4Config.radius)) {
lines.push(` --radius-ax-${key}: ${value};`);
}
lines.push("");
for (const [key, value] of Object.entries(v4Config.breakpoints)) {
lines.push(` --breakpoint-ax-${key}: ${value};`);
}
lines.push("}");
return lines.join("\n");
}
writeFileSync("tailwind4.css", generateThemeCSS());
/* -------------------------------------------------------------------------- */
/* Utilities */
/* -------------------------------------------------------------------------- */
function extractTokensForCategory(tokenName: string) {
const tokens = Object.entries(transformedTokens)
.filter(([key]) => key.startsWith(tokenName))
.map(([key, value]) => [key.replace(`${tokenName}-`, ""), value]);
return Object.fromEntries(tokens);
}
export { generateThemeCSS };
+80 -2
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "vitest";
import { config } from "./build";
import { config } from "./build-v3";
import { generateThemeCSS, v4Config } from "./build-v4";
describe("Tailwind config", () => {
describe("Tailwind v3 config", () => {
test("should have correct color tokens", () => {
const colorKeys = Object.keys(config.theme.colors);
expect(colorKeys).not.toContain("spacing");
@@ -14,3 +15,80 @@ describe("Tailwind config", () => {
expect(colorKeys).not.toContain("breakpoint");
});
});
const allowedVariables = [
"--color-",
"--font-",
"--text-",
"--font-weight-",
"--tracking-",
"--leading-",
"--tab-size-",
"--breakpoint-",
"--container-",
"--spacing-",
"--radius-",
"--shadow-",
"--inset-shadow-",
"--drop-shadow-",
"--blur-",
"--perspective-",
"--zoom-",
"--aspect-",
"--ease-",
"--animate-",
"--opacity-",
];
describe("Tailwind v4 config", () => {
test("should have correct color tokens without non-color categories", () => {
const colorKeys = Object.keys(v4Config.colors);
expect(colorKeys).not.toContain("spacing");
expect(colorKeys).not.toContain("shadow");
expect(colorKeys).not.toContain("font-weight");
expect(colorKeys).not.toContain("font-size");
expect(colorKeys).not.toContain("font-line-height");
expect(colorKeys).not.toContain("font-family");
expect(colorKeys).not.toContain("border-radius");
expect(colorKeys).not.toContain("breakpoint");
expect(colorKeys.some((k) => k.includes("radius"))).toBe(false);
});
test("should have shadow tokens", () => {
expect(Object.keys(v4Config.shadows).length).toBeGreaterThan(0);
});
test("should have font-size tokens", () => {
expect(Object.keys(v4Config.fontSizes).length).toBeGreaterThan(0);
});
test("should have breakpoints", () => {
expect(v4Config.breakpoints).toEqual({
sm: "480px",
md: "768px",
lg: "1024px",
xl: "1280px",
"2xl": "1440px",
});
});
test("should have radius tokens", () => {
expect(Object.keys(v4Config.radius).length).toBeGreaterThan(0);
});
test("all generated variables should use allowed Tailwind v4 theme namespaces", () => {
const css = generateThemeCSS();
const variableLines = css
.split("\n")
.filter((line) => line.trim().startsWith("--"))
.map((line) => line.trim().split(":")[0]);
expect(variableLines.length).toBeGreaterThan(0);
const invalid = variableLines.filter(
(v) => !allowedVariables.some((prefix) => v.startsWith(prefix)),
);
expect(invalid).toEqual([]);
});
});
+2 -1
View File
@@ -3,7 +3,8 @@
"esModuleInterop": true,
"allowJs": true,
"moduleResolution": "bundler",
"module": "preserve"
"module": "preserve",
"types": ["@types/node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
+1
View File
@@ -85,5 +85,6 @@ export default {
"**/@navikt/aksel/**",
"**/examples/**",
"plugin.js",
"**/tailwind4.css",
],
};
+1
View File
@@ -5486,6 +5486,7 @@ __metadata:
resolution: "@navikt/ds-tailwind@workspace:@navikt/core/tailwind"
dependencies:
"@navikt/ds-tokens": "npm:^8.14.0"
"@types/node": "npm:^24.10.9"
tailwindcss: "npm:^3.3.3"
tsx: "npm:^4.20.6"
typescript: "npm:6.0.3"