ドキュメントサイトを保守していると仮定します。記事カードにはゆったりした内側の余白が必要で、バージョンの通知欄は少しコンパクトにしたい。それでも、余白の単位は共通にしたいところです。calc() を2回書き、一方は3倍、もう一方は2倍にすれば、まずは要件を満たせます。

その後、サイドバーにもカードを置くことになり、そこでは余白の単位を小さくしました。計算自体は簡単なままです。追いにくくなるのは、どこが同じ倍率の体系を使い、単位はどこから来て、ある変更をどの部品にも反映すべきかという取り決めです。

CSS @function を使うと、この計算に名前を付けられます。計算ルールを共有しながら入力は部品ごとに変えたいとき、ルールと入力を分けることで変更の責任が明確になります。 ただし、1回しか使わない掛け算に名前を付けるだけなら、スタイルを理解するまでの手間が増えることもあります。

通常の CSS でできることから始める

仮想のドキュメントサイトで使う HTML は次のとおりです。以降も同じクラスを使います。

<article class="site-card">
  <h2>インストールガイド</h2>
  <p>プロジェクトの最初のページから始めましょう。</p>
</article>
<aside class="site-notice">
  <h2>バージョンのお知らせ</h2>
  <p>更新前に変更履歴を確認してください。</p>
</aside>

カスタムプロパティで単位を共有し、それぞれの部品で計算します。

:root {
  --space-unit: 0.5rem;
}
 
.site-card {
  padding: calc(var(--space-unit) * 3);
}
 
.site-notice {
  padding: calc(var(--space-unit) * 2);
}

ルートの文字サイズが16pxなら、単位は8px、カードの内側余白は24px、通知欄は16pxです。単位を変えると両方が更新され、倍率は各部品のルールに残ります。

これだけでも十分に明快です。カスタムプロパティが値を渡し、calc() が計算を担っています。掛け算が2回登場することだけを理由に書き換える必要はありません。関数を検討したいのは、似たコードがあるからではなく、その計算を今後も一緒に保守する必要が出てきたときです。

@function で計算に名前を付ける

次は先ほどの CSS 全体を置き換える例です。最初は倍率と単位を明示的に渡し、呼び出し側から依存関係を読めるようにします。

:root {
  --space-unit: 0.5rem;
}
 
@function --space(--steps <number>: 1, --unit <length>: 0.5rem)
  returns <length> {
  result: calc(var(--steps) * var(--unit));
}
 
.site-card {
  padding: --space(3, var(--space-unit));
}
 
.site-notice {
  padding: --space(2, var(--space-unit));
}

関数名と引数名は -- で始まります。定義をトップレベルに置き、プロパティの値で直接 --space(...) を呼び出します。var(--space(...)) で包んだり、@apply を使ったりする必要はありません。関数内の var(--steps) と var(--unit) が引数を参照し、result が結果の値を指定します。関数の定義規則で、この構成を確認できます。

この関数は数値の倍率と長さの単位を受け取り、長さを返すという契約です。--space() は両方の既定値を使って0.5rem、--space(3) は1.5rem、--space(3, 6px) は18pxになります。<length> にパーセントは含まれません。型は実際に受け付けたい値に合わせて選びます。

型と既定値は省略できますが、ここでは何を渡すべきかを明確にするために指定しています。単位の既定値は関数自身が持つ0.5remです。--space-unit に追従させたいなら、カードのように明示的に渡します。ルートのトークンを0.75remに変えても、関数定義に書いた既定値まで書き換わるわけではありません。

カスタムプロパティが評価される場所で結果が変わる

ルートのカスタムプロパティに計算を書いても、子孫ごとにトークンを読み直す関数を保存したことにはなりません。

次の独立した CSS を先ほどの HTML に適用し、カードを確認してください。

:root {
  --space-unit: 0.5rem;
  --card-space: calc(var(--space-unit) * 3);
}
 
.site-card {
  --space-unit: 0.25rem;
  padding: var(--card-space);
}

ルートの文字サイズが16pxなら、カードの内側余白は 24px のままです。--card-space 内の var(--space-unit) はルート要素で解決され、その結果が子孫に継承されます。カード上で0.25remを指定しても、ルートにあった変数参照をさかのぼって解決し直すことはありません。カスタムプロパティの評価と継承の規則によるもので、変数が変化に反応しなくなったわけではありません。

