🐙

CSS大解剖 14日目: 「カスケード」

に公開

本稿は、2024年3月頃に書き溜めていたシリーズです。最後まで温存させるのが勿体ないので、未完成ですがそのまま公開します(公開日: 2025/9/26)。そのため、内容の重複や記述方針の不一致があるかもしれませんが、ご理解ください。


CSSの仕様を理解するために、1日ごとにテーマを決めて説明する企画14日目です。今日のテーマは「カスケード」です。

カスケードとは

カスケードは、複数の矛盾するスタイル指定(プロパティ宣言)の中から、優先度の高いものを1つ選ぶという仕様のことです。Cascading Style Sheetの名の通り、カスケードはCSSの根幹をなす概念のひとつです。

ただし、CSSがプロパティの値を決定するためのプロセスはカスケード以外にもいくつかの要素から構成されています。

本稿では、カスケードを含め、CSSにおいてプロパティの値を決定するまでの流れ全体を扱います。

プロパティ

プロパティは要素や擬似要素にスタイルを指定するための値の入れもので、 color, font-size, width などの名称のことです。プロパティは大きく3種類に分けられます。

さらに、ユーザーエージェント定義プロパティは以下のように分けられます。

また、プロパティに類似のものとして記述子があります。たとえば @page 内に書けるプロパティのようなものは @page 記述子と呼ばれ、本稿で説明するカスケードに似た処理を受けます。

プロパティの決定手順

要素を描画するためには、各要素のプロパティの値を決定しなければなりません。プロパティの値は一発でポンと決まるわけではなく、大きく以下の6段階に分けられた手順を経て決定されます。

  1. はじめに、セレクターを評価することで、要素に適用可能な宣言を全て列挙します。この結果を宣言値 (declared value)といいます。宣言値は複数あることもあれば、1つもない場合もあります。
    • 正確に言うならば、このステップの出力は値そのもののリストではなく、それに対応する宣言のリストだと考えたほうがいいでしょう。
  2. 続いて宣言値に優先順位をつけ、最も優先度の高い1つだけを残します。この結果をカスケード値 (cascaded value)といいます。カスケード値は0個または1個あります。
  3. カスケード値がない場合や、カスケード値に特殊な指定が存在する場合には、所定のデフォルト値を評価します。この結果を指定値 (specified value)といいます。継承はデフォルトの一種です。
  4. 指定値を、プロパティごとに固有の方法で簡約したものを計算値 (computed value)といいます。たとえば、多くのプロパティでは、長さの相対指定をこの時点で解決するように指定されています。 calc() も、可能な範囲内でこのタイミングで解決されます。
  5. width: auto; など、実際のレイアウトを行うまで最終結果がわからない指定は多数存在します。これらの解決が済んだ値を使用値 (used value)といいます。長さの相対指定や calc() の中には、このタイミングまで評価が遅延されるものもあります。
  6. 使用値をWebブラウザの表示環境にあわせて補正した値を実値 (actual value)といいます。

ただし、この6段階の概念はあくまで値の計算の途中経過をわかりやすく分類したものにすぎず、実際には直線的に計算が進むわけではありません。CSSの実態をある程度考慮して手順を書き下すと、おおむね以下のようになると考えられます。

  1. パース時点で無効なプロパティを除外します。
  2. 有効な略称プロパティ宣言に対して、対応する擬似的な正称プロパティ宣言を生成します。この時点で対応する値が確定しない場合 (font プロパティのシステムデフォルト名が指定されている場合、フロー相対の略称プロパティの場合や中で var() が使われている場合) もあるので、このような場合は略称プロパティ宣言への内部的なリンクを生成するに留めておきます。
  3. コンテナクエリの処理のためのループ:
    1. 文書のルート要素によって作られるクエリコンテナから処理を開始します。
    2. 継承の処理のためのループ:
      1. クエリコンテナ内のルート要素から処理を開始します。
      2. プロパティ間依存処理のためのループ:
        1. 「全てのカスタムプロパティ・一括」を対象に処理を開始します。
        2. revert/revert-layerのためのループ:
          1. 全てのオリジン・全てのレイヤーが有効な状態から処理を開始します。
          2. セレクターを評価し、各正称プロパティの宣言値を列挙します。キーフレームの評価もこのタイミングで行います。
          3. 要素の書字方向を参照し、フロー相対正称プロパティの宣言値のリストを、対応する物理プロパティの宣言値のリストにマージします。
          4. 宣言値に優先順位をつけ、カスケード値を決定します。
          5. カスケード値がrevertまたはrevert-layerの場合は、レイヤーまたはオリジンを削ってステップ3-2-2-2-2からやり直します。それ以外の場合はデフォルト指定の処理を行い、指定値を決定しループを抜けます。
        3. 指定値に var() が含まれる場合はこれを展開し、パースを行います。パースに失敗した場合や、パース結果が明示的なデフォルト指定キーワードだった場合は、デフォルト処理を行います。カスタムプロパティ自身の var() の処理においてループが発生した場合は無効化処理を行います。
        4. 展開後の指定値と、親要素の情報をもとに計算値を決定します。
        5. 所定のプロパティの計算値がわかったので、他のプロパティについての計算のためにステップ3-2-2-2からやり直します。計算するべきプロパティがなければループを抜けます。このとき、プロパティの計算順にはいくつかの制約があります。 (以下は網羅的なリストではありません)
          • カスタムプロパティとアニメーション関係プロパティ (animation-*) は先に計算する必要があります。カスタムプロパティ・アニメーション関係プロパティ間の依存関係は固定されていないため、一括で処理してループの検出を行います。
          • color は、他の <color> を参照しうるプロパティよりも先に計算する必要があります。
          • font-size, font-family などのフォント関連プロパティや writing-mode, direction, text-orientation など文字の描画に影響のあるプロパティは、 font-size 等自身を除く他の <length> を参照しうるプロパティよりも先に計算する必要があります。
          • writing-mode, direction, text-orientation は、フロー相対プロパティとの対応関係を持つ物理指定プロパティよりも先に計算する必要があります。
      3. 全てのプロパティの計算値がわかったので、子要素についての計算を続けるためにステップ3-2-2からやり直します。これ以上処理するものがなければループを抜けます。
    3. レイアウトを行い、使用値実値を決定します。
    4. 子クエリコンテナの大きさが決定されたので、クエリコンテナの内部に入ってステップ3-2からやり直します。これ以上処理するものがなければ終了します。

TODO: プロパティの決定順について書く、var() が revert に解決された場合について書く

TODO: 例

TODO: Syntaxの章にValues and Unitsの話をつけ足す

TODO: revertまわりの処理のループ順を逆にして継承っぽく説明する

もちろん、上で示した手順はあくまで定義の依存関係を明確にするためのものであり、実際にWebブラウザが利用している高速なアルゴリズムはこれとは大きく異なる(しかし同じ結果をもたらす)手順で実装されていると考えられます。

それでは、個々のステップを詳しく見ていきましょう。

プロパティの構文解析

イントロ

宣言されたプロパティは、まず構文解析簡単な静的検査にかけられます。このときプロパティ値が**無効 (invalid)**と判定された場合は、そのプロパティ宣言は無視されます。CSSの前方互換性を担保するため、この挙動は厳格に定められています。たとえば、以下の例を考えます。

#hero {
  height: 100%;
  height: 100vh;
  height: 100svh;
}

ここで、 svh という単位を解釈できない古いWebブラウザは最後の宣言を無視します。残った宣言の中で最後に宣言された height: 100vh がカスケードで選択されることになります。

いっぽう、一見無意味な値であっても、構文上は有効として扱われる場合もあります。たとえば以下の例では、最後のプロパティ宣言は (calc() をサポートする十分に新しいWebブラウザでは) 有効です。

.meaningless {
  width: 100px; /* 有効 */
  width: -100px; /* 無効 */
  width: calc(-100px); /* 有効 */
}

最後のプロパティ宣言が有効であることから、 width: 100px; という指定はカスケードで上書きされてしまいます。

これは古いブラウザの対応だけではなく、ブラウザが設けている制限を検出してフォールバックするのにも使える可能性があります。たとえば、非常に長い calc()はWebブラウザごとに固有の制限により無効化される可能性がありますが、その場合はより短いプロパティを書いておくことでフォールバック処理とすることができます。

値の構文定義

さて、どのような値が有効であるかはプロパティごとに定められています。たとえば、以下はwidthプロパティの定義です。

図: widthプロパティの定義のスクリーンショット

このうち、値のフォーマットを定義しているのは以下の項目です。

この項目はValue Definition Syntaxというメタ構文によって記述されるルールになっています。上の例ではキーワードとして auto, min-content, max-content を受理するほか、 100px, 100%, calc(100px + 100%), fit-content(100px) などを受理することがわかります。

カスタムプロパティの場合は以下のようになります。

  • 未登録のカスタムプロパティは、ほとんど全てのトークン列を受理します。具体的には<declaration-value> という構文に従いますが、これはパースエラーによって生じる特殊なトークンを除外するほか、感嘆符記号 ! の出現を禁止しています。したがって以下は無効な宣言です。
    /* コア構文上は、最初の !important が値で2個目の !important は !important フラグを意味すると解釈される。 */
    /* しかし、 --foo の値として解釈しようとした時点でこの値は無効と判定される */
    --foo: !important !important;
    
    いっぽう、以下のような宣言は全て有効です。
    --foo: 100kg; /* 未知の単位 */
    --foo: #include <stdio.h>; /* 意味をなさないトークン列 */
    --foo:; /* 空 */
    
  • Houdiniの仕様に基づいて登録されたプロパティは、登録時にsyntax記述子により構文が指定されていますが、この段階では構文検査は行われません。この段階での挙動は、未登録の場合と同じです。
    • 構文指定に意味がないわけではなく、あとのほうで利用されます。

値の内部表現

構文解析された値は通常、Webブラウザ固有の内部表現に変換されます。たとえばChromium/Blinkの場合はCSSValueというクラスで、実質的には直和型を用いたツリー構造として表現されています

  • 長さなどの数値はUnitType型のnumeric_literal_unit_typeと、double型のnumの組み合わせで表現されます。
  • calc() などの数式呼び出しは、式の構造をそのままexpressionとして保持しています。

「値」という名前で呼ばれてはいますが、これらは概念的にはプログラミング言語における式のようなものとして考えたほうがいいでしょう。これらの値は、宣言された当初は calc(100% - 2em)auto などの複雑な式を許容しますが、本稿で説明するステップを経ることで段々と単純化していき、最終的には具体的な数値などレンダリングに必要な値が判明している状態にまで落とし込まれます。関数型言語の意味論に親しみのある人であれば、これは簡約ベースで定義された意味論のように考えるのがわかりやすいかと思います。

さて、値をこれらの内部表現に変換する過程で、すでにいくつかの情報は欠損しています。代表的なものとして以下のようなものがあります。

  • スペースの有無。
  • キーワードの大文字小文字。たとえば、 redRED の違い。
  • 数値がどのように表記されていたか。たとえば、 1.51.50 の違い。
  • 数値を浮動小数点数や固定長の整数で表現するにあたって欠落した情報。

CSSのレンダリングの過程で、宣言値の中間状態が直接露出することはないため、このタイミングでどれくらい値を簡略化するかは本来は大きな問題ではありません。しかし、CSSOMが宣言値をJavaScriptから取得する機能を備えているため、このAPIを通じて内部表現の表現精度が露出してしまいます。こういった事情から、CSSOMでは宣言値からどれくらいの情報が復元可能であるべきかをある程度規定しています

グローバルキーワードと var()

各プロパティの定義によらず常に有効とみなされる値が2種類あります。

1つ目はグローバルキーワードで、以下が知られています。

これらは各プロパティの定義よりも優先して解釈され、このタイミングでは必ず有効なものとして扱われます。これらの特別な値は、指定値の決定時に解釈されます。

.btn {
  /* borderの値の定義には含まれないが、特別な指定のため有効 */
  border: unset;
  /* initial はカスタムプロパティの値の定義にも合致するが、そちらではなく特別な指定として解釈される */
  --foo: initial;
}

2つ目はvar()が使われている場合です。この場合、以下のルールが適用されます。

var() の呼び出し形式の正しさは以下のように決まります。

  • var() 内の最初のトークン(空白を除く)が --foo のような形の識別子であること。
  • var() 内の2番目のトークン(空白を除く)が , であるか、または存在しない (1トークンのみ) こと。

たとえば、 var(--foo), var(--foo,), var(--foo, bar, baz) は正しい呼び出しですが、 var(foo)var() は不正な呼び出しです。このような不正な呼び出しはこの時点で無効とみなされます。

var() の呼び出しが1つ以上あり、それらの呼び出し自体が正しければ、他がでたらめでもこの時点では有効なプロパティとみなされます。たとえば以下の例では height の宣言はこの時点では有効です。

.separator {
  /* 意味をなさないトークン列だが、パース時点では有効と判定される */
  height: for(int i = len; var(--i) > 0;) printf("Hello!\n");
}

上の例にもあるように、 var() の出現位置は括弧にネストされた位置でもかまいません。よくあるのは calc() の中に出現する場合です。

なお、ChromeやFirefoxなどの実際の実装では !{} などが含まれると無効扱いされることがあるようです。

略称プロパティの処理

略称プロパティ (shorthand property) は、正称プロパティ (longhand property) に展開される特殊なプロパティです。宣言値・カスケード値・計算値など後続の処理は、すべて展開後の状態で処理されます

たとえば、以下の例の場合、 border の展開を行ってからカスケードが処理されます。結果、 border-color の値は red になります。

.foo {
  border: 1px solid black;
  border-color: red;
}

いっぽう、以下の場合は border-color の値は black です。

.foo {
  border-color: red;
  border: 1px solid black;
}

略称プロパティの主な目的は記述の簡略化ですが、 allfont など一部の略称プロパティには、単なる略記とは言えない代替不可能な役割があります。

指定なしの場合

略称プロパティは、その値にかかわらず、常に決まった数の正称プロパティに展開されます。言い換えると、指定のなかった部分についても必ず決まった値で上書きすることになります。

たとえば以下のプロパティ宣言を考えます。

border-top: solid black;

これは現時点では以下と等価です

border-top-color: black;
border-top-style: solid;
/* 指定がないため、border-topの規定に従い初期値にリセットされる */
border-top-width: medium;

「現時点では」と書いたのには理由があります。CSSに新規プロパティが導入されるタイミングに限り、後方互換性を保ちながら略称プロパティを拡大することができるからです。たとえば、将来ボーダーに触覚効果を追加する border-top-touch-effect が追加されたとします (あくまで想像上の例です)。それと同時に、 border-top を以下のように展開するように仕様を拡張します。

border-top-color: black;
border-top-style: solid;
/* 指定がないため、border-topの規定に従い初期値にリセットされる */
border-top-width: medium;
/* 指定がないため、border-topの規定に従い初期値にリセットされる */
border-top-touch-effect: none; /* 想像上のプロパティ */

これは後方互換性が保たれています。というのも、それ以前のWebページは border-top-touch-effect を利用していませんし、このプロパティの導入後に書かれるWebページは、はじめから border-top がこのように展開することを前提に組まれるからです。

なお、常に決まった正称プロパティに展開することだけが要件であり、それら全てを自由に操作できる必要はありません。たとえば、 border 略称プロパティは border-image-* を常にリセットしますが、 border 内には border-image-* の値を指定する手段があるわけではありません。

all

allは特殊な略称プロパティで、 directionunicode-bidi を除く全てのUA定義の正称プロパティに展開されます。

all は、いかなる値も受理しないように定義された略称プロパティであると言えます。言い換えると、このプロパティにはグローバルキーワードである initial, inherit, unset, revert, revert-layer のみを書くことができます。略称プロパティのグローバルキーワードは展開後に解釈されるため、これは以下のように全てのプロパティに同じキーワードを指定したのと同等だと言えます。

/* all: unset; */
align-content: unset;
align-items: unset;
align-self: unset;
animation: unset;
animation-delay: unset;
animation-direction: unset;
animation-duration: unset;
animation-fill-mode: unset;
animation-iteration-count: unset;
animation-name: unset;
animation-play-state: unset;
animation-timing-function: unset;
azimuth: unset;
/* ... */

とはいえ、定義されているCSSのプロパティは増え続けているので、Web作者の視点からはこれは展開後のコードと等価とは到底言えないでしょう。増え続けるプロパティ全てに対応できることから、 all は主に、ユーザーエージェントスタイルシートやコンポーネント境界の外で定義されたスタイルをリセットするために利用されます。

directionunicode-bidi が除外されているのは、これらが本来はCSSではなくHTMLなどのマークアップ言語の範疇の機能だからです。これらはHTML以外の、Webブラウザがサポートしていない文書形式のスタイルを行うための特殊機能であるため、HTML文書のスタイル指定においてはこれらのプロパティを上書きしてはいけません。

他にも、リセット後の挙動に注意が必要なプロパティがいくつかあります。以下にいくつかを例示します。

  • display: inline がデフォルトです。
  • cursor: auto がデフォルトですが、これは対象要素の種類によって挙動が変わります。
  • outline をはじめとして、アクセシビリティ用のスタイル指定の多くが無効になるため、これらの代替を適切に実装する必要があります。

font

font はいくつかの font-* プロパティの略称ですが、システム設定を参照する指定は font プロパティを通じてのみ行えます。そのため、 font は必ずしも展開後の指定で代替できません。

略称プロパティが var() を含む場合

略称プロパティが var() を含む場合は、その場で展開結果を計算することができません。この場合でも略称プロパティを仮想的に展開し、置換待ちの値 (pending-substitution value) という特殊なサンク値を保持することで後続のカスケードやデフォルトの処理を行えるようにします。

