SuCSSというクラスレスCSSフレームワークを作った

SuCSS というクラスレスCSSフレームワークを作りました。
クラス名もカスタム属性も書かず、<header><article><button> といった素のHTMLを書くだけでスタイルが当たります。素(す)のHTMLにあてるので SuCSS です。

デモは https://ryotasugawara.github.io/su-css/ に置いてあります。npmにも公開しました。

SuCSSのデモページ。クラス名を一切使っていない見出しや本文が、すりガラスのパネルの上に並んでいる

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ryo9ra/su-css/dist-lib/sucss.min.css">

作り始めたきっかけは、技術的な話ではなくて生活のほうにあります。
いま育休中で、赤ちゃんを抱っこしている間に片手でできることを探していました。
そうなるとローカルに環境を作るところから始めるものは向いていなくて、クラウドだけで完結するものがいい。それで見つけたのが Google AI Studio でした。ちょうど AI Pro プランに課金していたので、使うぞと決めて使いました。

そこにクラスレスであること、ダークモード対応、アクセシビリティ、テーマはCSS変数だけ、という条件を投げました。

返ってきたコードは、動くし見た目も悪くないという意味では十分でした。
ただ、この後やったことのほとんどは新機能を足すことではなく、投げた条件をそのコードが実際には満たしていなかったので、満たすところまで持っていく作業でした。クラスは使っていないのに属性を覚えないと使えなかったし、アクセシビリティを掲げているのにコントラストはWCAG AAを割っていた。以下はだいたいその話です。

クラスレスを徹底する

最初の時点では、フレームワークのCSSにカスタムのdata属性がそこそこ入っていました。data-tabsdata-secondarydata-griddata-flexdata-badgedata-header-containerdata-footer-container の7種類です。

クラス名の代わりに属性名を覚えないと使えないなら、クラスレスを名乗る意味がないので全部消しました。
消すといっても機能を落としたわけではなくて、標準の属性に寄せ直しています。セカンダリボタンは data-secondary ではなく type="button"type="reset" で、バッジは data-badge ではなく mark / samp / output / [role="status"] で当たるようにしました。

/* Secondary / Action Button variants via standard type attribute */
button[type="button"],
button[type="reset"],
input[type="button"],
input[type="reset"] {
  background-color: var(--bg-subtle);
  color: var(--text-main);
  border: 1px solid var(--border-color);
}

data-griddata-flex のようなレイアウト用のものは、フレームワークが持つべきものではないと判断して落としました。デモのページで並べ方を指定したい場所は、そのHTMLに直接書いています。
結果、フレームワークに残ったカスタム属性は data-theme だけです。ここに兄弟を増やさない、というのを CONTRIBUTING.md に書きました。

モーダルは <dialog>、アコーディオンは <details>/<summary>、トグルスイッチは <input type="checkbox" role="switch"> で、どれもJavaScriptなしで動きます。

色は全部トークンにする

色はファイル冒頭のカスタムプロパティにまとめて、ルールの中に直接色を書かないようにしています。--hue の数値をひとつ変えるだけで、背景のグラデーションまで含めてページ全体の色味が変わります。

:root {
  --hue: 262;
  --sat: 85%;
  --color-primary: hsl(var(--hue) var(--sat) 44%);
  --radius: 1rem;
  --glass-blur: 20px;
}

ダークモードは prefers-color-scheme に追従して、data-theme="light" / data-theme="dark" で明示的に上書きもできます。
つまりトークンを宣言するブロックが、ライト、ダーク、強制指定の [data-theme] と複数に分かれます。ここで変数名をtypoすると、片方のテーマだけ静かに壊れて気づけない。

CSSをテストする

なのでテストを書きました。ブラウザで表示して見比べるのではなく、CSSのファイルそのものを読み込んで、書かれている値を機械的に検査するテストです。

ひとつはコントラスト比のテストです。文字色と背景色の組み合わせをテーマごとにCSSから拾い出して、それぞれが読める明暗差になっているかを計算します。基準はWCAGのAAで、文字と背景の比が4.5:1以上あることを求められます。テーマの宣言が3ブロックに分かれている以上、さっき書いた「片方のテーマだけ静かに壊れる」もここで一緒に落ちます。

