Skip to content

fix: install glass effect only once the view is visible - #49

Open
vilindberg wants to merge 1 commit into
callstack:mainfrom
vilindberg:fix/glass-effect-skipped-while-view-is-transparent
Open

vilindberg wants to merge 1 commit into
callstack:mainfrom
vilindberg:fix/glass-effect-skipped-while-view-is-transparent

Conversation

@vilindberg

@vilindberg vilindberg commented Sep 23, 2026 •

Copy link
Copy Markdown

Summary

A LiquidGlassView that fades in from opacity: 0 - directly, or through any ancestor - renders as a plain view for the rest of its life, even once it is fully opaque.

UIKit silently refuses to build a UIGlassEffect for a view whose composited alpha is effectively zero, and never retries. Since the effect is created on the first layout pass, a view laid out while its fade is still at 0 misses its only chance to get one.

This is easy to hit: any entrance animation, any screen that mounts behind a cross-fade, any Animated.View wrapper starting at 0.

Before / after

Same example app, same JS bundle, same simulator - only the native change differs. The top row is a control that mounts at opacity: 1; the rows below fade 0 → 1 on mount.

comparison

Before - unpatched

before.mp4

After - patched

after.mp4

In the "before" column the labels still render - the views are present and laid out correctly. Only the glass material is missing.

Reproduction

function FadingCard() {
  const opacity = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    Animated.timing(opacity, {
      toValue: 1,
      duration: 500,
      useNativeDriver: true,
    }).start();
  }, [opacity]);

  return (
    <Animated.View style={{ opacity }}>
      <LiquidGlassView style={styles.card} effect="regular" />
    </Animated.View>
  );
}

The card ends at opacity: 1 with no glass material. Remove the Animated.View wrapper and it renders correctly.

The fix

Before creating the effect, walk the superview chain multiplying alpha. If the composited opacity is at or below 0.02, or any ancestor is hidden, defer and start a CADisplayLink that re-checks each tick and sets the view up once it becomes visible.

The guard sits in setupView() rather than layoutSubviews(), since setupView() is also reached from prop updates - that way a style change on a view that is still transparent is covered by the same path.

The link is held through a weak proxy so it never retains the view, and is invalidated once the effect lands, on teardownEffect (window change) and in deinit. It runs at 30Hz rather than the display's full rate: a view that never becomes visible would otherwise keep waking the main thread at 120Hz on a ProMotion screen for nothing, and 30Hz is imperceptible inside a fade.

Only the install is deferred. An existing effect survives the view returning to zero and coming back, so nothing needs to keep watching - verified by looping a mounted view 1 → 0 → 1:

roundtrip

That is why the link is stopped as soon as the effect lands.

Test plan

  • yarn typecheck - clean
  • yarn lint - clean
  • Example app on an iPhone 17 Pro simulator (iOS 26), built with and without the change against an identical JS bundle: every faded view is flat before and carries its material after, while the opaque control row is identical in both.
  • Covered a view whose effect flips none → regular while it is still transparent.
  • Confirmed the effect is not lost when a mounted view cycles back to opacity: 0 and returns.

CONTRIBUTING.md documents yarn test, but there is no test script in package.json and no test files in the repo, so there was nothing to extend. Happy to add a harness if you'd like one.

Notes

  • The 0.02 threshold is a small epsilon rather than a tuned value; > 0 also works, but a little slack avoids re-running setup for alphas that still will not render.
  • iOS/tvOS only. No JS API change, no new props, nothing to document.

UIKit silently skips a UIGlassEffect created while the view is effectively
transparent, and never retries it. A LiquidGlassView that fades in from
opacity 0 - directly or through any ancestor - therefore renders as a plain
view for the rest of its life, even after it is fully opaque.

Before creating the effect, walk the superview chain multiplying alpha. If the
composited opacity is at or below 0.02, or any ancestor is hidden, defer and
start a CADisplayLink that re-checks each tick and sets the effect up once the
view becomes visible.

The guard sits in setupView() rather than layoutSubviews() so that a style
change on a view that is still transparent is covered too.

The link is held through a weak proxy so it never retains the view, runs at
30Hz rather than the display's full rate because a view that never becomes
visible would otherwise keep waking the main thread at 120Hz, and is
invalidated once the effect lands, on teardown and in deinit. Only the install
is deferred - an existing effect survives the view returning to zero.
@vilindberg
vilindberg marked this pull request as ready for review September 23, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant