Munchable has a warm, slightly biscuity look that depends almost entirely on one effect: soft coloured circles that fade to nothing, sitting behind the real content. On the landing page there are fourteen of them, and the closing card at the bottom of every condition guide, for example the IBS and low FODMAP one, has four more.
On the website each one is a div:
.sd-blob,
.closing-blob,
.pm-blob {
position: absolute;
border-radius: 999px;
}
.sd-blob.gold,
.closing-blob.gold {
background: radial-gradient(
closest-side,
rgba(231, 172, 91, 0.6) 0%,
rgba(231, 172, 91, 0.32) 45%,
rgba(231, 172, 91, 0) 100%
);
}
closest-side is the load-bearing keyword. Without it the gradient's default ending shape reaches the furthest corner of the box, so on a square the colour is still at a third of its strength when it hits the edge and the circle cuts itself off with a visible seam. With it, the fade reaches zero exactly at the circle's own edge, and the thing has no border at all.
The app has to produce the same light, and React Native has no radial gradients in its style system.
The port
react-native-svg, one component, and the gradient written out by hand:
<RadialGradient id={`blob${uid}${i}`} cx="50%" cy="50%" r="50%">
<Stop offset="0" stopColor={b.color} stopOpacity={b.peak} />
<Stop offset="0.45" stopColor={b.color} stopOpacity={b.peak * 0.53} />
<Stop offset="1" stopColor={b.color} stopOpacity={0} />
</RadialGradient>
r="50%" on a circle is what closest-side buys you in CSS: the gradient ends where the shape ends.
The 0.53 is the only interesting number in the file. The CSS has three hardcoded alphas per colour, and they are not the same three for gold (0.6, 0.32, 0) and pink (0.55, 0.28, 0). The app's version takes a single peak per blob instead, because a card renders the same glow at full strength on a paywall and at a third of it on a quieter panel, and three independent alphas times a strength multiplier is three places for a rounding difference to show up as a different-looking product.
So the midpoint became a ratio of the peak. 0.32 / 0.6 is 0.533, and 0.28 / 0.55 is 0.509. One number covers both within a rounding error that nobody can see, and the shape of the falloff is now a property of the component rather than a property of each colour.
Decoration has to be genuinely absent
The two props I care about most in this component have nothing to do with how it looks:
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
// `pointerEvents` goes through the style, not the prop react-native
// deprecated, so a tap always lands on the control under the light.
style={[StyleSheet.absoluteFill, { pointerEvents: 'none' }]}
>
accessibilityElementsHidden is iOS, importantForAccessibility="no-hide-descendants" is Android, and you need both or you have a screen reader stopping on a decorative circle on one platform. The web side gets this for free, because a div with no text and no role is already nothing to a screen reader.
The pointerEvents comment is a small trap worth knowing. React Native deprecated the pointerEvents prop in favour of the style property, and the deprecated prop still works, so you can leave it in place for a long time without noticing that a full-screen absolutely positioned decoration is eating taps on the control underneath it. Putting it in the style is both the current API and the one that is harder to get wrong, because it travels with StyleSheet.absoluteFill in the same array.
The id collision you will hit eventually
// Unique per instance: two glows on one screen must not share gradient ids.
const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
SVG gradient ids are not scoped to a component. Two cards on one screen, both defining blob0, and the second definition wins for both, which shows up as one card quietly rendering the wrong colour. useId solves it, and the replace is there because React's generated ids contain colons, which are legal in an id attribute but a nuisance inside a url(#...) reference.
The comment that is now wrong
The component's doc comment says it has two jobs: the ambient wash behind a whole screen, and the warm panel a card is made of. One caller imports it. The screen wash was built, then deleted when the screens underneath it got busier, and the comment outlived the caller.
I am leaving the sentence in this post rather than quietly fixing it first, because it is the ordinary failure mode of a shared primitive: the justification for making it shared is written down once, the second consumer disappears, and the file now argues for an abstraction that one caller uses. It is still the right component. It just currently has a better argument on the website, where the same drawing exists in fourteen places and is maintained in two CSS rules.
Top comments (0)