「置換待ちの値」はWebブラウザで内部的に保持される値であるため、対応するCSSの構文はありません。擬似コードで説明すると、以下のような感じです。

たとえば、以下のようなコードを考えます。

border-top: var(--blen) var(--bsty) var(--bcol);

一見するとこれは border-top-width: var(--blen) のような対応関係があるように思えますが、それは名前からのヒューリスティックスでそのように推定できるだけで、予想に反して var(--blen) には black のような値が入っている可能性も排除できません。ですから、Webブラウザはそのような展開は行わず、仮想的に以下のようなサンクを生成します。

/* __LONGHAND__() は擬似コード */
border-top-color: __LONGHAND__(border-top, border-top-color, var(--blen) var(--bsty) var(--bcol));
border-top-style: __LONGHAND__(border-top, border-top-style, var(--blen) var(--bsty) var(--bcol));
border-top-width: __LONGHAND__(border-top, border-top-width, var(--blen) var(--bsty) var(--bcol));

フロー相対略称プロパティ

padding, margin など、いくつかの略称プロパティは、その物理的な方向にもとづいて正称プロパティに展開されます。

/* 略称による指定 */
padding: 1px 2px 3px 4px;

/* 展開後 */
padding-top: 1px;
padding-right: 2px;
padding-bottom: 3px;
padding-left: 4px;

これらのフロー相対な亜種がCSS Logical Properties and Values内で提案されています。ただし、この規定は例外的に不確定な部分としてCSS Logical Properties and Valuesの現行バージョンの正式な定義から除外されています。したがって、ここで説明する記法は暫定的なものであるという点に留意してください。

現行の提案されている構文では、以下のように logical をつけることでフロー相対に解釈されることになっています。

/* 略称による指定 (暫定的な構文) */
padding: logical 1px 2px 3px 4px;

/* 展開後 */
padding-block-start: 1px;
padding-inline-start: 2px; /* 現行暫定構文の規定ではltr時に物理指定と逆になる */
padding-block-end: 3px;
padding-inline-end: 4px; /* 現行暫定構文の規定ではltr時に物理指定と逆になる */

もしこの構文で実装される場合、以下のような場合分けが発生します。

  • 値が var() を含まない場合、フロー相対プロパティへの展開は容易です。
  • 値が var() を含む場合、それが物理プロパティに展開されるのか、フロー相対プロパティに展開するのかは一見するとわかりません。しかし、フロー相対プロパティに展開されたとしても、あとの作業で必ず4方向全ての物理プロパティの宣言リストに1つずつマージされることは保証されています。そのため、この時点では物理プロパティに対して「置換待ちの値」をサンクとして生成しておき、あとで展開時にlogicalが指定されていたらその時点でのwriting-mode/directionプロパティの値に基づいて射影方向を選択するということは可能です。

TODO: 規格ではcomputed valueでこの処理を行っているのでそのへんの辻褄合わせ

レガシー別名とレガシー略称

略称プロパティのうち、後方互換性のために単一の正称プロパティに射影されるものをレガシー別名/レガシー略称といいます。

  • レガシー別名 はこのような略称プロパティのうち、値を変換しないもののことです。
  • レガシー略称 はこのような略称プロパティのうち、値の変換をともなうもののことです。

目的こそ違えど、その振る舞いは一般の略称プロパティと変わりません。ただし、これらはよりシンプルであることから、一般の略称プロパティよりも簡易的に実装される可能性があります。

宣言値

ここまででようやく、各要素のプロパティ値を計算する準備ができました。

宣言値は、その要素に該当する宣言を全て集めてきたものです。これにはたとえば、以下のような(自然な)条件が加味されます。

  • その宣言を含むスタイルシートや、その祖先が有効であること。
  • その宣言に紐付けられた @media クエリやスタイルシート全体の media 条件が満たされていること。
  • その宣言に紐付けられた @supports クエリが満たされていること。
  • その宣言に紐付けられた @container クエリが満たされていること。
  • その宣言を含むスタイルブロックのセレクターが、その要素に該当すること。
  • その宣言が無効な宣言ではないこと。

継承はこれよりも後のステップで処理されるので、ここではまだ考えません。あくまで要素自身のことを考えればよく、その祖先要素に当てられたスタイルは考慮しません。ただし、セレクターでDOMの構造に関する結合子が指定されている場合は、そのルールは規定通りに適用します。

宣言値という名称で呼ばれているものの、この時点では技術的には値ではなく宣言そのものを保持していると考えるのが自然です。実は後述するように、少なくとも計算値を導出するまでは宣言との関係を保持しておく必要があります。

この時点で略称プロパティの処理は完了しているとみなせるため、以降では略称プロパティの宣言値を考える必要はありません。

フロー相対プロパティの宣言値のマージ

padding-top など、特定の方向に紐付くプロパティの値を計算するにあたっては、対応するフロー相対プロパティの宣言値をこのタイミングでマージしておきます

たとえば、以下の例を考えます。

padding-top: 2px;
padding-block-start: 4px;

この場合、横書きモードにおける padding-top の宣言値は 2px4px の2つですが、縦書きモードの場合は 2px の1つのみです。この分岐ではその要素自身のwriting-mode プロパティと direction プロパティ (加えて、この使用値に影響を与える text-orientation プロパティ) を参照するため、先にこれらのプロパティの計算を済ませておく必要があります。 (もちろん、実際の実装では必要になってから計算するような形式でもかまいません)

スコープ

@scope 等を使ってスコーピングされた宣言は、実際には単一の宣言ではなく、スコープルートによってパラメーター化されていると考えられます。この場合は、宣言への参照だけではなく、スコープルート要素への参照を保持しておく必要があります。 (あるいは、単にスコープ近接性を整数列で保持するだけでも十分です)。これについてはカスケードの節で説明します。

アニメーション宣言 (@keyframes)

@keyframesの評価もこのタイミングで行われます。これはおおむね以下のステップで行われます。

  1. animation-* プロパティアニメーション不可であるため、この時点でその計算値が決定されていると仮定できます。
  2. これをもとに @keyframes の内容を参照し、関連するキーフレーム(一般的に2個ある)を特定します。
  3. キーフレーム内のプロパティ宣言の計算値を求めます。
    1. 宣言は1つしかないので、宣言値は明らかです。
    2. 宣言は1つしかないので、カスケード値は明らかです。
    3. TODO

Transition宣言

TODO

カスケード値

カスケードは宣言値からカスケード値を得るステップです。これは一言で言えば、宣言値同士を所定の基準で比較し、最も優先されるべき宣言を採用するという処理です。この比較基準をカスケード順といいます。

なお、宣言値がひとつもない場合は、カスケード値はありません。言い換えると、宣言値がひとつもない場合のカスケード値は unset であるとみなされます。

カスケード順のルールは年々複雑化していますが、まずは古典的なカスケード順を理解しておきましょう。

  1. まず、オリジンと重要度を比較します。
    1. ユーザーエージェント (Webブラウザ) による !important 宣言 ↑強い
    2. ユーザーによる !important 宣言
    3. ページ作者による !important 宣言
    4. ページ作者による通常宣言
    5. ユーザーによる通常宣言
    6. ユーザーエージェント (Webブラウザ) による通常宣言 ↓弱い
  2. オリジンと重要度が同じ場合で、どちらかが style 属性に由来している場合は、それが優先されます。
  3. オリジンと重要度が同じ場合で、いずれも style 属性に由来する場合は、宣言に対応するセレクターの詳細度を比較します。詳細度が大きいほうが優先されます。
  4. オリジンと重要度が同じで、セレクターの詳細度も同じ場合は、出現順を比較します。後に出現するほうが優先されます。

現代ではこれが以下のように拡張されています。

  1. オリジンと重要度
    1. CSS Transitions由来の仮想的な宣言 ↑強い
    2. ユーザーエージェント (Webブラウザ) による !important 宣言
    3. ユーザーによる !important 宣言
    4. 文書の作者による !important 宣言
    5. CSS Animations由来の仮想的な宣言
    6. 文書の作者による通常宣言
    7. 文書の作者による表現ヒント
    8. ユーザーによる通常宣言
    9. ユーザーエージェント (Webブラウザ) による通常宣言 ↓弱い
  2. カプセル化文脈。シャドウDOMの階層において、外側の文脈のほうが優先される。 !important では逆順。
  3. style 属性の優先
  4. レイヤー。最後のレイヤーが優先される。 !important では逆順。
  5. 強スコープ近接性 (Cascading and Inheritance 6)。当該要素からスコープへの木上の距離が近いほうが優先される。現時点では、強スコープ近接性と弱スコープ近接性のどちらが採用されるかは未確定。
  6. 詳細度。詳細度の大きいほうが優先される。
  7. 弱スコープ近接性 (Cascading and Inheritance 6)。当該要素からスコープへの木上の距離が近いほうが優先される。現時点では、強スコープ近接性と弱スコープ近接性のどちらが採用されるかは未確定。
  8. 出現順。後に出現したほうが優先される。