関数を使わずに直すなら、カード自身に calc(var(--space-unit) * 3) を書けば十分です。多くの場所で同じ計算を使いながら定義を集中させたいときに、関数が役立ちます。

次は別のインターフェースです。倍率は引数で渡し、単位は呼び出し元の要素から読み取ります。依存関係の違いを混同しないよう、先ほどの --space() とは名前を分けています。

:root {
  --space-unit: 0.5rem;
}
 
@function --local-space(--steps <number>: 1) returns <length> {
  result: calc(var(--space-unit, 0.5rem) * var(--steps));
}
 
.site-card {
  --space-unit: 0.25rem;
  padding: --local-space(3);
}
 
.site-notice {
  padding: --local-space(2);
}

カードの内側余白は 12px、通知欄は引き続き 16px になります。関数内には --space-unit という引数やローカル変数がないため、呼び出し元の要素にあるカスタムプロパティを参照します。この関係は引数と変数のスコープ規則で定義されています。

書き方カードの単位の参照元この例の余白
ルートの --card-space を継承ルートで変数参照を解決したときの0.5rem24px
カードの単位を0.25remにして --space(3, var(--space-unit))呼び出し時の明示的な引数12px
--local-space(3)関数が読むカード上のトークン12px

明示的な引数は追跡やテストがしやすく、外部トークンの参照は呼び出しを短くする代わりに暗黙の依存を増やします。--local-space() をデザインシステムで提供するなら、--space-unit を読むことも利用規約に含める必要があります。同名の引数やローカル変数は外部の値を隠すため、意図しない名前の衝突にも注意します。

result は宣言であり、早期 return ではない

ここで仮の要件を追加します。ビューポート幅が40rem以下なら、両部品の余白を通常の4分の3にします。これはこのサイトの設計上の選択であり、スマートフォンの普遍的な定義ではありません。

次の完全な CSS では、ローカル変数に密度を保持し、条件に合う場合に上書きしてから、共通の式で結果を求めます。

:root {
  --space-unit: 0.5rem;
}
 
@function --responsive-space(--steps <number>: 1) returns <length> {
  --density: 1;
 
  @media (width <= 40rem) {
    --density: 0.75;
  }
 
  result: calc(var(--space-unit, 0.5rem) * var(--steps) * var(--density));
}
 
.site-card {
  padding: --responsive-space(3);
}
 
.site-notice {
  padding: --responsive-space(2);
}

ルートとブラウザーの既定文字サイズがともに16pxなら、800px幅では24pxと16px、600px幅では18pxと12pxになります。--density は関数内に留まり、要素上でほかの CSS が読めるトークンとして公開されるわけではありません。

この処理も宣言的です。result は JavaScript の return と異なり、次の関数は狭い画面の条件を満たしても常に1remになります。

@function --always-one() returns <length> {
  @media (width <= 40rem) {
    result: 0.75rem;
  }
 
  result: 1rem;
}

条件が成立した宣言は結果の選択に参加しますが、後にある result が上書きします。最初の結果で処理が終了するわけではありません。条件と実行モデルがこの違いを説明しています。基準値を先に、条件付きの上書きを後に置くか、前の例のように結果を1か所にまとめると読みやすくなります。

カードと通知欄が別々の幅条件に反応すべきなら、メディアクエリーは各部品側に残すほうが適切です。条件を共有することは、将来も一緒に変更するという約束を作ることでもあります。

型チェックは前の宣言へのフォールバックではない

まず、倍率に既定値を設けない関数で失敗を確認します。

@function --strict-space(--steps <number>) returns <length> {
  result: calc(var(--steps) * 0.5rem);
}
 
.site-card {
  padding: 24px;
  padding: --strict-space(red);
}

カスタム関数に対応したブラウザーでは、red は <number> に合わないため、呼び出しが算出値の段階で無効になります。padding は前の24pxに戻らず、初期値の 0 になります。必須引数を省略した --strict-space() にも利用できる倍率がありません。他のプロパティの結果は継承の有無にも左右されるので、すべてが0になるわけではありません。算出値の段階で無効になる値の規則が、カスケードをやり直さない理由を説明しています。

