Shadows

Bidirectional shadow transformation — including generating shadows from scratch when dark UIs have none.

The Challenge

Shadows are the hardest part of bidirectional theme generation. In light mode, shadows provide depth and elevation cues. In dark mode, shadows are nearly invisible and elevation is communicated through surface lightness instead. Nightfall handles both directions correctly.

Light to Dark: Adapting Shadows

When transforming light to dark, existing shadows need to become more prominent because they have less contrast against dark backgrounds:

  • Opacity increased by 3-5x — A shadow at rgba(0,0,0,0.05) becomes rgba(0,0,0,0.30)
  • Blur radius increased by 1.2-1.5x — Slightly softer to compensate for the increased opacity
  • Spread reduced slightly — Tighter shadows look more natural on dark surfaces
light-to-dark shadows
/* Original light shadows */
  --shadow-sm:  0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md:  0 4px 6px -1px rgba(0, 0, 0, 0.07),;
             0 2px 4px -2px rgba(0, 0, 0, 0.05);
  --shadow-lg:  0 10px 15px -3px rgba(0, 0, 0, 0.08),;
             0 4px 6px -4px rgba(0, 0, 0, 0.04);

/* Transformed dark shadows */
  --shadow-sm:  0 1px 3px rgba(0, 0, 0, 0.30);
  --shadow-md:  0 4px 8px -1px rgba(0, 0, 0, 0.40),;
             0 2px 5px -2px rgba(0, 0, 0, 0.30);
  --shadow-lg:  0 10px 20px -3px rgba(0, 0, 0, 0.50),;
             0 4px 8px -4px rgba(0, 0, 0, 0.25);

Dark to Light: Generating from Scratch

Dark UIs often have zero shadows because they are invisible on dark backgrounds. When generating a light theme from a dark source, Nightfall creates a complete shadow scale from scratch based on the elevation model:

shadow-generation.js
function generateShadowScale(elevationLevels) {
  // Map each elevation level to a shadow
  // Higher elevation = more prominent shadow
  return {
    xs:  '0 1px 2px rgba(0, 0, 0, 0.04)',
    sm:  '0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)',
    md:  '0 4px 6px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.04)',
    lg:  '0 10px 15px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.04)',
    xl:  '0 20px 25px rgba(0, 0, 0, 0.08), 0 8px 10px rgba(0, 0, 0, 0.04)',
    '2xl': '0 25px 50px rgba(0, 0, 0, 0.14)',
  };
}

Generated Shadow Scale

The generated shadow scale follows established conventions from Material Design and Tailwind CSS, using layered shadows for natural depth:

generated-shadows.css
/* Generated shadow scale for light theme */
  --shadow-xs:   0 1px 2px rgba(0, 0, 0, 0.04);
  --shadow-sm:   0 1px 3px rgba(0, 0, 0, 0.06),;
              0 1px 2px rgba(0, 0, 0, 0.04);
  --shadow-md:   0 4px 6px rgba(0, 0, 0, 0.05),;
              0 2px 4px rgba(0, 0, 0, 0.04);
  --shadow-lg:   0 10px 15px rgba(0, 0, 0, 0.06),;
              0 4px 6px rgba(0, 0, 0, 0.04);
  --shadow-xl:   0 20px 25px rgba(0, 0, 0, 0.08),;
              0 8px 10px rgba(0, 0, 0, 0.04);
  --shadow-2xl:  0 25px 50px rgba(0, 0, 0, 0.14);

Elevation-to-Shadow Mapping

Nightfall maps the dark theme's lightness-based elevation to shadow-based elevation in the light theme:

# Dark theme elevation (lightness-based)     → Light theme elevation (shadow-based)
# --bg-page     L=0.13 (base)                → --bg-page     white, no shadow
# --bg-surface  L=0.17 (+0.04 from base)     → --bg-surface  white + shadow-sm
# --bg-elevated L=0.21 (+0.08 from base)     → --bg-elevated white + shadow-md
# --bg-overlay  L=0.25 (+0.12 from base)     → --bg-overlay  white + shadow-lg

Multi-Layer Shadows

Each shadow level uses two layers: a larger ambient shadow and a smaller direct shadow. This produces a more natural, physically-inspired appearance compared to single-layer shadows:

/* Single layer (flat, unnatural) */
  --shadow-md:  0 4px 6px rgba(0, 0, 0, 0.10);

/* Two layers (natural, depth) */
  --shadow-md:  0 4px 6px rgba(0, 0, 0, 0.05),   /* ambient */;
             0 2px 4px rgba(0, 0, 0, 0.04);    /* direct */