オリジン

オリジンは、そのスタイルを誰が指定したかをあらわす概念です。古典的なオリジンは以下の3種類です。

  • 文書の作者 (author) はHTML文書の提供元です。HTML文書から <link><style> で参照されるスタイルにはこのオリジンが割り当てられます。
  • ユーザー (user) はWebブラウザを利用するユーザーです。ユーザーはWebブラウザの組み込み機能や拡張機能[1]を使ってスタイルをカスタマイズすることが許されています。このような機能を使って当てられるスタイルにはこのオリジンが割り当てられます。
  • ユーザーエージェント (user agent) は、平たく言えばWebブラウザのことです。

これらはスタイルの提供者と利用者の関係にあるといえます。Webブラウザは <h1><button> などの要素を、適切なスタイルが当てられた状態で提供してくれます。ユーザーや文書の作者は、このデフォルトスタイルの恩恵を受ける側というわけです。いっぽう、ユーザーと文書の作者の関係はいささか恣意的とも言えます。現代においてはユーザースタイルは文書のスタイルを上書きする意図で使われることが多いため、CSS1当時に決定されたこの仕様はいささか不便かもしれません。

上記の3種類以外の特別なオリジンとして以下があります。

  • CSS Transitions由来の仮想的な宣言。これは、プロパティの古い値と新しい値の混合値です。現在CSSの中で最も強いオリジンであり、 !important も突き抜けます。
    • CSS Transitionsの値は、元々カスケードに勝った値同士の混合なので、これが優先されることは合理的です。
  • CSS Animations由来の仮想的な宣言。これは @keyframes 内で宣言された値の混合値です。通常の (!important のついていない) 宣言の中で最も強いオリジンとして扱われます。
  • 文書の作者による表現ヒント

文書の作者による表現ヒントについてもう少し説明してみます。CSS登場以前には、HTMLがスタイル指定の役割を担っていました。その中には、要素の属性値を用いて直接的にスタイルを指定するもの (表現ヒント) がいくつか存在しました。

<!-- 古いHTMLの記述 -->
<body topmargin="30" background="https://example.com/bg.jpg">
<table width="80%" height="80%" cellspacing="2" cellpadding="2" border="1">
<hr width="50%">
<pre wrap>
<center>
<div align="center">
<br clear="left">
<font face="sans" color="red" size="4">
<ol type="a">

全ての表現ヒントが非推奨というわけではなく、 <img> のwidth/height は現在でも有効な表現ヒントです。また、SVGの表現属性は非推奨というほどではないものの、現在はCSSによる指定が推奨されています。

こうした表現ヒントは、文書の作者の意図をWebブラウザが代理で適用しているため、そのオリジンについては議論の余地があります。これについてCSSは以下の2つの選択肢を提供しています。

  • Webブラウザが適用したものとみなし、ユーザーエージェントオリジンに含める。
  • 文書の作者が適用したものとみなす。ただし、CSSによる定義よりは低い優先度にしたいため、本来の作者オリジンとは別の特殊なオリジンを割り当てます。これが 「文書の作者による表現ヒント」オリジン です。

HTMLやSVGの表現ヒントは後者です。つまり、これらは作者オリジンに準じる扱いを受けます。これは以下のことを含意しています。

  • 表現ヒントに由来する宣言は、ユーザーオリジンの通常の宣言よりも優先される。
  • 表現ヒントに由来する宣言は、作者オリジンでの revert 指定により巻き戻される。

!important

スタイル宣言に !important をつけると、以下の2つの効果が発生します。

  • その宣言は、!important のない通常の宣言よりも優先されるようになる。
  • !important 宣言間でのオリジンの優先度が逆転する。カプセル化文脈とレイヤーの優先度も逆転する。

この2つの効果はいずれも、通常では負けてしまうスタイルをどうしても勝たせたいという背景から説明できます。

なお後者のルールは元々CSS1にはなく、CSS2で導入されたもののようです。おそらく、無節操に !important を使う文書のせいでユーザースタイルが適用できないというような問題が発生して修正されたのではないかと思います。

なお、 !important は強力ですが、CSS Transitionsには勝てません。

シャドウDOMのカプセル化文脈

CSSは文書本体ではなく、シャドウDOMにおけるシャドウ木内で宣言されることがあります。この場合にはカプセル化文脈 (encapsulation context) による優先度が加味されます。

シャドウDOMは、Web Componentsと呼ばれる仕様群のひとつです。Web Componentsは、Reactのコンポーネントのような再利用可能なUIコンポーネントをWebプラットフォームに固有の機能だけで実現する仕組みであり、シャドウDOMはその中で、コンポーネントの呼び出しをその実装に展開するための仕組みを提供しています。

シャドウDOMの登場により、DOMツリーには展開前の木構造と、展開後の木構造 (平坦木 (flat tree)) の2通りの解釈が与えられるようになり、状況に応じてどちらが使われるのかを意識する必要が出てきました。

そして、シャドウDOM内で書かれたCSSには、以下の制約が発生します。

  • 特別なセレクターを使わない場合、その影響範囲は展開前の木構造における同じ木の中に限定される。
  • 特別なセレクターを使っても、その影響範囲はシャドウDOMの呼び出し元 (シャドウホスト) を起点とした平坦木上のサブツリーに限定される。

後者の制約を要素を起点に言い換えてみます。すると、ある要素にマッチする宣言は全て、その要素の平坦木上の祖先(自分自身を含む)にぶら下がっているシャドウ木内のCSSと、文書全体のCSSに由来するスタイルに限られるということができます。

一般的には木上の祖先/子孫関係は比較可能とは限りませんが、上のような性質を加味すると、ある要素に関係するCSSは、そのルート(文書またはシャドウルート)の祖先/子孫関係に関して弱い全順序をなすことがわかります。

シャドウDOMが関与するカスケードでは、この順序に関して、より外側のルートに適用されたスタイルが優先されます。ただし、 !important のついたルールでは順序が逆転します。

このルールは、オリジンによる順序と同様の考え方に基づいています。つまり、シャドウDOMの内側はコンポーネントを提供する側であり、外側はコンポーネントを利用する側なので、原則は提供する側が勝つというわけです。

style 属性の優先

要素のスタイルは、スタイルシートの記述以外に、要素の style 属性 の指定の影響を受けます。 style 属性はスタイルシートの記述より優先されます。

古い仕様書では、このことを「style 属性は最も高い詳細度を持つ」というような形で説明していることがあります。カスケードのルールが複雑化した現代においては、style 属性を詳細度と切り離して説明するほうが明確でよいでしょう。

!important はこのルールを逆転させまん。 !important なルール同士でも、 style 属性が優先されます。

オリジンとカプセル化文脈

style 属性は文書の作者オリジンに属するものとして扱われます。また、当該要素が所属する文書またはシャドウルートに対応するカプセル化文脈に属します。したがって、 style 属性内の通常の宣言はより外側の文脈のスタイルシートの通常の宣言に負けることがあります。また、 style 属性内の !important な宣言も、より内側の文脈のスタイルシートの !important な宣言に負けることがあります。

style 属性内の順序

同じ style 属性内ではオリジン・カプセル化文脈・レイヤー・スコープ近接性・詳細度は意味をなしません。そのため、style 属性内の比較で意味をもつのは !important と出現順のみです。

style 属性のパース後は、同じプロパティに対する複数の宣言を保持する理由はないため、不要な重複は削除されます。これにより style 属性内でのプロパティ宣言の出現順はほとんど意味をなさなくなったように見えますが、実際にはフロー相対プロパティと対応する物理プロパティの前後関係を保持する必要があります。CSSOMでは、この順序をitemメソッドによって観測可能です。

レイヤー

イントロ

レイヤーもWeb Componentsと同様に、コードの共通化と再利用を念頭に置いた仕様です。Web Componentsがマークアップ・スクリプト・スタイルの3つをセットでカプセル化するのに対して、レイヤーはスタイルシート単独での階層化を行う軽量な仕組みであることがポイントです。

レイヤーの考え方は一言で言えば、スタイルをスタイルシート間の依存関係に基づいてカスケードをしようというものです。とはいえ、スタイルシート間の依存関係はすでに @import at-rule という仕組みで実装されてしまっているので、この機能との互換性を壊さないようにしつつも、同時に利用することで効果を発揮しやすいような形で実装されています。

インライン 外部ファイル
平等にカスケード
(レイヤーを生成しない)
その場に記述 @import url(default.css);
依存関係を考慮してカスケード
(レイヤーを生成する)
@layer default { ... } @import url(default.css) layer(default);

レイヤーとは

レイヤーは同じ強さでカスケードされるスタイルシートの集まりです。レイヤー間には依存関係があり、これにより木構造が作られます。これはnpmなどのパッケージマネージャーが作る依存グラフと近いものですが、単なるDAGではなく木構造とみなせる点が異なります。