既定値がある場合は区別が必要です。先ほどの --space() は倍率の既定値を1にしています。引数の評価規則では型に合わない入力に対して既定値を試すため、--space(red) は0.5remになります。呼び出し全体が必ず無効になるわけではありません。既定値が入力ミスを隠す可能性もあるので、開発時には実際の結果を確認します。

型は値の種類を制限しますが、デザイン上の全制約を保証するものではありません。負数も <number> です。負の長さは margin には使えても padding には適しません。引数の有効範囲と、結果を使うプロパティの両方を考える必要があります。

何を一緒に保守したいかで選ぶ

これらの機能は組み合わせて使えます。今回なら、私はトークンと直接計算から始め、より多くの部品で計算ルールを同時に調整する必要が出てから関数に切り出します。

共有したいものまず検討する方法確認したい点
余白や色など上書き可能な値カスタムプロパティ宣言位置、継承、依存関係
単発の計算や範囲の制限calc()、min()、max()、clamp()直接書くほうが明快な場合が多い
入力を変えて使う共通計算ネイティブ @function引数、呼び出す要素、ブラウザー対応
ビルド時のデータ処理と CSS 生成Sass functionコンパイラーと入力データ
同じ宣言のまとまり共通クラスやセレクターのグループ化HTML とスタイルの構成
引数付きのスタイルブロックネイティブ @mixin 草案草案の成熟度とブロックの責任

Sass function はコンパイラーで実行され、ネイティブ CSS 関数はブラウザーのスタイル評価に参加します。Sass も var() や calc() を含む CSS を出力でき、その後の計算をブラウザーに任せられます。「Sass はすべて固定値で、ネイティブだけが動的」とは言えません。

@function が返すのは値です。border、padding、ネストしたセレクターをまとめて挿入する機能ではありません。ブロック単位の再利用については、関連記事のネイティブ CSS @mixinを参照してください。同じ仕様に含まれていても、実装の成熟度は同じではありません。

導入時は動作する基本スタイルを残す

確認日時点の互換性の元データは次のとおりです。これは機能の対応状況であり、利用率やこのサイトの閲覧者の分布ではありません。

ブラウザー@function の対応
Chrome/Chrome Android139から
Edge139から
Firefox未対応
Safari/iOS Safaripreview と記載。安定版に広く普及したとは扱わない

ブラウザーを問わず読めるサイトでは、動作する calc() を残し、追加部分を @supports at-rule(@function) で分けられます。冒頭の HTML にそのまま使える完全な CSS です。

:root {
  --space-unit: 0.5rem;
}
 
.site-card {
  padding: calc(var(--space-unit) * 3);
}
 
.site-notice {
  padding: calc(var(--space-unit) * 2);
}
 
@supports at-rule(@function) {
  @function --space(--steps <number>: 1, --unit <length>: 0.5rem)
    returns <length> {
    result: calc(var(--steps) * var(--unit));
  }
 
  .site-card {
    padding: --space(3, var(--space-unit));
  }
 
  .site-notice {
    padding: --space(2, var(--space-unit));
  }
}

これは控えめな段階的導入です。at-rule() の問い合わせ自体にも対応が必要で、関数を使えるブラウザーでも問い合わせを理解しなければ基本スタイルのままになります。この例はどちらも同じ見た目ですが、一時的に2つの計算を保守する負担があるため、導入効果と比較する必要があります。

@supports (padding: --space(3)) を、関数の存在や実行成功の証明には使わないでください。宣言を解析できることと、個々の呼び出しが成功することは別です。今回のテストでは、未定義の関数名にしても対応エンジンは true を返しました。at-rule() も規則の対応を調べるもので、すべての引数を検証するわけではありません。

サンプルの計算結果はローカルの Chromium 153.0.8010.12 と Playwright WebKit 26.6 で確認し、Chromium の CSSFunctions を無効にして基本スタイルも検証しました。Safari 安定版や Firefox の実機検証ではありません。Firefox はローカルでテストプロセスを起動できなかったため、表には前述の互換性データを用いています。

カードと通知欄に戻ると、集中させる価値があるのは、一緒に維持すると決めた計算ルールです。関数名がその取り決めを伝え、引数が利用箇所の違いを表すと、変更しやすくなります。1つの padding を理解するために、以前より多くの隠れたトークンをたどる必要があるなら、元の calc() を直接書くほうが適切かもしれません。