この記事はAteam Finergy Inc. × Ateam Wellness Inc. Advent Calendar 2023の7日目の投稿です。
本日は@themeaningof8がお送りします。
エイチームフィナジーではCSSライブラリとしてTailwindCSSを主に運用しています。
TailwindCSS自体が制約ベースなので実装上の一貫性の担保はしやすいのですが、Figmaでのプロトタイプ作成時に幅や余白が手作業での管理となっていたので、今回Figma Variablesを定義するJSONファイルからTailwindCSSの設定ファイルを生成するスクリプトを作成しました。
やったこと
tokens.jsonをSingle Source Of Truth(信頼できる唯一の情報源)として管理し、Figma、TailwindCSSで使用できるようにしました。
手順
tokens.jsonの作成
Figma Variablesとしてインポートしたり、TailwindCSSの設定ファイルとして変換するためのJSONファイルを作成します。フォーマットはDesign Tokens Format Module形式を参考に作成しています。https://design-tokens.github.io/community-group/format/
また、この後のTailwindCSSで利用できるファイルに変換するために、以下のようなtypeに分類しています。
type | 内容 |
---|---|
color | HEXコードで記載。色のトークンを指定する際に使用。 |
number | 任意の数値を記載。特にremに変換しない数値のトークンに使用。 |
dimension | 任意の数値を記載。px→remに変換が必要なトークンに使用。 |
{
"colors": {
"gray": {
"100": {
"$value": "#FAFAFA",
"$type": "color"
},
"200": {
"$value": "#EEEDED",
"$type": "color"
},
"300": {
"$value": "#DFDDDD",
"$type": "color"
},
"400": {
"$value": "#C3C1C1",
"$type": "color"
},
"500": {
"$value": "#9B9897",
"$type": "color"
},
"600": {
"$value": "#7D7978",
"$type": "color"
},
"700": {
"$value": "#4E4B4B",
"$type": "color"
},
"800": {
"$value": "#343232",
"$type": "color"
},
"900": {
"$value": "#1A1919",
"$type": "color"
}
},
...
},
"maxWidth": {
"0": {
"$type": "dimension",
"$value": "0px"
},
...
},
"opacity": {
"0": {
"$type": "number",
"$value": "0"
},
...
},
}
TailwindCSSで利用できるファイルに変更する
今回TailwindCSSのユーティリティクラスとして以下のトークンを使用できるようにしました。
'borderRadius','borderWidth','colors','ex-colors','maxWidth','opacity','screens','spacing',
以下のスクリプトを実行するとTailwindCSSの設定で読み込むためのJSファイルが作成されるようになります。
今回はStyle Dictionaryを使用して作成しました。
import StyleDictionary from 'style-dictionary'
import type { Options, TransformedToken } from 'style-dictionary'
StyleDictionary.registerTransform({
name: 'size/pxToRem',
type: 'value',
matcher: (token) => {
return token.type === 'dimension'
},
transformer: (token: TransformedToken, options: Options) => {
function getBasePxFontSize(options: Options) {
return (options && options.basePxFontSize) || 16
}
const baseFont = getBasePxFontSize(options)
const floatVal = parseFloat(token.value)
if (floatVal === 0) {
return '0'
}
return `${floatVal / baseFont}rem`
},
})
StyleDictionary.registerTransformGroup({
name: 'css',
transforms: ['attribute/cti', 'name/cti/kebab', 'color/hsl'],
})
StyleDictionary.registerTransformGroup({
name: 'javascript',
transforms: ['attribute/cti', 'size/pxToRem'],
})
type TokenObject = Record<string, Record<string, string>>
StyleDictionary.registerFormat({
name: 'typescript/custom',
formatter: ({ dictionary }) => {
const result: TokenObject = {}
dictionary.allProperties.map((property: TransformedToken) => {
if (typeof property.attributes?.item === 'string') {
const type = property.attributes.type
if (type) {
result[type] = result[type] || {}
result[type][property.attributes.item] = property.value
}
} else {
result[property.name] = property.value
}
})
return `export default ${JSON.stringify(
result,
null,
2
)} satisfies Record<string, string | Record<string, string>> \n`
},
})
StyleDictionary.registerParser({
pattern: /\.json$|\.tokens\.json$|\.tokens$/,
parse: ({ contents }) => {
const output = contents
.replace(/["|']?\$value["|']?:/g, '"value":')
.replace(/["|']?\$?type["|']?:/g, '"type":')
.replace(/["|']?\$?description["|']?:/g, '"comment":')
.replace(/(\d)_(?=\d)/g, '$1.')
return JSON.parse(output)
},
})
const getDestination = ({
name,
extension = 'json',
}: {
name: string
extension: string
}) => {
return [name, 'tokens', extension].join('.')
}
const categories = [
'borderRadius',
'borderWidth',
'colors',
'maxWidth',
'opacity',
'screens',
'spacing',
] as const
type TokenCategoryType = (typeof categories)[number]
const getSdJsConfig = (category: TokenCategoryType) => {
return {
source: ['./tailwindcss/tokens.json'],
platforms: {
js: {
buildPath: './tailwindcss/tokens/javascript/',
transformGroup: 'javascript',
files: [
{
destination: getDestination({ name: category, extension: 'ts' }),
format: 'typescript/custom',
filter: (token: TransformedToken) => {
return token.attributes?.category === category
},
},
],
},
},
}
}
const getSdCssConfig = () => {
return {
source: ['./tailwindcss/tokens.json'],
platforms: {
css: {
buildPath: './tailwindcss/tokens/css/',
transformGroup: 'css',
files: [
{
destination: getDestination({ name: 'ex-color', extension: 'css' }),
format: 'css/variables',
filter: (token: TransformedToken) => {
return token.attributes?.category === 'ex-color'
},
options: {
outputReferences: true,
},
},
],
},
},
}
}
// Build default tokens from config
console.log('👷 Building default tokens')
categories.map((category: TokenCategoryType) => {
console.log(`\n👷 Building ${category} tokens`)
StyleDictionary.extend(getSdJsConfig(category)).buildAllPlatforms()
})
console.log(`\n👷 Building css tokens`)
StyleDictionary.extend(getSdCssConfig()).buildAllPlatforms()
スクリプトを実行すると、(以下はborderRadius)tokensディレクトリにtokens.jsonで指定したカテゴリのオブジェクトが生成されるので、こちらをTailwindCSSの設定ファイルで展開してあげます。
export default {
'0': '0px',
'2': '2px',
'4': '4px',
'8': '8px',
DEFAULT: '1px',
} satisfies Record<string, string | Record<string, string>>
tailwind.config.tsファイルに設定
生成されたtsファイルをtailwind.config.tsファイル内に展開してあげます。
import type { Config } from 'tailwindcss'
import borderRadius from './tokens/javascript/borderRadius.tokens'
import borderWidth from './tokens/javascript/borderWidth.tokens'
import maxWidth from './tokens/javascript/maxWidth.tokens'
import opacity from './tokens/javascript/opacity.tokens'
import screens from './tokens/javascript/screens.tokens'
export default {
content: [],
theme: {
extend: {
borderRadius,
borderWidth,
maxWidth,
opacity,
screens,
...
},
},
} satisfies Config
結論
デザインデータの作成の際にTailwindCSSと同じデザイントークンを使用することが担保されたので、デザインデータの作成者とコーダーが異なる場合でも制作での認識の齟齬が起こりにくくなりました。