レイヤーを作る方法は4つあります。

  • 通常の方法で宣言されたスタイルシートは、自動的にルートレイヤーに分類されます。
  • 子レイヤーを作る:
    • @import at-ruleの layer() オプションにより辺を宣言し、読み込んだスタイルシートをそこに分類する。
    • @layer at-ruleにより辺を宣言し、インラインでスタイルシートを記述する。
    • 空の @layer により辺を宣言する。

レイヤーの依存ツリーの辺には名前がつけられています。たとえば上の例で @layer default { ... } と書いた場合の default は辺の名前をあらわしています。名前を省略すると匿名レイヤーが生成されますが、これはいわば @layer __123__ { ... } のように不可視な名前が内部的につけられたような挙動をします。

レイヤーの順序

さて、レイヤーの目的は依存関係に基づく順序をつけることですが、カスケードの規則として定式化するには全ての組み合わせが比較可能である (弱い全順序である) 必要があります。そこで、レイヤーの順序が定義されます。

レイヤーの順序は、ルートレイヤーから深さ優先探索を行ったときの帰りがけ順です。たとえば、以下の例を考えてみます。

@layer foo {
  @layer bar {
  }
}
@layer baz {
}

この場合、レイヤーの順序は foo.bar, foo, baz, ルートの順です。

辺の順序

深さ優先探索の順序は、辺の順序に依存します。これはスタイルシート内で最初にその辺の名前が宣言されたタイミングの順でソートされます。上の例の順序を変更して

@layer baz {
}
@layer foo {
  @layer bar {
  }
}

とするとレイヤーの順序は入れ替わり、 baz, foo.bar, foo, ルートの順に並ぶことになります。

レイヤーの順序を明示的に制御するための構文として空の @layer at-ruleが用意されています。たとえば上の例は以下のように書き換えられます。

/* 先にレイヤー順序を宣言 */
@layer baz, foo;

/* レイヤーの中身を実装 */
@layer foo {
  @layer bar {
  }
}
@layer baz {
}

レイヤーによるカスケード順

レイヤーの順序はコード間で提供者(ライブラリ側)と利用者(アプリケーション側)の関係を表したものといえるため、オリジンと同様の方法で優先度が決められます。つまり、

  • 通常の宣言においては、レイヤー順で後ろのレイヤーに所属する宣言が優先されます。
  • !important 宣言では、順序が逆転します。

スコープ近接性

スコープ近接性 (scoping proximity)Cascading and Inheritance Level 6で提案されている概念で、名前の通りスコープに基づいて優先度を判定します。

スコープ近接性には2種類の異なるモードが提案されており、どちらを採用するかは現時点では未確定です。

  • 強スコープ近接性 (strong scoping proximty) はセレクター詳細度よりも優先してスコープ近接性を比較するモードです。
  • 弱スコープ近接性 (weak scoping proximty) はセレクター詳細度が同じだった場合にのみスコープ近接性を比較するモードです。

スコープ

スコープはスタイル指定の有効範囲をDOM内の特定範囲に限定する機能です。

シャドウDOMでも同じような効果がありますが、それをシャドウDOMの構造によらずに柔軟に使えるようにしたのがスコープの仕組みだといえます。

スコープによる有効範囲の限定には2つの効果があります。

  • スコープルートと呼ばれる要素を1つ指定することで、その要素の子孫(自身を含む)にのみマッチするようになります。
    • シャドウDOMにおいて、シャドウホストや上位要素を参照できない仕組みと対応しています。
  • スコープ限界と呼ばれる要素を0個以上指定することで、それらの要素の子孫(自身を含む)を除外できます。
    • シャドウDOMにおいて、スロットを置換する要素を参照できない仕組みと対応しています。

スコープ近接性

スコープは詳細度の計算に寄与しません。かわりに、スコープ近接性 (scoping proximity) という値を計算することで優先度を決定します。

スコープ近接性とは名前の通り、選択された要素がスコープルートにどれくらい近いかをあらわす整数です。たとえば以下の例を考えます。

<div class="foo">
  <div class="bar">
    <div class="baz">
    </div>
  </div>
</div>
@scope (.baz) {
  .baz {
    color: red;
  }
}
@scope (.bar) {
  .baz {
    color: blue;
  }
}
@scope (.foo) {
  .baz {
    color: black;
  }
}

この場合、 .baz 要素は全ての宣言にマッチしますが、それぞれのスコープルートは異なります。

  • 最初の宣言は .baz 自身がスコープルートなので、スコープ近接性は0です。
  • 2つ目の宣言は .baz の親 (.bar) がスコープルートなので、スコープ近接性は1です。
  • 最後の宣言は .baz の親 (.foo) がスコープルートなので、スコープ近接性は2です。

この場合、スコープ近接性が一番小さい color: red が勝ちます。

スコープ限界はカスケードの判定には使われません。

ネストしたスコープの近接性

スコープは2つ以上ネストさせることができます。また逆に、通常の宣言は、0個のスコープの入れ子だと解釈できます。このような場合は、まずマッチした要素と直近のスコープとの距離を計算し、直近のスコープからそのすぐ上位のスコープの距離を計算し、以降同様に上位のスコープの距離を計算していきます。処理するべきスコープがなくなったら、残りはずっと ∞ が入っているものとして扱います。つまり、通常の宣言は [∞, ∞, ∞, ∞, …] というスコープ近接性を持ち、ネストしていないスコープは [n, ∞, ∞, ∞, …] というスコープ近接性を持ちます。これを辞書順で比較します。

特に、スコープされた宣言は通常の宣言よりもスコープ近接性の観点から優先されることになります。

複数のスコープが該当する場合

同じ要素に同じ宣言が、複数のスコープを経由して該当する場合もあります。たとえば以下の例を考えます。

<div id="e1" class="foo">
  <div id="e2" class="bar">
    <div id="e3" class="foo">
      <div id="e4" class="baz">
      </div>
    </div>
  </div>
</div>
@scope (.foo) {
  .baz {
    color: red;
  }
}

この場合、内部のセレクターの実行やカスケード処理の観点ではスコープルートの数だけ宣言が複製されたものとして扱います。つまり、以下のように展開されたと解釈します。

@scope (#e1) {
  .baz {
    color: red;
  }
}
@scope (#e3) {
  .baz {
    color: red;
  }
}

この場合はスコープ近接性が1の宣言と3の宣言があることになります。近接性が高いほうだけが残されたと考え、全体の近接性は1だとみなしてもいいでしょう。

シャドウDOMによるカプセル化文脈との違い

すでに説明したように、カプセル化文脈とスコープにはアナロジーが成立しますが、技術的には全く別の機能です。細かい部分でも色々と動作が異なります。

  • カスケードの適用順序。
  • カプセル化文脈では上位の文脈ほど優先されますが、スコープは要素がより下位の位置にあるときに優先されます。また、スコープの優先順序は !important 内でも逆転しません。
    • この意味では、スコープの優先度の考え方は詳細度の考え方 (より詳細な指定が勝つ) に近く、カプセル化文脈の考え方 (より利用側に近いほうが勝つ) とは異なるといえます。
  • カプセル化文脈による制限は部分セレクターにも再帰的に適用されますが、スコープによる制限は最上位のセレクターの結果にのみ適用されます
  • etc.

CSS Nestingとの違い

@scope は、CSS Nestingで定義されるネストしたスタイルルールと構文的にもよく似ています。しかし、その意味論は大きく異なります。

  • カスケードの適用順序。外側のセレクターは、CSS Nestingにおいてはセレクターの詳細度に寄与します。一方、 @scope においては外側のセレクターはセレクターの詳細度には寄与せず、スコープ近接性による比較に使われます。どちらが強いかは今後スコープ近接性の優先度がどう定義されるかにもよりますし、場面ごとにその結果は変わってきます。
  • CSS Nestingは単に親セレクターの再利用をしやすくするだけの機能であり、書き方次第では親セレクターで指定された要素の子孫以外に対してマッチさせることも可能です。
  • CSS Nestingでは親セレクターは総体として評価されますが、 @scope では親セレクターは個体として評価されます。これにより、たとえば以下のような記述では違いが発生します。
    .foo {
      /* .foo + .foo と同等。2つの .foo の並びがあればマッチする。 */
      & + & {
        color: red;
      }
    }
    @scope (.foo) {
      /* :scope + :scope と同等。 */
      & + & {
        color: red;
      }
    }
    
    ただし、現行ドラフトの記述では & をCSS Nestingと同様に展開するように指示されているので、実装がこの記述通りに変更されれば & に関してはこの差異は無くなります。明示的に :scope を使うのであれば同じ差異が引き続き発生します。

スコープ限定子孫結合子