書いた時点で、実際に基準割れが見つかりました。ボタンの背景色が、その上に乗る白い文字に対して、ライトで2.87:1、ダークで1.84:1しかない。4.5:1が要るところなので、かなり足りていません。アクセシビリティを掲げているスタイルシートが、一番目立つボタンで基準を割っていたわけです。ライトはボタンの背景を暗くして直しました。ダークのほうはリンクの色との兼ね合いで背景を明るいままにしたかったので、文字を白から暗い色に変えています。

もうひとつは、READMEで謳っている機能が本当にCSSに書かれているかを確かめるテストです。アニメーションを切る設定にしている人にはアニメーションしないこと、キーボードで操作したときにいまどこにいるかが見えること、ボタンや入力欄が指で押せる大きさを持っていること。こうした項目を、対応するルールがCSSに存在するかどうかで確認します。フォーカスの枠線を消しているのに代わりの表示を用意していないルールがあれば、そこで落ちます。

READMEに書いた主張が、書いただけで終わらない状態にしておきたかったので、この2つが一番書いてよかった部分です。

見た目を全面的に差し替えた

途中で、すりガラスのパネルが色のグラデーションの上に浮いているデザインに全面的に差し替えました。Claude Code に「こういう見た目にして」と頼んだだけで済んでいます。これはだいぶ楽だった。

現在の配布物は minify + gzip で 4.7KB、対応ブラウザは Chrome/Edge 111+、Safari 16.4+、Firefox 128+ です。

リリースまわりで踏んだもの

npmには、公開せずにいったん保留しておく仕組みがあります。CIができるのはそこに積むところまでで、実際に配布されるのは自分が2FAで承認してからです。CIが乗っ取られても、それだけでは利用者に手が届かない形にしたかったので、こうしています。

一番手こずったのは、リリース用のワークフローが起動した瞬間に落ちる現象でした。ジョブがひとつも動かないので、ログを見にいっても手がかりがほとんどありません。

GitHub Actionsでは、ワークフローから別のワークフローを呼び出せます。このとき、呼ばれる側は呼び出す側が渡した権限より強い権限を要求できません。リリース用のワークフローには、GitHubのリリースページを作るために書き込み権限を必要とするジョブがひとつだけあって、呼び出す側は読み取り権限しか渡していませんでした。
このジョブには「この経路では実行しない」という条件を付けてあったのですが、権限の検証はジョブが動き出す前に行われるので、条件が評価されるより先に実行全体が弾かれます。使いもしない書き込み権限を呼び出し元すべてに配るのは避けたかったので、そのジョブだけ独立したワークフローに切り出しました。

あとは、すでに公開済みのバージョン番号のままリリースを流してしまい、ビルドもテストも通ったあとの最後で You cannot publish over the previously published versions: 0.0.1 に落ちたのと、リポジトリ名を sucss から su-css に変えたら、デモサイトが古い名前でファイルを探しにいってGitHub Pagesが真っ白になったやつです。

ブログに適用する

作ったあと、このブログのCSSをBulmaからSuCSSに差し替えました。ユーティリティクラスを全部落として main / header / article / section に書き直しただけなので、差分はほとんど削除です。

適用してから見つかった問題がひとつあって、article の中に header 要素を置くとsticky指定のサイトヘッダと衝突するので、記事の中では header を使わないようにしました。フレームワーク側で直すか、使う側で避けるかは迷ったのですが、いまは避けています。

もうひとつ、このブログはビルド時に astro-purgecss をかけているので、クラスレスCSSがまとめて消えないようにsafelistを足す必要がありました。

purgecss({
  safelist: {
    standard: [":root", "html", "body"],
    greedy: [/data-theme/],
  },
}),

いま見えているこのページのスタイルがそれです。自分で使うものを自分で作ると、直したいところがそのまま次のコミットになるので、しばらくは0.xのまま触っていくつもりです。


🤖 Generated with Claude Code