Opacity Layers: Why Shadows Must Stay Opaque
Add a drop shadow to a blue rectangle. The shadow is 100% visible—black, solid.
Put that rectangle inside a parent frame with 50% opacity.
The shadow turns gray. Semi-transparent. Wrong.
The Problem
In Figma, when a node has 100% visible effects (shadows, blurs), those effects remain fully opaque relative to the node's content, even when the parent has reduced opacity.
Visual hierarchy:
Parent (50% opacity)
└─ Blue Rect (100% opacity, drop shadow at 100% visibility)
Expected: Shadow appears solid black between the blue rectangle and the background. The entire group (shadow + blue) renders at 50% opacity together.
What we got: Shadow at 50% opacity (gray), blue at 50% opacity. They composite separately.
The shadow looked washed out, like it wasn't really there.
First Attempt: Multiply Opacity Everywhere
Maybe just apply the parent's opacity to everything?
function drawNode(node, parentOpacity) {
const opacity = parentOpacity * node.opacity;
// Draw shadow with parent opacity
drawShadow(node.shadow, opacity); // ← Shadow at 50%
// Draw content with parent opacity
drawContent(node, opacity); // ← Content at 50%
}
This made shadows semi-transparent when parents had opacity < 1. A "100% visible" shadow at 50% parent opacity became 50% transparent gray, not solid black.
Visually incorrect—shadows should be opaque relative to their content.
Understanding Opacity Groups
The key insight: opacity affects groups, not individual elements.
Think of it like a stack of acetate sheets:
- Sheet 1: Shadow (opaque black)
- Sheet 2: Blue rectangle (opaque blue)
- Composite both sheets together → One combined image
- Then make that combined image 50% transparent
Not:
- Sheet 1: Shadow at 50% gray
- Sheet 2: Blue at 50% lighter blue
- Composite separately
The Rendering Pipeline
In Figma's model, rendering happens in layers:
1. Blend Mode / Opacity Layer (outermost)
└─ 2. Effect Layer (shadows, blurs)
└─ 3. Content (fills, strokes)
└─ 4. Children
Each layer serves a purpose:
Layer 1 - Compositing Layer: Created when node has opacity < 1.0 or blend mode ≠ normal
- Applies the node's own opacity only (not inherited)
- Composites the entire rendered result as one unit
Layer 2 - Effect Layer: Created when node has visible effects
- Draws shadows, blurs at full opacity (1.0)
- No parent opacity applied here
Layer 3 - Content: Fills and strokes
- Opacity depends on which layers are active (see below)
The Solution: Nested Layers with Context-Aware Opacity
Track which layers we're inside and adjust opacity accordingly:
class SkiaRenderer {
private insideCompositingLayer = false;
private insideEffectLayer = false;
drawNode(node, accumulatedOpacity) {
const nodeOpacity = node.opacity ?? 1.0;
const opacity = accumulatedOpacity * nodeOpacity;
// Start compositing layer if needed
const needsLayer = nodeOpacity < 1.0 || node.blendMode !== 'NORMAL';
if (needsLayer) {
this.startCompositingLayer(nodeOpacity);
this.insideCompositingLayer = true;
}
// Start effect layer if needed
if (node.hasEffects) {
this.startEffectLayer(); // Always at opacity 1.0
this.insideEffectLayer = true;
}
// Draw content with appropriate opacity
const effectiveOpacity = this.getEffectiveOpacity(
opacity,
nodeOpacity
);
this.drawContent(node, effectiveOpacity);
// End layers
if (node.hasEffects) {
this.endEffectLayer();
this.insideEffectLayer = false;
}
if (needsLayer) {
this.endCompositingLayer();
this.insideCompositingLayer = false;
}
}
}
The Key: Context-Aware Opacity
The tricky part is determining what opacity to use for drawing content. Here's the decision process:
Question 1: Are we inside a compositing layer?
If YES → effectiveOpacity = 1.0
Why? The compositing layer already has the node's opacity set.
Drawing at 1.0 inside, then compositing at node opacity = correct result.
If NO → Go to Question 2
Question 2: Are we inside an effect layer?
If YES → effectiveOpacity = parentOpacity only
Why? Effect layer has no opacity set, so shadows draw at 1.0.
Content needs parent opacity to match the shadow.
If NO → effectiveOpacity = accumulatedOpacity (parent × node)
Why? No layers at all, so apply all opacity directly to drawing.
In code:
function getEffectiveOpacity(accumulatedOpacity, nodeOpacity) {
// Extract parent's contribution
const parentOpacity = accumulatedOpacity / nodeOpacity;
// Decision tree:
if (this.insideCompositingLayer) {
return 1.0; // Layer handles it
}
else if (this.insideEffectLayer) {
return parentOpacity; // Match shadow opacity
}
else {
return accumulatedOpacity; // Direct application
}
}
Example calculation:
Parent opacity: 0.5
Node opacity: 1.0
Accumulated: 0.5 × 1.0 = 0.5
Inside effect layer (no compositing layer):
parentOpacity = 0.5 / 1.0 = 0.5
effectiveOpacity = 0.5 ✓
Shadow draws at 1.0 (always)
Content draws at 0.5 (matches shadow visually)
Group composites together, both at "full" internal opacity
Final result: 50% opacity applied to entire group
Why This Works
Case 1: Node with opacity < 1.0
startCompositingLayer(0.5) // ← Node's own opacity
insideCompositingLayer = true
drawContent(effectiveOpacity = 1.0) // ← Draw at full, layer handles opacity
endCompositingLayer() // ← Composite at 50%
Case 2: Node with effects, parent has opacity
Parent opacity: 0.5
Node opacity: 1.0
startEffectLayer() // ← No compositing layer
insideEffectLayer = true
drawShadow(opacity = 1.0) // ← Shadow at full opacity
drawContent(effectiveOpacity = 0.5) // ← Parent opacity only
endEffectLayer() // ← Composite together
Result: Shadow opaque, content at 50%, group composited together
Case 3: Node with effects AND opacity < 1.0, parent has opacity
Parent opacity: 0.5
Node opacity: 0.5
startCompositingLayer(0.5) // ← Node's opacity
insideCompositingLayer = true
startEffectLayer()
insideEffectLayer = true
drawShadow(opacity = 1.0) // ← Full opacity
drawContent(effectiveOpacity = 1.0) // ← Full opacity (inside compositing layer)
endEffectLayer() // ← Shadow + content at full opacity
endCompositingLayer() // ← Composite at 50% (node)
Parent composites this at 50% → Final: 0.5 × 0.5 = 0.25
Concrete Example: Siblings with Shadows
Parent frame (50% opacity) contains two rectangles. Blue rectangle on top has a drop shadow that should fall on the red rectangle below.
Parent (opacity=0.5)
├─ Blue Rect (drop shadow, 100% visible)
└─ Red Rect (behind blue)
Without proper grouping (wrong):
drawShadow(opacity=0.5) // Gray shadow
drawBlueRect(opacity=0.5) // Light blue
drawRedRect(opacity=0.5) // Light red
Result: Gray shadow barely visible over red
With opacity grouping (correct):
startCompositingLayer(0.5) // Parent's opacity
drawShadow(opacity=1.0) // Black shadow
drawBlueRect(opacity=0.5) // Blue at parent opacity
drawRedRect(opacity=0.5) // Red at parent opacity
endCompositingLayer() // Composite entire scene at 50%
Result: Black shadow clearly visible over red, entire scene at 50%
The shadow is opaque black in the layer buffer, fully covering the red rectangle. Then the entire composited result (shadow + blue + red) gets rendered at 50% opacity.
Why Shadows Must Be Opaque
Imagine a physical light casting a shadow:
- The shadow is always 100% black (where light is blocked)
- If you put the scene behind frosted glass (opacity), everything gets faded equally
- The shadow stays black relative to the scene, just the whole thing is dimmer
That's what we're simulating: shadows at full opacity within the group, then the group fades together.
Implementation Details
The actual opacity calculation extracts parent vs node contributions:
const nodeOpacity = node.opacity ?? 1.0;
const parentOpacity = accumulatedOpacity / nodeOpacity;
Example:
accumulatedOpacity = 0.25 (parent 0.5 × node 0.5)
nodeOpacity = 0.5
parentOpacity = 0.25 / 0.5 = 0.5 (right)
This separation lets us apply:
- Node opacity → compositing layer
- Parent opacity → content drawing (when no compositing layer)
- Both → layered composition
Results
Shadows now render correctly:
- 100% visible shadows remain opaque relative to content
- Parent opacity affects the entire group uniformly
- Multiple effects composite together before opacity is applied
- Nested opacity multiplies correctly (0.5 × 0.5 = 0.25)
The fix was ~30 lines of layer tracking and ~10 lines of opacity calculation logic. The hard part was understanding when to apply which opacity value.
Opacity isn't a per-element property—it's a grouping operation.
Read next: Why path.transform() Killed Performance - CPU bottlenecks and GPU-side transforms.