Cascading and Inheritance Level 6は @scope に加えて、スコープ限定子孫結合子 >> も提案しています。これは原則として子孫結合子と同様に動作しつつ、マッチ時に >> の左辺に対応する要素をスコープルートとしてスコープ近接性を再計算するというもののようです。 >> を複雑な位置 (:is() の中など) で使った場合の挙動がどのように規定されるのかは現時点では不明です。

セレクターの詳細度

宣言の重要度・オリジン・カプセル化文脈・レイヤーが同じで両者とも style 属性に由来していなければ、セレクターの詳細度が比較されます。より詳細度の大きいほうが勝ちます。

セレクターの詳細度について、本稿でもごく簡単に説明しておきます。詳細度は3つの非負整数の組 (a, b, c) で表される値で、セレクターの構文上の構造から決定されます。a, b, cはざっくり以下を意味しています。

  • a: IDセレクターの個数
  • b: クラスセレクターや、それと同等な条件の個数
  • c: 要素型セレクターや、それと同等な条件の個数

この組を辞書順で比較して大きいほうが勝ちます。たとえば、 (1, 3, 0) は (1, 2, 3) よりも大きいことになります。

詳細度はたとえば、「a よりも a.foo のほうが詳細な指定なので、後者が前者を上書きする意図だと解するべきだ」というような素朴な場面 (セレクター間に論理的な包含関係が成立する場合) ではうまく動作します。このような理由もあって詳細度は長らくCSSのカスケードの中心的な存在でした。しかし、より複雑なケースでは、詳細度は運用ミスを誘発しやすい側面があります。ここでは筆者の実体験をもとに加工した例をもとに説明してみます。当初、以下のような定義がありました。

/* ライブラリの定義 */
.Button {
  background-color: #ccf;
}

/* アプリケーションの定義 -- アプリケーションの定義を上書きした */
.Button.DeleteButton {
  background-color: #fcc;
}

この時点では、詳細度に基づくカスケードは意図通りに動作しています。ここに以下のような変更が加わったとします。

/* ライブラリの定義 -- コンポーネント側の変更により新しいクラスが導入された */
.Button {
  background-color: #ccc;
}
.Button.ButtonPrimary {
  background-color: #ccf;
}
.Button.ButtonPrimary:hover {
  background-color: #aad;
}

/* アプリケーションの定義 */
.Button.DeleteButton {
  background-color: #fcc;
}

このようにコンポーネント側の進化にあわせてスタイルを変更したときに、それまでよりも強い詳細度のスタイルが宣言されてしまうと、アプリケーション側での上書きが無効化されてしまいます。実際の例ではもう少し意識しにくい形でこのような問題が発生していました。

ここで問題なのは、詳細度がコンポーネントのインターフェースの一部になってしまっていることです。もちろん、これは詳細度という仕組みが100%悪いわけではなく、スタイルのルールを適切に制定して運用すればこうした問題はある程度回避できます。

しかし、現代のCSSは、このようなスタイルの共通化・再利用に強く依存する開発スタイルのために、様々な道具を提供しています。作ろうとしているソフトウェアの要件次第では、なるべく詳細度の影響を受けないような書き方を検討する余地はあるでしょう。

出現順

ここまでの条件のいずれでも順序が決定できなければ、最終的に宣言の出現順により優先度が決定されます。あとに出現した宣言が優先されます。

この出現順は、その時点での文書の状態から決定されるものであり、スタイルシートが挿入された動的な順序で決まるわけではありません。時間的にあとから挿入されたスタイルであっても、DOM上で手前のほうに挿入されれば、出現順では優先度が低くなります。

出現順がもっとも重要になるのは、同一スタイルブロック内での比較です。これには主に2つの使い道があります。ひとつは、新しい機能が使えなかった場合のフォールバックスタイルを記述する場合です。

#hero {
  height: 100%;
  height: 100vh;
  height: 100svh;
}

ここで新しい機能を使った宣言ほど下に書くのは、有効な宣言の中で出現順で最後のものが勝つことが保証されているからです。

また、略称プロパティの一部を正称プロパティで上書きするという使い方も頻出です。

/* all: unset は最初に書く */
all: unset;
display: block;
box-sizing: content-box;

また、常に単一のクラス名を使ってスタイルを当てるような書き方の場合は、同じ詳細度のブロックがたくさんできるので、運用次第では意図せずこのような競合が発生する可能性もあります。このような場合にも、スタイルの出現順を意識するか、なるべく意識しなくていいような書き方への転換が必要になるかもしれません。

var() の展開と遅延構文解析

カスケード値が決定したら、次に指定値を決定するのですが、その前にもう1つ処理しておくことがあります。それは var() の展開です[2]。また、 var() の有無とは無関係に、登録カスタムプロパティの構文解析もこのタイミングで処理します。

保証無効値

カスタムプロパティの値は保証無効値 (guaranteed-invalid value)という特別な値を持つことがあります。名前の通り、これはそのカスタムプロパティが無効であることを意味しています。これは以下の状況で発生します。

  • 初期値
  • カスタムプロパティが計算値時無効になった場合
    • カスタムプロパティ内の var() が保証無効値を参照しており、デフォルトが指定されていなかった場合
    • カスタムプロパティ内の var() の展開が循環参照になった場合

一般的な「無効」のニュアンスとは異なり、カスタムプロパティにおける保証無効値は必ずしも悪い状況をあらわしているわけではなく、正常なCSSスタイリングの一環として出現することがあります。

var() の展開

var() には2種類の形式があります。

  • 1引数形式。 var(--foo) など、コンマを使わない形。
  • 2引数形式。 var(--foo,), var(--foo, bar), var(--foo, bar, baz), var(--foo,,) など、コンマを使う形。

指定したカスタムプロパティに保証無効値ではなくトークン列が入っていれば、 var() 呼び出しはそれに置換されます。保証無効値が入っていた場合は、2引数目を使います。2引数目がない場合は無効な呼び出しと判定されます。

1引数形式
var(--foo)
2引数形式
var(--foo, bar)
--foo は無効 無効 bar
--foo: 12345 12345 12345

トークンは並べるだけ

規格中でも注意されているように、 var() の展開はトークン単位で並べるだけ[3]です。

トークンは結合されないので、以下のようなスタイルは意図した通りに動作しません。

/* これは動かないので、calcを使って書き直す必要がある */
width: var(--px)px;

逆に、以下のようなスタイルは width に複数の値を入れようとしているので一見おかしいように思えますが、実際には有効な宣言として機能する可能性があります。

/* --foo か --bar のいずれかが空だったらうまくいく可能性はある */
width: var(--foo) var(--bar);

遅延構文解析と計算値時無効

以下の場合には、このタイミングまで構文解析が遅延されています。

この時点で構文エラーまたは何らかの静的な条件への違反があった場合は計算値時無効 (invalid at computed-value time)[4]という特別な状態になります。

計算値時無効の場合は、その値を unsetinitial のいずれかとして再解釈します。どちらになるかは以下のルールで決められます。

  • UA定義プロパティの場合: unset として扱う。
  • カスタムプロパティの場合
    • 未登録のカスタムプロパティの場合: initial として扱う。 (= 保証無効値)
    • 登録されたカスタムプロパティの場合
      • 登録された構文が普遍構文定義の場合: initial として扱う。 (= 保証無効値)
      • 登録された構文が普遍構文定義以外の場合: unset として扱う。

通常の無効プロパティと計算値時無効なプロパティの違いは、それがカスケードの前か後かという点にあります。通常、プロパティが無効であればカスケード順位で下位の宣言にフォールバックしますが、計算値時無効ではカスケードのフォールバックがありません

var() とグローバルキーワード

var() の第2引数がグローバルキーワードである場合、展開後の値がグローバルキーワードと一致する可能性があります。

/* inherit に展開される可能性がある */
border: var(--something, inherit);

このような場合は、展開後のグローバルキーワードはグローバルキーワードとして指定された通りに解釈されます。

またその帰結として、カスタムプロパティの計算値がグローバルキーワードに一致することはありません[5][6]。ここで述べたように var() がグローバルキーワードに展開されるのは、第2引数の値が使われたときに限られると考えていいでしょう。

依存関係と循環参照の解決

CSSのプロパティ計算では、素朴に解釈すると循環参照に陥るケースがあります。UA定義プロパティではそれぞれに固有の方法で循環参照を解決しますが、カスタムプロパティの場合はそのような回避策を事前に定義することはできません。

そこで、カスタムプロパティでは、循環参照は var() の展開のタイミングで検出することになっています。

循環参照が発生した場合、循環参照に関与したカスタムプロパティは全て計算値時無効として扱うことになっています

アニメーションと循環参照

アニメーションとカスタムプロパティは以下のように相互作用します。

  • @keyframes を経由して、アニメーションがカスタムプロパティの値に影響を与えます。
  • animation-* プロパティを経由して、カスタムプロパティの値がアニメーションに影響を与えます。

