CSS のコンテナクエリを @property と組み合わせたときに予想外の挙動に出会い、原因を追ううちに「CSS のプロパティ値処理」の仕様にたどり着きました。本記事では、その仕様を踏まえて @property 有無で挙動が変わる理由を解説し、学んだ知識で他の CSS 挙動(line-height や width の % 解釈)も読み解いていきます。
前提: @property・コンテナクエリ・cqi
まず状況を説明する上で前提となる、@property・コンテナクエリ・cqi の 3 つについて簡単に紹介します。
@property
カスタムプロパティ(CSS 変数)に型や初期値を宣言できる仕組みです。syntax: "<length>" のように型を指定することで、CSS エンジンはその変数を単なる文字列ではなく型付きの値として扱うようになります。
コンテナクエリ
メディアクエリのようなビューポートではなく親要素のサイズを基準にスタイルを切り替えられる仕組みです。container-type: inline-size を指定した要素がコンテナになります。
cqi
コンテナクエリに対応する単位です。width: 100cqi と書くと「最も近い祖先コンテナの幅(正確にはインラインサイズ)の 100%」になります。
詳しい使い方などは MDN(@property / コンテナクエリ)を参照してください。
理解できない挙動との出会い
やりたかったこと
cqi は常に最寄りのコンテナを基準にするので、コンテナが入れ子になっていると参照先が内側に切り替わってしまいます。「特定の祖先コンテナを基準に指定する方法はないのか」と興味本位で調べていました。
<div class="container-a">
<!-- コンテナA: 1000px -->
<div class="container-b">
<!-- コンテナB: 400px -->
<div style="width: 100cqi">
<!-- 1000px ではなく 400px になる -->
</div>
</div>
</div>
外側コンテナ A の 100%(1000px)が欲しいのに、内側コンテナ B の 100%(400px)として処理されてしまいます。
コンテナには名前をつけられるので name/100cqi のように「コンテナ名 + 単位」で指定するような構文があるんじゃないかなと思って調べてみましたが、そのような構文はありませんでした。
カスタムプロパティを試してみた
「ならカスタムプロパティに入れて、A の配下で --a-width: 100cqi と書いておけば固定できるのでは?」と思ったのですが、これもうまくいきませんでした。
<div class="container-a">
<!-- コンテナA: 1000px -->
<div style="--a-width: 100cqi">
<!-- ここの cqi は container-a を基準にするはず -->
<div class="container-b">
<!-- コンテナB: 400px -->
<div style="width: var(--a-width)">
<!-- 依然として 400px になる -->
</div>
</div>
</div>
</div>
子孫で var(--a-width) を使っても、結局その場の最も近いコンテナ B を基準に解釈されてしまいます。
@property を付けると実現できた
AI に相談したところ「@property で型を宣言すれば解決する」と返ってきました。
@property --a-width {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
<div class="container-a">
<!-- コンテナA: 1000px -->
<div style="--a-width: 100cqi">
<div class="container-b">
<!-- コンテナB: 400px -->
<div style="width: var(--a-width)">
<!-- ちゃんと 1000px になる -->
</div>
</div>
</div>
</div>
半信半疑で @property を追記したところ、子孫要素で --a-width を参照するとコンテナ A を基準にした値(1000px)が取得できました。
ただ型を宣言しただけで、同じ 100cqi の解釈先がコンテナ B からコンテナ A に切り替わるのが直感的に納得できず、仕様を追いかけることにしました。
仕様を読んで原因を探る
まず @property の仕様を読んでみることにしました。
すると、原因と関係がありそうな一節が見つかりました。「@property で宣言されたカスタムプロパティは、書いたままの記述ではなく計算値として置換される」というものです。
「計算値」という言葉が引っかかったので深掘りしてみると、CSS のプロパティ値処理を定めた仕様にたどり着きました。そこには、プロパティ値は複数段階の計算を経て最終値になる、と定められていました。
なんとなく値が内部で変換されていることはわかっていましたが、複数段階に分かれているとは思っていなかったので、ここで自分の認識を改めました。
処理の流れを簡単な図にすると、次のようになります(例として width: 2em、その要素の font-size が 16px の場合)。
宣言値 : "2em" ← CSS に書いた形
│ カスケード(適用する宣言を決める)
▼
カスケード値 : "2em"
│ 継承・初期値で埋める
▼
指定値 : "2em" ← 相対単位のまま
│ 相対単位を絶対値化・キーワードを置換
▼
計算値(算出値) : "32px" ← 自要素の font-size (16px) 基準で絶対値に解決
│ レイアウト結果が必要な値を解決(width の % など)
▼
使用値 : "32px"
│ 描画時の制約を反映
▼
実効値 : "32px"
そして、@property の有無で挙動が変わる理由の決め手になったのが、この図中にある「計算値(Computed Values)」の項の記述でした。そこには「継承で親から子へ渡されるのはこの値である」と明記されています。つまり、計算値の段階で確定した値が子に渡るわけです。
ここまでで分かった 3 つのルールが揃ったところで、先ほどの @property と cqi の挙動を理解できそうに思えました。
@propertyで型宣言したカスタムプロパティは、計算値として解決される- プロパティ値は段階を踏んで処理される
- 継承されるのは計算値
ルールを踏まえて今回の挙動を考えてみる
先ほど整理した 3 つのルールを使って、最初に挙げたカスタムプロパティのケースを見直していきます。
@property を使わない場合
まずは @property なしのパターンから。
型宣言がないカスタムプロパティは、CSS エンジンから見るとほぼただの文字列として扱われます(仕様上は「未解決のトークン列」)。
/* container-a の配下に置いた中間要素で宣言 */
.a-scope {
--a-width: 100cqi;
}
/* CSSエンジンの理解: --a-width には "100cqi" という文字列が入っている */
型が分からない以上、計算値フェーズで解決しようがないので、文字列のまま子孫に継承されることになります。
そうすると、var(--a-width) を受け取った側で初めて型が決まる。そこで cqi が解決されるとき、基準になるのは当然「そのときの最寄りのコンテナ」なので、コンテナ B の 400px になってしまうわけです。
@property を使った場合
次に @property ありのパターン。
syntax: "<length>" と宣言することで、CSS エンジンが --a-width を length 型の値として扱うようになります。こうなると通常の width や padding と同じで、計算値フェーズで解決される対象になります。
つまり --a-width: 100cqi と書いた場所でそのまま 1000px に確定する。あとはこの計算値が子孫に渡るだけなので、途中にコンテナ B が挟まろうと影響を受けない、というのが型宣言で挙動が変わる理由なんですね。
/* container-a の配下に置いた中間要素で宣言 */
.a-scope {
--a-width: 100cqi;
/* @property ありの場合、ここで 1000px に確定する */
}
.child {
width: var(--a-width);
/* 受け取るのは 1000px。コンテナ B の影響を受けない */
}
答え合わせ
@property の有無で挙動がどう変わるかを並べると次の通りです。
- なし: 計算値フェーズがスキップ → 文字列のまま子孫に渡る
- あり: 計算値フェーズで
100cqi→1000pxに解決 → 計算値が子孫に渡る
「型を宣言する」という行為が、値がいつ・どこで確定するかまで変えている、というのが面白いポイントでした。@property は単に型を書く機能というより、カスタムプロパティを通常のプロパティと同じ処理パイプラインに乗せるためのスイッチなんですね。
応用: 学んだ仕様で他の CSS 挙動を読み解く
@property と cqi の話はここまでですが、今回学んだ段階処理と計算値の継承は他の CSS 挙動にも当てはまります。自分が「そういうものだ」で済ませていた挙動を言語化できるようになった例を 2 つ紹介します。
line-height: 150% と 1.5 の違い
line-height の「パーセント(150%)」と「単位なしの数値(1.5)」どちらも同じ「1.5 倍」なのに、子孫での挙動が変わります。
.case-a {
font-size: 16px;
line-height: 150%;
}
.case-b {
font-size: 16px;
line-height: 1.5;
}
.child {
font-size: 32px;
}
<div class="case-a">
親 (16px)
<span class="child">子 (32px)</span>
</div>
<div class="case-b">
親 (16px)
<span class="child">子 (32px)</span>
</div>
- ケース A(
150%): 子の line-height も24px(親で確定した値がそのまま引き継がれる) - ケース B(
1.5): 子の line-height は48px(32px × 1.5)
これは、line-height の仕様で単位なしの数値について、計算値は指定値と同じ、使用値は要素のフォントサイズとの掛け算で決まると定義されているためです。1.5 は数値のまま計算値として子孫に届き、受け取った要素ごとに font-size と掛け算されて使用値になります。一方、150% や 1.5em は親の時点で絶対値まで解決された値が計算値になるので、子の line-height は親で解決された値のまま固定され、子の font-size を変えても変わりません。
width: % は使用値フェーズまで解決されない
ここまで「相対単位は計算値フェーズで絶対値に解決される」という話をしてきましたが、すべての相対単位がそうではありません。同じ % でもプロパティによって解決されるフェーズが違います。
先ほどの line-height: 150% は計算値フェーズで絶対値に解決されますが、width: 50% は計算値フェーズでは解決されず、使用値フェーズまで持ち越されます。
これは、先の図にあった通り、レイアウト結果が必要な値は使用値フェーズに持ち越されるためです。line-height の基準(自要素の font-size)は計算値フェーズで確定済みなので解決可能、width の基準(包含ブロックの幅)はレイアウトが終わるまで決まらないので保留されます。
.parent {
width: 800px;
}
.child {
width: 50%;
}
.grandchild {
width: inherit; /* width は通常継承されないので明示的に継承 */
}
<div class="parent">
<!-- 800px -->
<div class="child">
<!-- 400px(800px × 50%) -->
<div class="grandchild">
<!-- 200px(400px × 50%) -->
</div>
</div>
</div>
.childの計算値:50%(パーセントのまま).childの使用値:400px(包含ブロックである.parentの幅が決まってから算出).grandchildは.childから計算値50%を継承 → 包含ブロックが.child(400px)になるので使用値は200px
もし使用値(400px)が継承されていれば孫も 400px になるはずですが、実際は 200px になります。これは継承されているのは計算値(50%)であり、使用値フェーズでの解決は受け取った要素ごとに行われるためです。
まとめ
- CSS のプロパティ値は「宣言値 → カスケード値 → 指定値 → 計算値 → 使用値 → 実効値」と段階を踏んで処理されます
- 親から子へ渡るのは計算値です
- カスタムプロパティでは、
cqiのような相対単位がいつ絶対値になるかが@propertyの有無で変わります
CSS を「宣言型の言語」と捉えると見過ごしがちですが、内部ではいつ・どこで値が確定するかが細かく定義されていて、どの段階で解決されるかはプロパティと単位の組み合わせで決まっています。段階処理を念頭に考えると、継承される値の挙動も理解しやすくなるはずです。
最初は「なんだこの挙動?」から始まった話でしたが、辿ってみれば全部仕様通りの綺麗な動作でした。普段何気なく書いている CSS の下に、こんな細かい処理モデルが敷かれているとは思いませんでした。



