An inset box-shadow Ring Hides Behind a Child Background
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
An inset box-shadow paints at the element's own background and border stage, so any statically positioned child with a background of its own always covers it.
Conclusion
An inset box-shadow is painted at the same stage as the element’s own background and border. If a child placed inside it stays statically positioned and carries a background of its own, that child is painted after the parent’s box-shadow in normal flow, so the shadow is always hidden behind the child’s background. To make it work as a ring, split it out from the dimming and the base decoration into its own element and place it after children in DOM order so it wins the stacking.
Symptom
A preview frame mimicking the real school signage panel (SignageMonitor) drew a double ring — white plus accent colour — to mark where the ad rail sits. The intent was “the edge stays visible both against a white panel and against the dimming overlay that darkens everything outside the rail”, so the ring and the dimming were combined into one box-shadow declaration on a single element.
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)";
{/* The ad rail itself (the bright window in the spotlight). children overlay here. */}
<div className="absolute" style={{ ...AD_STYLE, boxShadow: SPOTLIGHT }}>
{children}
</div>
Overlay the submission form’s preview image onto this rail as children and only the ring disappears. The dimming (the outset 0 0 0 9999px) is perfectly visible, while the two inset rings hide under the preview.
The children from the submission form laid down their own background and filled the element, regardless of whether a preview image was present.
<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>
A div carrying bg-gray-900/70 spans the parent with h-full w-full, and for the reason below it sat on top of the ring.
Cause
An inset box-shadow is painted at the same stage as the element’s background and border. In normal CSS stacking, that stage is below the element’s children. So when a parent carrying an inset ring contains a statically positioned child with a background of its own, that child is painted after the parent’s background and box-shadow, and the child’s background covers the ring.
The dimming 0 0 0 9999px is an outset shadow, painted around the outside of the rail, so it never competes with the background of children inside the rail. Mixing inset and outset in one declaration is what produced the apparently baffling “the dimming shows but only the ring vanishes”.
The fix
Split the dimming (DIM) and the ring (RING) into separate box-shadow constants and add a ring-only element after children in DOM order.
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)";
{/* The ad rail itself (the bright window in the dimming). children overlay here. */}
<div className="absolute" style={{ ...AD_STYLE, boxShadow: DIM }}>
{children}
</div>
{/* The ring goes after children so it comes to the front. */}
<div
className="pointer-events-none absolute"
style={{ ...AD_STYLE, boxShadow: RING }}
/>
The ring div has no children; it is a pure decoration layered over the same rectangle via AD_STYLE. Being later in DOM order than the dimming element, ordinary stacking puts it above both the dimming and the children. pointer-events-none keeps it out of the way of clicks on the submission form.
A #22c55e fallback was also added to var(--color-accent) in RING. Tailwind v4 only emits a CSS variable when it finds the var() string while scanning source. This code was the only reference in the repository, so a refactor that split the string would drop the variable, make var() invalid and silently kill RING — and with it the ring. With a fallback, the ring survives even if the variable stops being emitted.
The submission form was also tidied so the bg-gray-900/70 backdrop is not laid down when a preview image is present. The image is opaque and fills the rail, so the backdrop is unnecessary — and it had a side effect of darkening only the inside of the rail. The opaque backdrop is now used only for the placeholder state.
Preventing a repeat
The fixed code carries comments explaining both why the ring is a separate element and why var() has a fallback.
/**
* The rail's ring: a white + accent-green double ring so the edge survives both on a
* white panel and over the dimming.
*
* Keep it as a separate element, after children in DOM order. An inset box-shadow paints
* at the parent's background/border stage and is overwritten by children that fill the
* rail (the submission preview's background). Winning the stacking order is the only way.
*
* The fallback in var() is not duplicated colour management, it is loss avoidance. The
* authority is --color-accent in globals.css, but Tailwind v4 only emits the variable when
* it finds this string while scanning source (this is currently the only consumer). A
* refactor that splits the string makes var() invalid and drops the whole box-shadow —
* silently taking the dimming with the ring.
*/
The inset box-shadow behaves exactly to spec; there is no browser bug. The apparently baffling “the dimming shows but only the ring vanishes” came from mixing inset and outset in one declaration and applying it to an element with children. The reason for the split now lives in the code, for whoever next writes the same shape — a decorative box-shadow plus children with backgrounds.
For another CSS trap where stacking breaks the visuals, see a leftover sticky top:0 making a fixed bar fill the screen.
よくある質問
Q1Why does a child element erase a ring drawn with inset box-shadow?
An inset box-shadow paints at the same stage as the element's own background and border. Statically positioned children with backgrounds of their own are painted after that stage, so the child's background lands on top of the shadow and the ring disappears.
Q2Why does splitting the ring into its own element bring it to the front?
Add a ring-only element after the children in DOM order inside the same parent. Stacking between siblings follows DOM order — later is higher — so the ring element comes to the front without any z-index. Separate the dimming (outset) from the ring (inset).
Q3Why add a fallback to var(--color-accent)?
Tailwind v4 emits a CSS variable only when it finds the var() string while scanning source. That reference was the only consumer in the repository, so a refactor splitting the string would drop the variable, make var() invalid and kill the whole box-shadow. A fallback keeps it drawing.
確認した環境
- Next.js 16.2.7 / React 19.2.4 / Tailwind CSS 4.3.0
- Introduced 2026-07-24 18:39, fixed in review at 18:51 the same day
この記事の根拠
- TypeScriptファイル 43〜90行目コミット 32c608d
- TypeScriptファイル 56〜117行目コミット 408053a
- TypeScriptファイル 456〜472行目コミット 32c608d
- TypeScriptファイル 459〜478行目コミット 408053a
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。