この2つによって発生するループはアニメーション汚染という処理によって解消されます。 @keyframes の影響を直接的または間接的に受けるカスタムプロパティは「アニメーション汚染されている」 (animation-tainted) とみなされ、アニメーション関連プロパティの計算時は無効化 (初期値が入っているものとみなす)されます。

つまり、アニメーション汚染フラグによる場合分けを通じて、アニメーションとカスタムプロパティの相互作用は 「アニメーション汚染されていないカスタムプロパティ」「アニメーション関連プロパティ」「アニメーション汚染されているカスタムプロパティ」 のように順序づけられ、一方向化されます。

……というのが仕様上の規定ですが、現行のChrome/Firefoxの実装では、単にアニメーション関連プロパティの評価時は @keyframes の影響を無視してカスケードを処理するという挙動のようです。

デフォルト値の処理

カスケード値が決定したら、そこから指定値を決定します。このときに行うのがデフォルト値の処理です。

カスケード値(の展開後の値)が以下の場合に、デフォルト値の処理が発生します。

非継承プロパティの場合 継承プロパティの場合
カスケード値なし 初期値 ⬇️継承値
unset 初期値 ⬇️継承値
initial 初期値 初期値
inherit ⬇️継承値 ⬇️継承値
revert ※再計算 ※再計算
revert-layer ※再計算 ※再計算

非継承プロパティと継承プロパティ

CSSのプロパティは、あらかじめ非継承プロパティ継承プロパティ (inherited property)のいずれかに分類されています。これは仕様書のプロパティ定義に記載されています。たとえば次の例では "Inherited: yes" と書いてあり、 color プロパティは継承プロパティであることがわかります。

図: 'color' プロパティのスクリーンショット。 "Inherited: yes" と書いてある。

また、未登録のカスタムプロパティは全て継承プロパティですが、Houdiniの仕様を通じて登録された場合は継承の有無を選べます

非継承プロパティにはデフォルトで初期値が設定され、継承プロパティにはデフォルトで継承値が設定されます。しかし、非継承プロパティであっても明示的に継承を行うことができる点にも注意が必要です。

width: inherit; /* 非継承プロパティを強制的に継承させる */

あるプロパティが継承プロパティかどうかをある程度予測する方法があります。それは余計な <div><span> を挿入する思考実験をしてみることです。

たとえば以下のマークアップを考えます。

<span style="color: red">Hello!</span>

ここでマークアップに余計な階層を増やして以下のようにしてみます。

<span style="color: red"><span>Hello!</span></span>

もし color が継承しないとすると、後者の Hello! は初期の色 (黒など) で描画されることになってしまいます。ここでは元のマークアップと同じように赤で描画されるのが自然なので、このプロパティは継承されるのだろうと予測することができます。

いっぽう、以下の例ではどうでしょうか。

<div style="background-color: green">
  Hello!
</div>

ここで <div> の階層を増やしたとしても、外側の div が塗り潰す範囲は変わりません。内部の要素で同じ色でもう一度塗り潰す必要はないため、このプロパティは継承されないのだろうと予測することができます。

もちろん、これは継承の有無について完全なルールを与えるものではありません。古い規格で継承プロパティとして定義されていたものが、諸般の事情から非継承プロパティに変更されたプロパティや、初期値の計算時に実質的に継承と近い効果が発生するプロパティもあり、これらは継承の有無が完全にアプリオリに決まるものではないことを表していると言えます。

初期値

初期値は各プロパティごとに定められる固有の値です。継承プロパティにも初期値は存在します

未登録のカスタムプロパティは保証無効値 (guaranteed-invalid value)と呼ばれる特別な初期値を持ちますが、Houdini仕様を通じて登録されるときは初期値の指定が可能です。

初期値は以下のような場面で使われます。

  • 非継承プロパティのカスケード値がない場合や unset が指定された場合
  • initial が指定された場合
  • 継承元となる要素がない場合の継承値
  • 略称プロパティで記述が省略された部分の値 (別途指定がない場合)

継承値

継承値は指定値計算の前に算出される値で、以下のように決まります。

  • 文書のルート要素の継承値は、そのプロパティの初期値である。
  • それ以外の要素の継承値は、そのプロパティの親要素における計算値である。

非継承プロパティでも継承値は存在します。継承値は以下のような場面で使われます。

  • 継承プロパティのカスケード値がない場合や unset が指定された場合
  • inherit が指定された場合
  • 個々のプロパティの計算値や使用値の算出にあたって、そのプロパティの記述にしたがって利用される場合がある。

継承値は、親要素の計算値を参照します。このことが計算値という概念の重要度を規定しているといっても過言ではないでしょう。

継承とシャドウDOM

シャドウDOMが関与する場合、継承は展開後の木構造である平坦木に沿って行われます。これは以下を意味します。

  • ライト→シャドウ継承: シャドウホストのプロパティは、シャドウルート以下の要素に継承されます。
  • シャドウ→ライト継承: スロットのプロパティは、スロットに割り当てられたスロッタブルに継承されます。

Web Componentsの文脈でいうと、カスタム要素とそれを利用する文書は、継承を通じて互いにスタイルの情報を交換していることになります。もちろん、それが不都合である場合は all: initial などを使って明示的に継承をオプトアウトすることも可能です。

ボックスと擬似要素の継承

ボックスは要素を表示の都合にあわせて分割・再構成して得られる単位です。レイアウト時には、要素のプロパティ値に基づいてボックスのプロパティ値を決定することになります。ボックスのプロパティ値は、そのボックスが生成された状況に応じて個別に決められますが、原則は以下の通りです:

  • 主ボックスには、対応する要素のプロパティ値がそのままコピーされる。
  • それ以外のボックスは、ボックス木の構造に沿って all: unset のように継承される。

つまり、主ボックスの値の計算はボックス木の構造ではなく、要素の木構造に従う点に注意が必要です。

revert / revert-layer

revertrevert-layerカスケードから再計算を行う特殊なコマンドです。もちろん、ただ再計算するのではなく、現在のオリジンまたはレイヤーを無効化して再計算します。この無効化は、この要素のこのプロパティに対して一時的に適用されるもので、指定値が決定できたタイミングでリセットされます。

revert の目的は、すでに行った宣言を取り消すことにあります。たとえば以下の例では、 all: unset でユーザーエージェントのスタイルをリセットしていますが、 list-style についてだけその設定を除外しています。

/* color以外の全てのスタイルをリセットする (colorはWebブラウザの指定を使う) */
all: unset;
list-style: revert;

この場合、 revert を発見したことで all: unsetlist-style: revert両方を宣言値のリストから除外してカスケードから再計算します。すると今度はユーザーエージェントが指定した list-style がカスケードで勝つため、ここで指定値が確定することになります。

無効化される宣言の範囲は、 !important が関与するとややこしくなります!important はオリジン・カプセル化文脈・レイヤーの優先度を逆転させるため、この状態でどのように優先度を判定させるかは自明ではありません。この問題を踏まえた正しいルールは以下のように定義されています

  • revert/revert-layer の元となった宣言について、その通常の宣言と**!important 宣言**の両方のバリエーションを仮想的に想定する。
  • これらの2種類の宣言の間にある宣言を全て無効化する。これは以下のように決定される。
    • revert の場合: 「オリジンと重要度」だけで、カスケード優先度に基づき比較する。[7]
    • revert-layer の場合: 「オリジンと重要度」「カプセル化文脈」「style 属性の優先」「レイヤー」の4つの要素だけで、カスケード優先度に基づき比較する。

ただし、それぞれにはさらに注意すべきコーナーケースがあります。

まず、 revert の場合は、文書の作者による表現ヒントを文書の作者によるオリジンと同一視します。たとえば color: revert を指定すると <font color="red"> の指定も一緒にリバートされます。

また、 revert-layer の場合、 style 属性との関係に注意する必要があります。

というのも、上のルールでわざわざ !important 宣言に言及している背景は、 !important 宣言と通常の宣言に関する対称性を考慮したものだと考えられるからです。しかし、 revert-layer の場合、 revert-layer が対象とするカスケード要件のうち、 style 属性の優先ルールのみ !important による逆転の影響を受けません。この非対称性をどうするかという議論の結論として、revert-layer の処理にあたっては、本来のカスケード順とは異なり、 !importantstyle 属性は同列の他の宣言より優先度が下であるかのように扱うという仕様になっています。[8]

カスタムプロパティのrevert

カスタムプロパティでもプロパティごとにrevertを処理すればよいだけですが、このrevertはカスタムプロパティ間での依存関係の処理中に発生するので停止性が少しだけ非自明です。1つの文書中で出現するレイヤーの個数は有限であることから、ある要素に関するカスタムプロパティのrevert回数は (レイヤーの個数) × (カスタムプロパティの個数) 回で抑えられるため、これを考慮したループ変量を考えることになります。

計算値の決定

指定値が決定されたら、これを計算値 (computed value)へと加工していきます。 (なお、本稿では計算値の算出処理の一部である var() の展開と遅延構文解析を2つ前の節で処理済みとして扱っています)

