Skip to content

feat(CSS): support animating backgroundImage gradients - #10193

Merged
tshmieldev merged 22 commits into
software-mansion:mainfrom
Titozzz:claude/reanimated-backgroundimage-support-as2wrl
Sep 23, 2026
Merged

tshmieldev merged 22 commits into
software-mansion:mainfrom
Titozzz:claude/reanimated-backgroundimage-support-as2wrl

Conversation

@Titozzz

@Titozzz Titozzz commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Note

This pull request was authored by AI on behalf of @Titozzz and @tshmieldev.

Summary

Adds backgroundImage (linear and radial gradients) to CSS animations and CSS transitions, on native and on the Web. Fixes #8297.

The JS processor from the original version of this PR landed separately in #10486 (it also enabled gradients in useAnimatedStyle). This PR now contains the CSS engine part on top of current main, merged from #10664, which it supersedes.

Native

  • New value type CSSGradient (Common/cpp/reanimated/CSS/common/values/complex/CSSGradient.{h,cpp}) registered as array({value<CSSGradient>(CSSGradient())}), one interpolator per layer, like boxShadow. It reads and emits the exact shape produced by processBackgroundImage, so React Native parses the animated value with its existing backgroundImage parser on both platforms. Gradient type, radial shape and radial size keyword are enums; a length is a {value, isPercent} struct that serializes back to a number or a "N%" string.
  • Both prop names are supported: backgroundImage (registered for React Native 0.87 and newer, where the name exists) and experimental_backgroundImage (registered unconditionally; React Native 0.87 and 0.88 still read it as an alias). Both are separately interpolated nested properties, so keyframes are split per layer index.
  • Interpolation rules (from CSS Images 4 §7.4 and §3.5.3 where the spec has an answer):
    • same-type gradients interpolate colors, color stop positions, the angle direction, and the radial size and position per component;
    • a shorter color stop list is padded by repeating its last stop, anchored at its resolved position, so red, blue → red, blue, lime animates instead of snapping;
    • missing stop positions are resolved with the CSS color stop fixup (first 0%, last 100%, runs spaced evenly) before interpolation, only when the surrounding stops use percentages; a stop that is null in both keyframes stays null in the output so React Native resolves it;
    • a layer present in only one keyframe interpolates against an empty "none" gradient, i.e. it fades in from or out to transparent;
    • pairs that cannot be interpolated switch at the midpoint of the animation (FALLBACK_INTERPOLATION_THRESHOLD): linear ↔ radial, keyword ↔ angle direction, different keywords, shape mismatch, size keyword ↔ explicit size, px ↔ %, transition hint ↔ color stop at the same index.
  • Layers pair by array index, so a layer that exists in only one keyframe has to be last.
  • The JS processor rejects non-finite numbers and negative radial sizes in the object form, matching what the CSS string parser already did.

Web

  • processBackgroundImageWeb turns the object form into a CSS linear-gradient() / radial-gradient() string (strings pass through), with the same defaults as the native processor. A circle with an explicit size is emitted as circle max(x, y)px (what iOS renders); a circle with a percentage size, which CSS does not allow, is emitted as ellipse x y. experimental_backgroundImage is emitted under background-image.
  • Browsers treat background-image as a discrete animation type, so on the Web the gradient switches at the midpoint between keyframes; the docs say so.

Related

Test plan

  • Example screen: apps/common-app → CSS → Animations → Animated properties → Others → Background Image (Linear / Radial / Layers tabs). Checked on the Android emulator (Pixel 9 Pro, API 37), the iPhone 17 simulator, and Expo web.

  • Runtime tests (new suite css/backgroundImage.test.tsx, 8 cases; it pins a paused animation at a known progress with a negative animationDelay and reads the parsed native prop back with getViewProp):

    yarn workspace fabric-example runtime-tests --library reanimated --platform android --avd <avd> --only "css animations"

    10/10 on Android after every native change in this PR.

  • Jest: yarn workspace react-native-reanimated jest backgroundImage registry keyframes (native processor incl. validation cases, web processor incl. circle and default cases, nested properties registry, keyframes normalization).

  • yarn workspace react-native-reanimated type:check, type:check:legacy-rn-types, lint:js; clang-format v19, scripts/validate-common.sh and validate-includes on the C++.

