ryer.io

Shadows and Contrast: Where the Design System Stopped Helping

TL;DR

  • Teal theme made scores modal text nearly invisible — white plus opacity doesn’t survive a theme change.
  • I’d bumped opacity from 40% to 75% while testing the blue theme and never rechecked the others.
  • Android doesn’t do shadowOffset; it does elevation, and identical styling produces nothing.
  • React Native 0.80’s filter-based drop shadow finally makes Android images match iOS.
  • React Native SVG doesn’t support the new architecture, so SVGs need wrapping in a view to take the filter.

A day of UI work that kept turning into platform archaeology.

Opacity isn’t a colour

Our product owner flagged that the scores modal in the teal theme had text you basically couldn’t read. Figma didn’t have teal-specific instructions, so it wasn’t obvious why.

The trail: Figma specified white at 40% opacity. I’d raised it to 75% while improving visibility during blue theme testing, and never rechecked red or green. My reasoning at the time was that a design system built on reusable colour principles — neutral 500 across all themes — makes per-theme testing redundant.

That reasoning was wrong in a specific way worth remembering. The design system does guarantee consistency, but only for values that come from the system. White-plus-opacity isn’t a system colour; it’s a computed result that depends on whatever sits behind it. The guarantee never applied.

The fix is to stop using opacity tricks and control colour explicitly through the standardised design system shades, where the mapping is predefined and a theme change can’t silently alter the result.

Worth automating too — contrast checks in our Jest setup, similar to what browsers do, would catch this class of thing without anyone manually cycling themes.

Contrast in modals

Related symptom: AccomplishedResultsModal lacked the contrast that the DailyAssess screen had. My hypothesis was that the assess screen’s darker background made the same contrast read as sharper against the modal’s lighter backdrop.

To verify, I forced the modal to render directly inside the DailyAssess tab. I nested it wrong at first, corrected it, and confirmed the theory. Checking the background colours against Figma and correcting the shades solved part of the problem.

Android shadows are a different system

Trying to pixel-match the rest surfaced the real time sink. I spent about 45 minutes trying to make Android shadows look like iOS shadows by adjusting opacity, shadowColor and radius. Identical styling produced barely visible shadows or none at all.

Android doesn’t implement explicit offset controls. It uses elevation, a single value approximating height above the surface, and no amount of iOS shadow properties will substitute. Adding an elevation of 5 finally made something appear, though not at the intensity I wanted.

Comparing against legacy components like card box showed the padding and elevation values in the older implementations differed from what I’d been writing, which was another piece of it. My workaround was applying shadowOffset and elevation per card box element, and building looser custom shadows for higher elevation on Android to bridge the gap.

React Native 0.80 fixes the image case

The genuinely good news. RN 0.80 supports filter-based drop shadows on Android, so images can finally have real shadows instead of elevation approximations — elevation never looked right on images.

Two wrinkles:

  • I applied it conditionally for Android only, keeping iOS on its existing path.
  • React Native SVG doesn’t support the new architecture yet, so any SVG needing a shadow gets wrapped in a view that carries the filter style prop instead.

Testing across user profiles needed a logout and login cycle to validate properly.

Where it stands

Not everything is finished — shadow fixes still need applying consistently across themes, and I’ll be iterating on that. But the important shift is conceptual: I’d been treating shadows and contrast as styling values that should transfer between platforms, when they’re platform-specific systems that happen to produce similar-looking results. Writing one and expecting the other to match was never going to work.