From cc149d788eb0de5d609fbe23d4f0e8909bc7bf52 Mon Sep 17 00:00:00 2001 From: Marcin Szalski Date: Fri, 16 Oct 2026 09:47:14 +0200 Subject: [PATCH] feat: add backdrop-filter js support --- .../View/ReactNativeStyleAttributes.js | 9 + .../NativeComponent/BaseViewConfig.android.js | 2 + .../NativeComponent/BaseViewConfig.ios.js | 2 + .../Libraries/StyleSheet/StyleSheetTypes.js | 1 + .../__tests__/processBackdropFilter-itest.js | 330 ++++++++++++++ .../BackdropFilter/BackdropFilterExample.js | 410 ++++++++++++++++++ .../js/utils/RNTesterList.android.js | 5 + .../rn-tester/js/utils/RNTesterList.ios.js | 5 + 8 files changed, 764 insertions(+) create mode 100644 packages/react-native/Libraries/StyleSheet/__tests__/processBackdropFilter-itest.js create mode 100644 packages/rn-tester/js/examples/BackdropFilter/BackdropFilterExample.js diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index e869e9fa41d5..fa51349903b7 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -40,6 +40,10 @@ export const filterAttribute: AnyAttributeType = nativeCSSParsing ? true : {process: processFilter}; +export const backdropFilterAttribute: AnyAttributeType = nativeCSSParsing + ? true + : {process: processFilter}; + export const boxShadowAttribute: AnyAttributeType = nativeCSSParsing ? true : {process: processBoxShadow}; @@ -181,6 +185,11 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = { */ filter: filterAttribute, + /** + * BackdropFilter + */ + backdropFilter: backdropFilterAttribute, + /** * MixBlendMode */ diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js index f25ee8cecb3a..3a2093d8f464 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js @@ -12,6 +12,7 @@ import type {PartialViewConfigWithoutName} from './PlatformBaseViewConfig'; import ReactNativeStyleAttributes from '../Components/View/ReactNativeStyleAttributes'; import { + backdropFilterAttribute, backgroundImageAttribute, backgroundPositionAttribute, backgroundRepeatAttribute, @@ -223,6 +224,7 @@ const validAttributesForNonEventProps = { backgroundRepeat: backgroundRepeatAttribute, experimental_backgroundRepeat: backgroundRepeatAttribute, boxShadow: boxShadowAttribute, + backdropFilter: backdropFilterAttribute, filter: filterAttribute, mixBlendMode: true, isolation: true, diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js index 80c413a7c1d0..9f13c8cd2f9b 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js @@ -12,6 +12,7 @@ import type {PartialViewConfigWithoutName} from './PlatformBaseViewConfig'; import ReactNativeStyleAttributes from '../Components/View/ReactNativeStyleAttributes'; import { + backdropFilterAttribute, boxShadowAttribute, colorAttribute, filterAttribute, @@ -251,6 +252,7 @@ const validAttributesForNonEventProps = { collapsable: true, collapsableChildren: true, filter: filterAttribute, + backdropFilter: backdropFilterAttribute, boxShadow: boxShadowAttribute, mixBlendMode: true, isolation: true, diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index 0e926394122a..cef1e7799672 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -890,6 +890,7 @@ export type ____ViewStyle_InternalBase = Readonly<{ cursor?: CursorValue, boxShadow?: ReadonlyArray | string, filter?: ReadonlyArray | string, + backdropFilter?: ReadonlyArray | string, mixBlendMode?: ____BlendMode_Internal, backgroundImage?: ReadonlyArray | string, experimental_backgroundImage?: ReadonlyArray | string, diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackdropFilter-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackdropFilter-itest.js new file mode 100644 index 000000000000..9a091b8c6f1f --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackdropFilter-itest.js @@ -0,0 +1,330 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {FilterFunction} from '../StyleSheetTypes'; + +import processColor from '../processColor'; + +const processFilter = require('../processFilter').default; + +describe('processBackdropFilter', () => { + testStandardFilter('brightness'); + testStandardFilter('opacity'); + testStandardFilter('contrast'); + testStandardFilter('saturate'); + testStandardFilter('grayscale'); + testStandardFilter('sepia'); + testStandardFilter('invert'); + + testNumericFilter('blur', 5, [{blur: 5}]); + testNumericFilter('blur', -5, []); + testUnitFilter('blur', 5, '%', []); + testUnitFilter('blur', 5, 'px', [{blur: 5}]); + + testNumericFilter('hue-rotate', 0, [{hueRotate: 0}]); + testUnitFilter('hue-rotate', 90, 'deg', [{hueRotate: 90}]); + testUnitFilter('hue-rotate', 1.5708, 'rad', [ + {hueRotate: (180 * 1.5708) / Math.PI}, + ]); + testUnitFilter('hue-rotate', -90, 'deg', [{hueRotate: -90}]); + testUnitFilter('hue-rotate', 1.5, 'grad', []); + testNumericFilter('hue-rotate', 90, []); + testUnitFilter('hue-rotate', 50, '%', []); + + it('multiple filters (array)', () => { + expect( + processFilter([ + {brightness: 0.5}, + {opacity: 0.5}, + {blur: 5}, + {hueRotate: '90deg'}, + ]), + ).toEqual([{brightness: 0.5}, {opacity: 0.5}, {blur: 5}, {hueRotate: 90}]); + }); + + it('multiple filters (string)', () => { + expect( + processFilter('brightness(0.5) opacity(0.5) blur(5px) hue-rotate(90deg)'), + ).toEqual([{brightness: 0.5}, {opacity: 0.5}, {blur: 5}, {hueRotate: 90}]); + }); + + it('multiple filters — one invalid returns []', () => { + expect( + processFilter([ + {brightness: 0.5}, + {opacity: 0.5}, + {blur: 5}, + {hueRotate: '90foo'}, + ]), + ).toEqual([]); + }); + + it('string multiple filters — one invalid returns []', () => { + expect( + processFilter('brightness(0.5) opacity(0.5) blur(5px) hue-rotate(90foo)'), + ).toEqual([]); + }); + + it('string chained blur + saturate (percentage)', () => { + expect(processFilter('blur(4px) saturate(150%)')).toEqual([ + {blur: 4}, + {saturate: 1.5}, + ]); + }); + + it('string chained blur + hue-rotate + invert', () => { + expect(processFilter('blur(3px) hue-rotate(90deg) invert(20%)')).toEqual([ + {blur: 3}, + {hueRotate: 90}, + {invert: 0.2}, + ]); + }); + + it('drop-shadow: string 2-length (offsetX offsetY)', () => { + expect(processFilter('drop-shadow(4px 4px)')).toEqual([ + {dropShadow: {offsetX: 4, offsetY: 4}}, + ]); + }); + + it('drop-shadow: string 3-length (offsetX offsetY blur)', () => { + expect(processFilter('drop-shadow(4px 4px 8px)')).toEqual([ + {dropShadow: {offsetX: 4, offsetY: 4, standardDeviation: 8}}, + ]); + }); + + it('drop-shadow: string lengths + named color', () => { + expect(processFilter('drop-shadow(4px 4px 10px blue)')).toEqual([ + { + dropShadow: { + offsetX: 4, + offsetY: 4, + standardDeviation: 10, + color: processColor('blue'), + }, + }, + ]); + }); + + it('drop-shadow: string color first', () => { + expect(processFilter('drop-shadow(#ee2233 4px 4px 10px)')).toEqual([ + { + dropShadow: { + offsetX: 4, + offsetY: 4, + standardDeviation: 10, + color: processColor('#ee2233'), + }, + }, + ]); + }); + + it('drop-shadow: string negative offsets', () => { + expect(processFilter('drop-shadow(-4px -4px)')).toEqual([ + {dropShadow: {offsetX: -4, offsetY: -4}}, + ]); + }); + + it('drop-shadow: string rgba color', () => { + expect( + processFilter('drop-shadow(4px 4px 10px rgba(0, 0, 0, 0.8))'), + ).toEqual([ + { + dropShadow: { + offsetX: 4, + offsetY: 4, + standardDeviation: 10, + color: processColor('rgba(0, 0, 0, 0.8)'), + }, + }, + ]); + }); + + it('drop-shadow: object form', () => { + expect( + processFilter([ + { + dropShadow: { + offsetX: 4, + offsetY: 4, + standardDeviation: '6px', + color: 'rgba(0,0,0,0.6)', + }, + }, + ]), + ).toEqual([ + { + dropShadow: { + offsetX: 4, + offsetY: 4, + standardDeviation: 6, + color: processColor('rgba(0,0,0,0.6)'), + }, + }, + ]); + }); + + it('drop-shadow: negative blur radius is invalid', () => { + expect(processFilter('drop-shadow(4px 4px -8px)')).toEqual([]); + }); + + it('drop-shadow: comma-separated args are invalid', () => { + expect(processFilter('drop-shadow(4px, 4px, 10px, red)')).toEqual([]); + }); + + it('drop-shadow: chained with other filters', () => { + expect( + processFilter('drop-shadow(4px 4px 10px red) brightness(0.5)'), + ).toEqual([ + { + dropShadow: { + offsetX: 4, + offsetY: 4, + standardDeviation: 10, + color: processColor('red'), + }, + }, + {brightness: 0.5}, + ]); + }); + + it('url() reference is not supported and returns []', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter('url("filters.svg#filter")')).toEqual([]); + }); + + it('url() mixed with valid filters returns []', () => { + // $FlowExpectedError[incompatible-call] + expect( + processFilter('url("filters.svg#filter") blur(4px) saturate(150%)'), + ).toEqual([]); + }); + + it('"inherit" is not a valid filter value and returns []', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter('inherit')).toEqual([]); + }); + + it('"initial" is not a valid filter value and returns []', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter('initial')).toEqual([]); + }); + + it('"revert" is not a valid filter value and returns []', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter('revert')).toEqual([]); + }); + + it('"revert-layer" is not a valid filter value and returns []', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter('revert-layer')).toEqual([]); + }); + + it('"unset" is not a valid filter value and returns []', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter('unset')).toEqual([]); + }); + + it('empty array returns []', () => { + expect(processFilter([])).toEqual([]); + }); + + it('empty string returns []', () => { + expect(processFilter('')).toEqual([]); + }); + + it('unknown filter name returns []', () => { + // $FlowExpectedError[incompatible-type] + expect(processFilter([{foo: 5}])).toEqual([]); + }); + + it('mixed case filter names are accepted', () => { + expect(processFilter('bRiGhTnEsS(0.5) hUe-RoTaTe(90deg)')).toEqual([ + {brightness: 0.5}, + {hueRotate: 90}, + ]); + }); +}); + +function testStandardFilter(filter: string): void { + const value = 0.5; + const expected = createFilterPrimitive(filter, value); + const percentExpected = createFilterPrimitive(filter, value / 100); + + testNumericFilter(filter, value, [expected]); + testNumericFilter(filter, -value, []); + testUnitFilter(filter, value, 'px', [expected]); + testUnitFilter(filter, value, '%', [percentExpected]); +} + +function testNumericFilter( + filter: string, + value: number, + expected: Array, +): void { + const filterObject = createFilterPrimitive(filter, value); + const filterString = filter + '(' + value.toString() + ')'; + + it(filterString, () => { + expect(processFilter([filterObject])).toEqual(expected); + }); + it('string ' + filterString, () => { + expect(processFilter(filterString)).toEqual(expected); + }); +} + +function testUnitFilter( + filter: string, + value: number, + unit: string, + expected: Array, +): void { + const unitAmount = value + unit; + const filterObject = createFilterPrimitive(filter, unitAmount); + const filterString = filter + '(' + unitAmount + ')'; + + it(filterString, () => { + expect(processFilter([filterObject])).toEqual(expected); + }); + it('string ' + filterString, () => { + expect(processFilter(filterString)).toEqual(expected); + }); +} + +function createFilterPrimitive( + filter: string, + value: number | string, +): FilterFunction { + switch (filter) { + case 'brightness': + return {brightness: value}; + case 'blur': + return {blur: value}; + case 'contrast': + return {contrast: value}; + case 'grayscale': + return {grayscale: value}; + case 'hue-rotate': + return {hueRotate: value}; + case 'invert': + return {invert: value}; + case 'opacity': + return {opacity: value}; + case 'saturate': + return {saturate: value}; + case 'sepia': + return {sepia: value}; + default: + throw new Error('Invalid filter: ' + filter); + } +} diff --git a/packages/rn-tester/js/examples/BackdropFilter/BackdropFilterExample.js b/packages/rn-tester/js/examples/BackdropFilter/BackdropFilterExample.js new file mode 100644 index 000000000000..d052461ee0e8 --- /dev/null +++ b/packages/rn-tester/js/examples/BackdropFilter/BackdropFilterExample.js @@ -0,0 +1,410 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet'; + +import * as React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +function BackdropScene({ + overlayStyle, + testID, +}: { + overlayStyle: ViewStyleProp, + testID?: string, +}): React.Node { + return ( + + + + + + + Hello world! + + + + ); +} + +function Comparison({ + overlayStyle, + testID, +}: { + overlayStyle: ViewStyleProp, + testID?: string, +}): React.Node { + return ( + + + Original + + + + Filtered + + + + ); +} + +const styles = StyleSheet.create({ + scene: { + width: 150, + height: 150, + overflow: 'hidden', + }, + bgRed: { + position: 'absolute', + top: 0, + left: 0, + width: 75, + height: 75, + borderRadius: 37.5, + backgroundColor: '#e74c3c', + }, + bgBlue: { + position: 'absolute', + top: 0, + right: 0, + width: 75, + height: 75, + borderRadius: 37.5, + backgroundColor: '#2980b9', + }, + bgGreen: { + position: 'absolute', + bottom: 0, + left: 0, + width: 75, + height: 75, + borderRadius: 37.5, + backgroundColor: '#27ae60', + }, + bgYellow: { + position: 'absolute', + bottom: 0, + right: 0, + width: 75, + height: 75, + borderRadius: 37.5, + backgroundColor: 'rgba(243, 156, 18, 0.25)', + }, + bgTextContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }, + bgText: { + textAlign: 'center', + fontWeight: 'bold', + color: 'black', + fontSize: 16, + }, + overlay: { + position: 'absolute', + top: 25, + left: 25, + width: 100, + height: 100, + backgroundColor: 'rgba(255, 255, 255, 0.6)', + }, + row: { + flexDirection: 'row', + justifyContent: 'space-around', + }, + column: { + alignItems: 'center', + gap: 4, + }, + label: { + fontSize: 12, + color: '#666', + }, +}); + +exports.title = 'Backdrop Filter'; +exports.category = 'UI'; +exports.description = + 'Graphical effects applied to the area behind an element. The element or its background must be transparent or semi-transparent for the effect to be visible.'; +exports.examples = [ + { + title: 'Blur (array form)', + description: 'backdropFilter: [{blur: 10}]', + name: 'blur-array', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Blur (string form)', + description: "backdropFilter: 'blur(10px)'", + name: 'blur-string', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Brightness — brighter (number)', + description: 'backdropFilter: [{brightness: 1.5}]', + name: 'brightness-up', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Brightness — darker (percentage string)', + description: "backdropFilter: 'brightness(30%)'", + name: 'brightness-down', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Contrast (number)', + description: 'backdropFilter: [{contrast: 2}]', + name: 'contrast-array', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Contrast (percentage string)', + description: "backdropFilter: 'contrast(40%)'", + name: 'contrast-string', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Grayscale (full)', + description: 'backdropFilter: [{grayscale: 1}]', + name: 'grayscale-full', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Grayscale (percentage string)', + description: "backdropFilter: 'grayscale(55%)'", + name: 'grayscale-string', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Sepia (number)', + description: 'backdropFilter: [{sepia: 0.9}]', + name: 'sepia-array', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Sepia (percentage string)', + description: "backdropFilter: 'sepia(90%)'", + name: 'sepia-string', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Saturate (over-saturated)', + description: 'backdropFilter: [{saturate: 4}]', + name: 'saturate-array', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Saturate (percentage string)', + description: "backdropFilter: 'saturate(20%)'", + name: 'saturate-string', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Invert (full)', + description: 'backdropFilter: [{invert: 1}]', + name: 'invert-full', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Invert (percentage string)', + description: "backdropFilter: 'invert(70%)'", + name: 'invert-string', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Hue Rotate (deg string)', + description: "backdropFilter: [{hueRotate: '120deg'}]", + name: 'hue-rotate-deg', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Hue Rotate (rad string)', + description: "backdropFilter: [{hueRotate: '2.094rad'}] (≈120deg)", + name: 'hue-rotate-rad', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Hue Rotate (CSS string form)', + description: "backdropFilter: 'hue-rotate(120deg)'", + name: 'hue-rotate-string', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Chained (array form)', + description: + 'backdropFilter: [{blur: 6}, {brightness: 1.2}, {saturate: 1.8}]', + name: 'chained-array', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Chained (string form)', + description: "backdropFilter: 'blur(4px) saturate(150%)'", + name: 'chained-string', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Chained (mixed: blur + hue-rotate + invert)', + description: "backdropFilter: 'blur(3px) hue-rotate(90deg) invert(20%)'", + name: 'chained-mixed', + render(): React.Node { + return ( + + ); + }, + }, +] as Array; diff --git a/packages/rn-tester/js/utils/RNTesterList.android.js b/packages/rn-tester/js/utils/RNTesterList.android.js index 61844bb5f196..cc5efc2850a1 100644 --- a/packages/rn-tester/js/utils/RNTesterList.android.js +++ b/packages/rn-tester/js/utils/RNTesterList.android.js @@ -345,6 +345,11 @@ const APIs: Array = ( category: 'UI', module: require('../examples/Filter/FilterExample'), }, + { + key: 'BackdropFilterExample', + category: 'UI', + module: require('../examples/BackdropFilter/BackdropFilterExample'), + }, { key: 'LinearGradientExample', category: 'UI', diff --git a/packages/rn-tester/js/utils/RNTesterList.ios.js b/packages/rn-tester/js/utils/RNTesterList.ios.js index d0821c08709f..9b3416d0cd65 100644 --- a/packages/rn-tester/js/utils/RNTesterList.ios.js +++ b/packages/rn-tester/js/utils/RNTesterList.ios.js @@ -323,6 +323,11 @@ const APIs: Array = ( key: 'FilterExample', module: require('../examples/Filter/FilterExample'), }, + { + key: 'BackdropFilterExample', + category: 'UI', + module: require('../examples/BackdropFilter/BackdropFilterExample'), + }, { key: 'LinearGradientExample', category: 'UI',