Minimal snippet:

<Animated.View
  style={{
    width: 120,
    height: 120,
    animationName: {
      from: { backgroundImage: 'linear-gradient(0deg, cyan, blue)' },
      to: { backgroundImage: 'linear-gradient(360deg, red, yellow)' },
    },
    animationDuration: '2s',
    animationIterationCount: 'infinite',
    animationTimingFunction: 'linear',
  }}
/>

Expected: the gradient rotates a full turn while its colors blend, on both platforms. On React Native 0.86 use experimental_backgroundImage.

Changelog

  • I added an entry to the Unpublished section of each changed package's CHANGELOG.md, or this PR does not change react-native-reanimated or react-native-worklets.

claude added 4 commits August 6, 2026 15:35
…tion

Adds proper support for animating the backgroundImage style prop
(and its experimental_backgroundImage alias) in CSS animations,
CSS transitions and useAnimatedStyle, which was previously stripped
from all animated styles (backgroundImage: false in the style config).

JS side:
- New processBackgroundImage value processor that normalizes both the
  CSS string syntax (linear-gradient(...)/radial-gradient(...)) and the
  object syntax into the processed structure React Native's
  backgroundImage prop parsing expects, mirroring RN's
  processBackgroundImage (colors run through processColor so
  interpolation works)
- backgroundImage and experimental_backgroundImage wired into
  STYLE_PROPERTIES_CONFIG and registered as separately interpolated
  nested properties so each gradient layer animates independently
- Web: processBackgroundImageWeb serializes the object syntax back to a
  CSS gradient string (strings pass through unchanged)

Native side:
- New CSSLinearGradient/CSSRadialGradient CSS value types that
  interpolate gradients smoothly when they are compatible (same
  direction kind, shape, units and number of color stops): colors, stop
  positions, angles, sizes and positions all animate; incompatible
  gradients fall back to the standard discrete flip
- Registered in the interpolator registry for both prop names

Also adds unit tests (processor, web serializer, keyframes
normalization), an example screen in the common app and a
supported-properties docs entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p2PjxNunbHriH5853qssK
CSSValueVariant and SimpleValueInterpolator define their member functions
in .cpp files with explicit instantiation lists, so the new
<CSSLinearGradient, CSSRadialGradient> combination used by the
backgroundImage interpolator must be instantiated there as well -
otherwise the native build fails with undefined symbol link errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p2PjxNunbHriH5853qssK
The radial gradient CSS string parser consumed the token following a
single explicit size while looking for a second size value and dropped
it when it wasn't a length. When that token was 'at', the whole position
clause was swallowed and its values were re-parsed as a new size
(e.g. 'circle 50% at 25% 25%' produced size 25%/25% with the default
center position). Push the token back so the loop re-processes it.

This bug exists in React Native's own processBackgroundImage as well -
the parser here now handles the '<size> at <position>' combination
correctly.

Also:
- reject gradient strings with trailing characters after the closing
  parenthesis instead of silently accepting them
- use valid px circle sizes in the "Moving highlight" example
  ('circle 50%' is rejected by browsers, so it would break on web)
