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:

Not:

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

Layer 2 - Effect Layer: Created when node has visible effects

Layer 3 - Content: Fills and strokes

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:

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:

Results

Shadows now render correctly:

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.