🪡

ゼロランタイムのCSS-in-JSを作った話

2025/03/13に公開

モチベーション

使いたいのがなかった上に*.module.cssに煩わしさを感じていた。

筆者のこれまでのCSS経験

Tailwind CSS ⇨ styled-jsx ⇨ emotioin ⇨ Chakra UI ⇨ Metarial UI ⇨ CSS Modules

興味のあったCSS in JS

vanilla-extract・Stylex・Linaria

作成するにあたっての条件

・ゼロランタイム
・機能盛り盛りよりもミニミニな実装にしたい
・設定を煩わしくしたくないのでバンドラー専用プラグインを作りたくない
・補完が効く・リンターが使える
・React19 & Next.js サーバーコンポーネント対応
・JSX. TSX対応

作ったもの

linariaに触発されて亜種としてplumeriaという名前でオブジェクトで書けるCSS in JSを作りました。空いていたcinerariaと迷いましたがこちらは縁起が悪い可能性があるということで見送り、plumeriaに決定。

作っていて困ったこと考えたこと

ライブラリ自体の命名が難しく2回くらい引っ越してプルメリアという名前に決まりました。
機能の実装より機能を削ることを検討することが多く必要ない機能をとにかく削りました。

ライブラリ自体の機能

import { css, cx } from '@plumeria/core'

export const styles = css.create({
  header_main: {
    position: 'absolute',
    top: 0,
    zIndex: 1,
    width: '100%',
  },
})

こんな感じで、cssがNode.jsAPIのfsみたいにクラスなのでチェインさせて以下の4つのAPIがあります。

  1. create クラスネームを返り値とするオブジェクトを作成します
  2. global グローバルスタイルを直接オブジェクトで定義します
  3. defineThemeVars CSS変数として定義できるmediaやdata-themeを作成します
  4. keyframes MetaのstylexのkeyframesAPIを真似て作成しました、二極全く同じです
  • cx よくあるAPIなので既知であると思いますが、クラスネームや擬似クラス・要素を結合します。

インストール

Viteではデフォルトで動きますが今回は、Next.jsで使うことを前提に紹介します。
お好きなパッケージマネージャでインストール:

pnpm i @plumeria/core @plumeria/compiler @plumeria/next

Next.jsは開発環境でもSSRを行うため、ServerComponentsで動作させるためにlayout.tsxにplumeria/nextを設定していきます。

import { ServerCSS } from '@plumeria/next'
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <head>
        <ServerCSS />
      </head> 
      <body>
        <main>{children}</main>
      </body>
    </html>
  )
}

メディアクエリ

使い方はcss.media.max()でwidthかheightかを指定し数値を入れます。
関数なので配列で囲んでセレクタとして使用します。

import { css, cx } from '@plumeria/core'

export const styles = css.create({
  header_main: {
    [css.media.max('width: 900px')]: {
      position: 'absolute',
      top: 0,
      zIndex: 1,
      width: '100%',
    },
  },
})

擬似要素(クラス)

hoverやafterなどの擬似クラス、要素はセレクターのネストに制限を設けておりメディアクエリの中に1回だけ擬似要素は指定できますが、擬似要素の中にはメディアクエリをネストできないようにしています。コンパイラはこの挙動をよしなに捌きますが、これを型レベルで許してしまうと無限ネストになってしまうため、意図的に制限しています。

import { css, cx } from '@plumeria/core'

export const styles = css.create({
  link_container: {
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    width: 60,
    height: 40,
    [css.pseudo.hover]: {
      color: '#515151',
      textDecoration: 'underline',
    },
  },
})

コンパイル

npx css
npx css --log
npx css --type-check

その後に作ったもの

これだけだと型チェックがCSSの値に効かなくて、誤字ったときに間違ってコンパイルされてしまいます。そこでstylexjsのように自社で作るeslint-pluginを考えましたが、vanilla-extractやpigment-cssでも使えるように外部向けに作りました。ただcss.createのスコープに限定すれば余計なeslintignoreを書かなくて済むのは真ですが、汎用性という観点では他ライブラリで使えるリンターの方が役に立つでしょう。

ESLint plugin

eslint-plugin-object-css

  • recess-order
    recess式のソート(stylelint-config-recess-orderからリストをフォークで頂いています)

  • valid-value
    値の検証を行いエラーや警告を出します。1ヶ月掛けて作成しました。
    現行のCSSの397個がリストされています。

ドキュメント

plumeria.dev

副産物

zss-engineというシステムバックエンド寄りのライブライリを作成しました。
zero-runtime style sheet engineの略です。
これはもともとinternalとして切り離していたフォルダが外部向けに使えるように公開しています。
transpilerやinjectCSSなどの誰でもCSS in JSが作成できる関数が入っています。

今後の課題

  • メンテナーが自分一人だけ(理想的には是非メンバーを追加して行きたい)
  • Next.jsのSCの挙動が特殊なため今後追従して開発しなければならない
  • MetaやVercelがバックについている訳ではないため認知されないからと落ち込まないこと

自分で使ってみた所感

ESLintで型チェックをするのが良いです、ソートも機能するためcssをstylelintでソートしているようにセーブ時に並び替えられるので使っていてストレスを感じません。
CSS Modulesよりパフォーマンスが出るので仕事でも使うかも。

その他

例えばclassNameはスニペットを展開する時に文字列展開をしますがオブジェクト展開するようにVSCodeを設定できます、これはCSS Modulesやオブジェクト系のCSS in JSを使う上で必須の設定になります。

{
  // Place your snippets for javascript here. Each snippet is defined under a snippet name and has a prefix, body and 
	// description. The prefix is what is used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
	// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. Placeholders with the 
	// same ids are connected.
	// Example:
	// "Print to console": {
	// 	"prefix": "log",
	// 	"body": [
	// 		"console.log('$1');",
	// 		"$2"
	// 	],
	// 	"description": "Log output to console"
	// }
  "React className": {
    "prefix": "cn",
    "body": "className={$1}",
    "description": "React className with curly braces",
  }
}

あった方がいい機能や消した方がいい機能があれば随時コメントで教えてください。
宜しくお願いします😄

Discussion