- document the RN 0.87 / experimental_backgroundImage naming and the
  smooth vs discrete interpolation rules in supported-properties

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p2PjxNunbHriH5853qssK
Ports the two upstream react-native processBackgroundImage changes
(react/react-native#57873 and react/react-native#57874) into the
backgroundImage processor to keep both parsers in sync:

- #57873 (position dropped after an explicit size) was already fixed
  here; align the code comment with upstream and use a px-sized circle
  in the regression test
- #57874: reject a percentage radius for circle radial gradients
  (explicit 'circle 50%' and the inferred circle from a single '50%'
  size). Per the CSS spec a circle radius must be a <length>;
  percentages remain valid for ellipse sizes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p2PjxNunbHriH5853qssK
@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 79fe9c20-64c3-4def-a0c6-609c48718854

📥 Commits

Reviewing files that changed from the base of the PR and between 2d01583 and cf2b91a.

📒 Files selected for processing (5)
  • docs/docs-reanimated/docs/guides/supported-properties.mdx
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/complex/CSSGradient.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/complex/CSSGradient.h
  • packages/react-native-reanimated/src/common/style/registry.ts
💤 Files with no reviewable changes (1)
  • docs/docs-reanimated/docs/guides/supported-properties.mdx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-native-reanimated/src/common/style/registry.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The change adds native parsing and interpolation for linear and radial CSS gradients used by backgroundImage and experimental_backgroundImage. It enables style processing for both property names and adds web serialization of gradient values. The change also adds validation, documentation, animation examples, and tests for gradient processing and interpolation.

Priority: ➖ Normal

Change: Feature · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to cf2b9

Gradient background-image support is merge-ready; the web serializer preserves native circle sizing for explicit numeric dimensions.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #8297 requires animated experimental_backgroundImage linear gradients without the Android update crash. The PR enables the experimental property in style processing, registers it with the nati…
Out of Scope Changes check ✅ Passed The changes remain within the gradient animation feature related to issue #8297. Web serialization, CSS animation and transition support, alias handling, parser validation, documentation, examples, an…
Title check ✅ Passed The title clearly and concisely describes the main change: adding support for animating backgroundImage gradients in CSS.
Description check ✅ Passed The description directly explains the backgroundImage gradient support, native and web behavior, interpolation rules, tests, examples, and related issue.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Caution

CodeRabbit couldn't update its existing comment. The review summary may be out of date.

Error details
putComment timed out

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/base/appearance/BackgroundImage.tsx`:
- Around line 1-5: In the BackgroundImage animation examples, replace the legacy
experimental_backgroundImage keyframe properties with backgroundImage across all
eight keyframes and remove the file-wide camelcase ESLint disable. Only retain a
narrowly scoped disable if one example intentionally continues covering the
legacy alias.

In
`@packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/complex/CSSBackgroundImage.cpp`:
- Around line 72-81: Replace the fixed six-decimal std::to_string formatting
with a shared compact formatNumber helper in the anonymous namespace, adding the
required iomanip and sstream includes. Use formatNumber in
GradientLengthPercentage::toDynamic() and toString(), and update the angle
formatting around the existing line-240 serialization to use it as well,
preserving percent and CSS unit suffixes.

In
`@packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/complex/CSSBackgroundImage.h`:
- Around line 6-10: Add a direct <ostream> include to CSSBackgroundImage.h
alongside the standard library headers so the std::ostream declarations used by
its debug stream operators are self-contained, without relying on folly/json.h
transitive includes.

In
`@packages/react-native-reanimated/Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp`:
- Around line 210-211: The backgroundImage and experimental_backgroundImage
entries in InterpolatorRegistry must not use CSSLinearGradient() as the default
padding value. Replace it with a transparent non-gradient default, or enforce
matching layer counts so an empty colorStops gradient is never serialized or
sent to React Native.

In
`@packages/react-native-reanimated/src/common/style/processors/__tests__/backgroundImage.test.ts`:
- Around line 336-354: The radial-gradient object test permits an invalid
percentage radius for a circular shape. Update the validation handling in
backgroundImage.ts for the object path, then change this test’s shape to ellipse
so the custom percentage/number radii remain valid, or classify the case as
invalid if that matches the intended validation rules.
- Around line 214-226: Update getPositionFromCSSValue to reject malformed length
values that parseFloat converts to NaN, then extend the invalid-input test.each
cases with linear-gradient(red xpx, blue) and radial-gradient(xpx, red, blue),
ensuring both throw through processBackgroundImage.

In
`@packages/react-native-reanimated/src/common/style/processors/backgroundImage.ts`:
- Around line 771-794: The object-based radial gradient handling in
processBackgroundImageObjects must reuse CSS-path validation: validate size.x,
size.y, and position as non-negative lengths or percentages, apply the circle
percentage-radius restriction from the CSS path, and report invalid positions
through ERROR_MESSAGES.invalidGradientPosition. In
packages/react-native-reanimated/src/common/style/processors/__tests__/backgroundImage.test.ts:336-354,
update the custom-values case to use an ellipse; in :356-372, add a circle with
a percentage radius to the invalid inputs.
- Around line 156-165: Update getPositionFromCSSValue to validate the numeric
portion of both px and percentage values before returning them; reject malformed
inputs such as “xpx” and “a%” by returning null, while preserving valid pixel
numbers and percentage strings.
- Around line 617-624: Update the radial-gradient prelude parsing loop around
hasShapeSizeOrPositionString so an unrecognized token after a recognized shape,
size, or position token throws instead of breaking and discarding the prelude.
Also reject multiple lengths when the shape is circle, allowing only a single
circle radius; preserve the existing color-stop detection for an unrecognized
first token.

In
`@packages/react-native-reanimated/src/common/web/style/processors/backgroundImage.ts`:
- Around line 52-55: Update the color-stop processing around processColor in the
background-image processor to avoid String(color) fallbacks. When
processColor(color) is not a string, skip that stop or throw the established
Reanimated error; preserve the existing formatted output only for valid string
colors.
- Around line 45-51: Update the color-stop serialization in the background-image
processor to pass numeric entries from positions through the existing
maybeAddSuffix helper before joining them, while preserving existing units and
nonnumeric values. Apply this to both transition-hint and regular color-stop
output paths.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dbd4b119-a81c-4063-b355-9efac67e07cb

📥 Commits

Reviewing files that changed from the base of the PR and between 82a3e16 and 315a621.

📒 Files selected for processing (21)
  • apps/common-app/src/apps/css/examples/animations/routes/properties/base.ts
  • apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/base/appearance/BackgroundImage.tsx
  • apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/base/appearance/index.ts
  • docs/docs-reanimated/docs/guides/supported-properties.mdx
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/CSSValueVariant.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/complex/CSSBackgroundImage.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/complex/CSSBackgroundImage.h
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/interpolation/values/SimpleValueInterpolator.cpp
  • packages/react-native-reanimated/src/common/style/config.ts
  • packages/react-native-reanimated/src/common/style/processors/__tests__/backgroundImage.test.ts
  • packages/react-native-reanimated/src/common/style/processors/backgroundImage.ts
  • packages/react-native-reanimated/src/common/style/processors/colors.ts
  • packages/react-native-reanimated/src/common/style/processors/index.ts
  • packages/react-native-reanimated/src/common/style/registry.ts
  • packages/react-native-reanimated/src/common/web/style/config.ts
  • packages/react-native-reanimated/src/common/web/style/processors/__tests__/backgroundImage.test.ts
  • packages/react-native-reanimated/src/common/web/style/processors/backgroundImage.ts
  • packages/react-native-reanimated/src/common/web/style/processors/index.ts
  • packages/react-native-reanimated/src/css/native/__tests__/registry.test.ts
  • packages/react-native-reanimated/src/css/native/normalization/animation/__tests__/keyframes.test.ts

Comment thread packages/react-native-reanimated/src/common/style/processors/backgroundImage.ts Outdated
Comment thread packages/react-native-reanimated/src/common/style/processors/backgroundImage.ts Outdated
@Titozzz

Titozzz commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Codex review finding (P2): explicit circle sizes serialize to invalid CSS on web

I’m Codex reviewing this PR. In processBackgroundImageWeb, an object-form radial size is always serialized as both size.x and size.y. For example:

{
  type: 'radial-gradient',
  shape: 'circle',
  size: { x: 100, y: 100 },
  colorStops: [{ color: 'red' }, { color: 'blue' }],
}

becomes radial-gradient(circle 100px 100px, red, blue).

The CSS radial-gradient grammar permits a single non-negative <length> for an explicit circle radius; two <length-percentage> values describe an ellipse. Consequently, browsers reject the generated circle declaration. The existing web test also expects circle 100px 50%, which is invalid for the same reason.

Relevant code:

if (size) {
if (typeof size === 'string') {
parts.push(size);
} else {
parts.push(
`${maybeAddSuffix(size.x, 'px')} ${maybeAddSuffix(size.y, 'px')}`
);

CSS specification: https://drafts.csswg.org/css-images/#radial-gradients

Please branch on shape: serialize one valid circle radius where the {x, y} semantics can be represented, or reject/document combinations that cannot be represented faithfully on the web. The test should verify that the resulting value is accepted by the browser/CSS parser, rather than only comparing the generated string.

…n web

The web serializer emitted both axis values for circle sizes
(e.g. 'circle 100px 100px'), but the CSS radial-gradient grammar only
allows a single non-negative length as an explicit circle radius - two
values describe an ellipse and a percentage radius is invalid, so
browsers rejected the whole declaration. Serialize an equal-axis length
circle size as a single radius and throw for combinations that cannot
be represented as a valid circle (unequal axes or percentages).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p2PjxNunbHriH5853qssK

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 84550791-9219-424c-b475-3b404102acef

📥 Commits

Reviewing files that changed from the base of the PR and between 8277134 and 26c0f93.

📒 Files selected for processing (22)
  • apps/common-app/runtime-tests/reanimated/suites.ts
  • apps/common-app/runtime-tests/reanimated/tests/css/backgroundImage.test.tsx
  • apps/common-app/src/apps/css/examples/animations/routes/properties/base.ts
  • apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/base/appearance/others/BackgroundImage.tsx
  • apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/base/appearance/others/index.ts
  • docs/docs-reanimated/docs/guides/supported-properties.mdx
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/CSSValueVariant.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/complex/CSSGradient.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/common/values/complex/CSSGradient.h
  • packages/react-native-reanimated/Common/cpp/reanimated/CSS/interpolation/values/SimpleValueInterpolator.cpp
  • packages/react-native-reanimated/changelog/bgimg-css.feature.md
  • packages/react-native-reanimated/src/PropsRegistryGarbageCollector.ts
  • packages/react-native-reanimated/src/common/style/config.ts
  • packages/react-native-reanimated/src/common/style/processors/__tests__/backgroundImage.test.ts
  • packages/react-native-reanimated/src/common/style/processors/backgroundImage.ts
  • packages/react-native-reanimated/src/common/style/registry.ts
  • packages/react-native-reanimated/src/common/web/style/config.ts
  • packages/react-native-reanimated/src/common/web/style/processors/__tests__/backgroundImage.test.ts
  • packages/react-native-reanimated/src/common/web/style/processors/backgroundImage.ts
  • packages/react-native-reanimated/src/css/native/__tests__/registry.test.ts
  • packages/react-native-reanimated/src/css/native/normalization/animation/__tests__/keyframes.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/react-native-reanimated/src/common/style/registry.ts
  • docs/docs-reanimated/docs/guides/supported-properties.mdx

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

Comment thread docs/docs-reanimated/docs/guides/supported-properties.mdx Outdated
Comment thread packages/react-native-reanimated/src/common/style/registry.ts
Comment thread packages/react-native-reanimated/src/common/style/registry.ts
@tshmieldev

Copy link
Copy Markdown
Member

Thanks @Titozzz!

@tshmieldev
tshmieldev merged commit 20d6c94 into software-mansion:main Sep 23, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

experimental_backgroundImage: linear-gradient crash on update

4 participants