insetのbox-shadowで描いた縁取りが子要素の背景に隠れて消える
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
insetのbox-shadowは要素自身の背景/ボーダー段で塗られるため、その内側に静的配置で背景を持つ子要素を置くと、box-shadowは常に子要素の背景の下に隠れて消える。
結論
inset指定のbox-shadowは要素自身の背景・ボーダーと同じ描画段に塗られる。 その内側に置いた子要素が静的配置(static)のまま背景を持っていると、子要素は通常のフローで親のbox-shadowより後に描画されるため、box-shadowはその子要素の背景に必ず隠れて消える。縁取りとして機能させたいなら、暗幕やベースの装飾とは別の要素に分け、DOM順でchildrenより後ろに置いて重ね順で勝たせる必要がある。
症状
学校のサイネージ実機を模したプレビュー枠(SignageMonitor)に、広告レールの位置を示す白+アクセント色の二重リングを縁取りとして描いていた。狙いは「白い盤面の上でも、レール以外を暗く沈める暗幕の上でも、縁がはっきり見えること」で、リングと暗幕を同じbox-shadow宣言にまとめて1つの要素に適用していた。
const SPOTLIGHT =
"inset 0 0 0 2px rgba(255,255,255,0.95), inset 0 0 0 4px var(--color-accent), 0 0 0 1px rgba(255,255,255,0.9), 0 0 0 9999px rgba(2,6,23,0.62)";
{/* 広告レール本体(スポットライトの明るい窓)。children があればここに重なる。 */}
<div className="absolute" style={{ ...AD_STYLE, boxShadow: SPOTLIGHT }}>
{children}
</div>
このレールに入稿フォームのプレビュー画像をchildrenとして重ねると、リングだけが消える。暗幕(外向きの0 0 0 9999px)は問題なく見えているのに、内側の二重リング(insetの2本)だけが入稿プレビューの下に隠れていた。
入稿フォーム側のchildrenは、プレビュー画像の有無にかかわらず自前の背景を敷いて要素いっぱいに広がる作りだった。
<div className="flex h-full w-full items-center justify-center bg-gray-900/70 p-1">
{previewUrl ? (
<img src={previewUrl} alt="" className="aspect-[9/16] max-h-full w-full rounded object-cover" />
) : (
<div className="flex aspect-[9/16] max-h-full flex-col items-center justify-center gap-1 rounded border-2 border-dashed border-white/70 px-1 text-center">
{/* ... */}
</div>
)}
</div>
bg-gray-900/70が付いたdivがh-full w-fullで親いっぱいに広がっており、これが後述の理由でリングの上に重なっていた。
原因
box-shadowのinset指定は、要素のペイント順のうち背景・ボーダーと同じ段で描かれる。CSSの通常のスタッキングでは、この段は要素の子要素(children)より下にある。したがって、insetのリングを持つ親要素の内側に、静的配置(static位置指定なし、またはposition未指定)で自前の背景を持つ子要素を置くと、その子要素は親の背景・box-shadowより後に描画される兄弟として扱われ、リングの上に子要素の背景が乗る形になる。
暗幕側の0 0 0 9999pxは外向き(outset)のbox-shadowで、レール要素の外周を覆う形で塗られるため、レールの内側にあるchildrenの背景とは競合しない。同じ宣言の中にinsetとoutsetが混在していたため、「暗幕は見えるのにリングだけ消える」という一見不可解な症状になっていた。
直し方
暗幕(DIM)とリング(RING)を別のbox-shadow定数に分割し、リング専用の要素をchildrenの後ろのDOM順で追加した。
const DIM = "0 0 0 9999px rgba(2,6,23,0.62)";
const RING =
"inset 0 0 0 2px rgba(255,255,255,0.95), inset 0 0 0 4px var(--color-accent, #22c55e), 0 0 0 1px rgba(255,255,255,0.9)";
{/* 広告レール本体(暗幕の明るい窓)。children があればここに重なる。 */}
<div className="absolute" style={{ ...AD_STYLE, boxShadow: DIM }}>
{children}
</div>
{/* 縁取りは children の後ろに置いて最前面に出す。 */}
<div
className="pointer-events-none absolute"
style={{ ...AD_STYLE, boxShadow: RING }}
/>
リング用のdivはchildrenを持たず、AD_STYLEで同じ矩形に重ねて置くだけの純粋な装飾要素にした。DOM順で暗幕用の要素より後にあるため、通常のスタッキングコンテキストで暗幕・childrenの上に重なる。pointer-events-noneを付けて、入稿フォームのクリック操作を妨げないようにしてある。
あわせて、RINGのvar(--color-accent)に#22c55eのfallbackを追加した。Tailwind v4はソースコードを走査してvar()の文字列を見つけたときだけ、対応するCSS変数を出力対象にする。このコードがリポジトリ内で唯一の参照だったため、文字列を分割するようなリファクタでこの参照が消えると変数ごと出力されなくなり、var()がinvalidになってRING全体、ひいては縁取りが無音で描画されなくなる。fallbackを付けておけば、変数が出力されなくなってもfallback値で縁取りが残る。
入稿フォーム側も、プレビュー画像がある場合は下地のbg-gray-900/70を敷かないよう分岐を整理した。プレビュー画像は不透明でレール全面を埋めるため、下地を敷く必要がないだけでなく、下地があるとレールの内側だけが暗く沈んで見える副作用もあったため、プレースホルダ表示のときだけ不透明な下地を敷く形にしている。
再発防止
修正後のコードには、リングを暗幕と別要素に分ける理由と、var()にfallbackを付ける理由の両方をコメントとして残した。
/**
* レールの縁取り。白+アクセント緑の二重リングで、白い盤面の上でも暗幕の上でも縁が消えないようにする。
*
* ⚠ 暗幕とは別の要素に、children より後ろの DOM 順で敷くこと。inset の box-shadow は
* 親の背景/ボーダー段で塗られるため、レールを埋める子(入稿プレビューの背景など)に
* 上書きされて消える。リングを最前面へ出すには重ね順で勝たせるしかない。
*
* ⚠ var() に fallback を付けているのは色の二重管理ではなく全損回避。値の権威は globals.css の
* --color-accent だが、Tailwind v4 はソース走査でこの文字列を見つけた時だけ変数を出力する
* (現状この1箇所が唯一の消費者)。文字列を分割するリファクタで変数が消えると var() が
* invalid になり box-shadow ごと落ちる=リングだけでなく暗幕まで無音で消える。
*/
insetのbox-shadowは見た目の仕様どおりに動いており、バグはブラウザ側にはない。「暗幕は見えているのにリングだけ消える」という一見不可解な症状は、同じ宣言にinsetとoutsetを混在させ、かつそれをchildren持ちの要素に適用したという組み合わせ由来だった。次に同じ構図(装飾用のbox-shadowと、背景を持つchildren)を書くときのために、分離の理由をコード側に残してある。
似た「重ね順で見た目が壊れる」系のCSSの罠は stickyのtop:0が残ったままfixedにすると下部固定バーが全画面化する にも書いている。
よくある質問
Q1inset box-shadowで描いた縁取りが子要素に消されるのはなぜですか?
box-shadowのinset指定は要素自身の背景・ボーダーと同じ描画段(ペイント順で背景の直後)に塗られます。その要素の内側にchildrenとして静的配置(static)の子要素を置き、その子要素自身が背景を持っていると、子要素は通常のフローで親の背景/box-shadowより後に描画されるため、box-shadowの上に子要素の背景が重なって縁取りが見えなくなります。
Q2縁取りを別要素に分けると、なぜ最前面に出せるのですか?
同じ親の中でchildrenを描く要素より後ろのDOM順に、縁取り専用の要素を追加します。position: absoluteでも兄弟要素同士の重なりはDOM順(後にあるものが上)に従うため、z-indexを足さなくても縁取り用の要素が最前面に出ます。暗幕(外向きのbox-shadow)と縁取り(inset box-shadow)を別要素に分離するのがポイントです。
Q3var(--color-accent)にfallbackを付けたのはなぜですか?
Tailwind v4はソースコードを走査してvar()の文字列を実際に見つけたときだけ、そのCSS変数を出力対象にします。この記事の対象コードではその参照がリポジトリ内で唯一の消費者だったため、文字列を分割するようなリファクタで参照が消えると変数ごと出力されなくなり、var()がinvalidになってbox-shadow宣言全体が無効になります。fallback値を書いておけば、変数が出力されなくなってもfallbackで描画が続きます。
確認した環境
- Next.js 16.2.7 / React 19.2.4 / Tailwind CSS 4.3.0
- 2026-07-24 18:39 に導入、同日18:51のレビューで修正
この記事の根拠
- TypeScriptファイル 43〜90行目コミット 32c608d
- TypeScriptファイル 56〜117行目コミット 408053a
- TypeScriptファイル 456〜472行目コミット 32c608d
- TypeScriptファイル 459〜478行目コミット 408053a
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。