計算値の算出過程において、様々な文脈依存の式や相対値が展開されます。何が展開され、何が展開されないかを理解することは、継承の挙動を理解する上でとても大切です。

計算値の決定方法はプロパティごとに独立に定められています。同じような式でも、プロパティごとに展開されるものと展開されないものがあります

たとえば、以下は width, height の定義です。

図: width と height の定義

このうち、

Computed value: as specified, with <length-percentage> values computed

と書かれていることから、width/heightの計算値では %, em, calc() の展開が(可能な範囲で)行われるいっぽう、 auto などの指定はそのままであり使用値計算まで遅延されることがわかります。

計算値が観測される例

たとえば以下のスタイルを考えます。

#parent {
  color: blue;
  border: 1px solid currentColor;

  font-size: 16px;
  min-height: 10em;
  
  padding: 1px;
}

/* parentの子要素 */
#child {
  color: red;
  border: inherit;

  font-size: 2px;
  min-height: inherit;
}

この例における border-colormin-height は、いずれも他のプロパティの値を参照する形で指定されています。ところが、この2つは計算値の算出方法が異なるため、以下のように継承時に異なる結果が得られることになります。

  • border-color ... #child 側で再計算される (#childcolor を参照する)
    • #parent における計算値currentColor
    • #parent における使用値rgb(0, 0, 255)。 (#parent における color の使用値を参照するため)
    • #child における計算値currentColor。 (親の計算値を継承するため)
    • #child における使用値rgb(255, 0, 0)。 (#child における color の使用値を参照するため)
  • min-height ... #parent 側で計算した値が使用される (#parentfont-size を参照する)
    • #parent における計算値160px。 (#parent における font-size の計算値をもとに展開されるため)
    • #parent における使用値160px
    • #child における計算値160px。 (親の計算値を継承するため)
    • #child における使用値160px

また、 line-height のように、ひとつのプロパティの中でも指定の仕方によって計算値で解決されるもの (e.g. 120%) と使用値で解決されるもの (e.g. 1.2) が混在する場合があります。

%

% は相対的な単位のひとつですが、これが何に対する相対値であるかはプロパティごとに異なります。各プロパティの Percentages: の欄にその意味が書いてあります。

  • 長さ
    • インラインサイズまたは主軸方向のサイズを参照
      • 包含ブロックのインラインサイズ (margin-*, flex-basis など)
      • それ以外のインラインサイズ
    • 同じ軸の大きさを参照
      • 包含ブロックの大きさ
      • それ以外の大きさ (border-radius など)
    • それ以外の大きさ

  • フォントサイズの継承値 (font-size)
  • 無次元量 (opacity など)
  • 意味をもたない
    • 未登録あるいは普遍構文のカスタムプロパティ
    • 色系のプロパティ (color, background-color など)
    • 択一系のプロパティ (border-*-style など)
    • 名前系のプロパティ (page など)

TODO

相対長 (em 等)

相対長は一般的に計算値中で解決されます。これにはフォント相対長ビューポート百分率長コンテナ相対長が知られています。

フォント相対長

フォント相対長は以下のように決定されます。

  • 前進尺度を参照する ch, rch, ic, ricwriting-modetext-orientation に依存するため、先にこちらの計算値を導出しておきます。これらはキーワード指定のみのため、循環参照になることはありません。
  • 全てのフォント相対長さはフォント指定 (特に font-size の値) に依存するため、これらを先に計算しておく必要があります。
    • もし font-* プロパティの中で em, ex, cap, ch, ic, lh などの r のついていない単位が使われた場合は、循環参照回避のために、計算値ではなく継承値を参照します。
      • たとえば、 font-size: 2em は親要素のfont-sizeの2倍に解決されます。
    • 同様に、ルート要素の font-* プロパティの中で rem, rex, rcap, rch, ric, rlh などの r のついた単位が使われた場合も、計算値ではなく継承値 (=初期値) を参照します。
  • lh, rlhline-height にも依存するため、これを先に計算しておきます。循環参照回避のために、 line-height の中でこれらの単位が使われた場合は計算値ではなく継承値を参照します。

ビューポート相対長とコンテナ相対長

ビューポート百分率長コンテナ相対長は、それぞれ計算値において絶対長に解決されます。

ビューポートの大きさはレイアウト時に所与であるため、循環参照の心配はありません。

一方、コンテナの大きさはコンテナの外側のレイアウトの計算によって決まります。コンテナはこのように「内側の要素の(広義の)カスケードの挙動を、外側の要素のレイアウトに依存して決定させる」という目的のために導入されたものであり、循環参照を防ぐために、コンテナ外のレイアウトをコンテナ内のカスケードに依存させないよう設計されています。

var()

すでに説明したように、var()は計算値の算出より前に展開されます。現行規格では名目上これが計算値の計算の一部であるとして扱っていますが、実態としては指定値計算前の前処理と考えたほうがいいでしょう。

数式

CSSにおいて数値を指定する構文 (<number><length>, <length-percentage> など) は数式を許容するように定義されています。

数式は数学関数と呼ばれる関数記法のいずれかを使って記述される値です。これは一般的な意味での「値」の範囲を超えていますが、CSSの用語としての「値」が指す範囲には含まれます。

現行の安定した仕様であるCSS Values and Units 3では1段階の calc() のみが数式として定義されていますが、Working DraftであるValues and Units 4では数式が大幅に拡張されているため、本稿ではこの定義をもとに説明します。

数学関数は数値を引数にとります。再帰的に数学関数を指定することも可能です。

width: max(10px, min(20px, 30px));

数学関数の中では四則演算も利用可能です。数学関数の外では利用できません。

width: max(100%, 50% + 10px); /* OK */
width: max(50%, 10px) + 50%; /* 不可能 */

特に利用したい数学関数はなく、四則演算だけを利用したい場合は、式を calc() で囲みます。これは恒等関数として振る舞います。

width: calc(max(50%, 10px) + 50%);

TODO: calc() とinvalid判定について

TODO: calc() の解決

if()

url()

attr()

anchor()

css-easing

システム設定

  • system color

関連するプロパティの影響を受ける計算値

  • wrap-flow

その他

  • <<bg-position>>
  • <<image>>
  • <<basic-shape>>
  • 'contain'
  • 'content'
  • 'display'
  • 'font-weight'
  • 'font-width'
  • 'font-style'
  • 'font-variation-settings'
  • snap as a border width
  • 'continue'
  • clamped to the range
  • outline-color/currentColor

使用値の決定

currentcolor

関連するプロパティの影響を受ける使用値

  • currentColor (outline-color etc.)
  • border-width

Style/Layout interleaving -- @containeranchor()

解決値

CSSOM View


脚注
  1. Stylusなど ↩︎

  2. 名目上は、この処理は計算値の算出時に行うことになっています。しかし、算出後にグローバルキーワードが出現した場合の挙動が規定されており、続く例示や実際の実装を確かめるとここでの「指定値」は「カスケード値」の誤りであると考えることが自然です。つまりこの規定を素直に解釈すると、var() の展開後にデフォルト値を再度処理しなければいけないことになります。しかし、 var() の展開とデフォルト値の展開は干渉しないことから、先に var() が展開されるとしても問題なく、そう考えるとデフォルト値の処理は1箇所にまとめられます。このような理由から、ここでは仕様の記述とは異なる順序で説明しています。 ↩︎

  3. より正確に言うならば、要素値 (component value) のリストを単位として置換している。 ↩︎

  4. 「計算時無効」ではなく「計算時無効」です。計算値 (computed value) が術語であることに注意が必要です。 ↩︎

  5. 登録カスタムプロパティの場合を考慮する必要がありますが、CSSの値構文はグローバルキーワードと衝突しないように設計されているのでやはりこのようなことは起こらないと考えられます。 ↩︎

  6. グローバルキーワードをトークン列の一部として含むことはありえます。将来的にトークン列を加工する高度な手段が増えた場合は、この限りではなくなるかもしれません。 ↩︎

  7. 仕様では revert についてこの形で説明している箇所はありませんが、アニメーションオリジンが作者オリジンに含まれるという規定が実質的にこのことを意味していると解釈できます。ユーザーエージェントオリジンとユーザーオリジンで通常 revert が使われないことと、このステップでアニメーションオリジンから revert が出現することがないことを考えると、これで論理的には整合性が取れます。 ↩︎

  8. ただし、ここでの仕様の記述も少しおかしなことになっています。ここでの記述の意図は本文で説明した通りですが、実際には「style 属性のrevertは、 style 属性とアニメーションオリジンにのみ影響する」という形で記述されています。確かにこれは一般的な状況下では本来の意図と思われる挙動と一致しますが、カプセル化文脈が複数あるような状況下ではおかしな解釈になってしまいます。実際にどのように実装されていくのかは今後の仕様と実装の動向を見守る必要があります。 ↩︎

Discussion