diff --git a/.changeset/no-react-native-literal-colors.md b/.changeset/no-react-native-literal-colors.md
new file mode 100644
index 000000000000..c689a5383e72
--- /dev/null
+++ b/.changeset/no-react-native-literal-colors.md
@@ -0,0 +1,24 @@
+---
+"@biomejs/biome": patch
+---
+
+Added the nursery rule [`noReactNativeLiteralColors`](https://biomejs.dev/linter/rules/no-react-native-literal-colors/), which disallows color literals inside React Native styles.
+
+The rule belongs to the `reactNative` domain. It reports properties whose name contains `color` and whose value is a string literal when they appear inside a `StyleSheet.create(...)` call or inside a JSX attribute whose name contains `style`.
+
+```jsx
+// Invalid
+const Hello = () => hi;
+
+const styles = StyleSheet.create({
+ text: { color: 'red' }
+});
+```
+
+```jsx
+// Valid
+const red = '#f00';
+const styles = StyleSheet.create({
+ text: { color: red }
+});
+```
diff --git a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs
index 55d9b2465a1f..66f25e3fe628 100644
--- a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs
+++ b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs
@@ -3653,6 +3653,18 @@ pub(crate) fn migrate_eslint_any_rule(
.get_or_insert(Default::default());
rule.set_level(rule.level().max(rule_severity.into()));
}
+ "react-native/no-color-literals" => {
+ if !options.include_nursery {
+ results.add(eslint_name, eslint_to_biome::RuleMigrationResult::Nursery);
+ return false;
+ }
+ let group = rules.nursery.get_or_insert_with(Default::default);
+ let rule = group
+ .unwrap_group_as_mut()
+ .no_react_native_literal_colors
+ .get_or_insert(Default::default());
+ rule.set_level(rule.level().max(rule_severity.into()));
+ }
"react-native/no-raw-text" => {
if !options.include_nursery {
results.add(eslint_name, eslint_to_biome::RuleMigrationResult::Nursery);
diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs
index 253abd06030f..6cfb6f19ddf4 100644
--- a/crates/biome_configuration/src/analyzer/linter/rules.rs
+++ b/crates/biome_configuration/src/analyzer/linter/rules.rs
@@ -287,6 +287,7 @@ pub enum RuleName {
NoQwikUseVisibleTask,
NoReExportAll,
NoReactForwardRef,
+ NoReactNativeLiteralColors,
NoReactNativeRawText,
NoReactPropAssignments,
NoReactSpecificProps,
@@ -780,6 +781,7 @@ impl RuleName {
Self::NoQwikUseVisibleTask => "noQwikUseVisibleTask",
Self::NoReExportAll => "noReExportAll",
Self::NoReactForwardRef => "noReactForwardRef",
+ Self::NoReactNativeLiteralColors => "noReactNativeLiteralColors",
Self::NoReactNativeRawText => "noReactNativeRawText",
Self::NoReactPropAssignments => "noReactPropAssignments",
Self::NoReactSpecificProps => "noReactSpecificProps",
@@ -1269,6 +1271,7 @@ impl RuleName {
Self::NoQwikUseVisibleTask => RuleGroup::Correctness,
Self::NoReExportAll => RuleGroup::Performance,
Self::NoReactForwardRef => RuleGroup::Suspicious,
+ Self::NoReactNativeLiteralColors => RuleGroup::Nursery,
Self::NoReactNativeRawText => RuleGroup::Nursery,
Self::NoReactPropAssignments => RuleGroup::Correctness,
Self::NoReactSpecificProps => RuleGroup::Suspicious,
@@ -1767,6 +1770,7 @@ impl std::str::FromStr for RuleName {
"noQwikUseVisibleTask" => Ok(Self::NoQwikUseVisibleTask),
"noReExportAll" => Ok(Self::NoReExportAll),
"noReactForwardRef" => Ok(Self::NoReactForwardRef),
+ "noReactNativeLiteralColors" => Ok(Self::NoReactNativeLiteralColors),
"noReactNativeRawText" => Ok(Self::NoReactNativeRawText),
"noReactPropAssignments" => Ok(Self::NoReactPropAssignments),
"noReactSpecificProps" => Ok(Self::NoReactSpecificProps),
diff --git a/crates/biome_configuration/src/generated/domain_selector.rs b/crates/biome_configuration/src/generated/domain_selector.rs
index fd800ddb3023..fb5d61845280 100644
--- a/crates/biome_configuration/src/generated/domain_selector.rs
+++ b/crates/biome_configuration/src/generated/domain_selector.rs
@@ -89,8 +89,12 @@ static REACT_FILTERS: LazyLock>> = LazyLock::new(|| {
RuleFilter::Rule("suspicious", "noReactForwardRef"),
]
});
-static REACTNATIVE_FILTERS: LazyLock>> =
- LazyLock::new(|| vec![RuleFilter::Rule("nursery", "noReactNativeRawText")]);
+static REACTNATIVE_FILTERS: LazyLock>> = LazyLock::new(|| {
+ vec![
+ RuleFilter::Rule("nursery", "noReactNativeLiteralColors"),
+ RuleFilter::Rule("nursery", "noReactNativeRawText"),
+ ]
+});
static SOLID_FILTERS: LazyLock>> = LazyLock::new(|| {
vec![
RuleFilter::Rule("correctness", "noSolidDestructuredProps"),
diff --git a/crates/biome_configuration/src/generated/linter_options_check.rs b/crates/biome_configuration/src/generated/linter_options_check.rs
index dec79844d7aa..b4e14ef9bab4 100644
--- a/crates/biome_configuration/src/generated/linter_options_check.rs
+++ b/crates/biome_configuration/src/generated/linter_options_check.rs
@@ -938,6 +938,13 @@ pub fn config_side_rule_options_types() -> Vec<(&'static str, &'static str, Type
"noReactForwardRef",
TypeId::of::(),
));
+ result.push((
+ "nursery",
+ "noReactNativeLiteralColors",
+ TypeId::of::<
+ biome_rule_options::no_react_native_literal_colors::NoReactNativeLiteralColorsOptions,
+ >(),
+ ));
result.push((
"nursery",
"noReactNativeRawText",
diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs
index cc637027ee95..3e21e3f5a613 100644
--- a/crates/biome_diagnostics_categories/src/categories.rs
+++ b/crates/biome_diagnostics_categories/src/categories.rs
@@ -228,6 +228,7 @@ define_categories! {
"lint/nursery/noPlaywrightWaitForSelector": "https://biomejs.dev/linter/rules/no-playwright-wait-for-selector",
"lint/nursery/noPlaywrightWaitForTimeout": "https://biomejs.dev/linter/rules/no-playwright-wait-for-timeout",
"lint/nursery/noProto": "https://biomejs.dev/linter/rules/no-proto",
+ "lint/nursery/noReactNativeLiteralColors": "https://biomejs.dev/linter/rules/no-react-native-literal-colors",
"lint/nursery/noReactNativeRawText": "https://biomejs.dev/linter/rules/no-react-native-raw-text",
"lint/nursery/noRedundantDefaultExport": "https://biomejs.dev/linter/rules/no-redundant-default-export",
"lint/nursery/noReturnAssign": "https://biomejs.dev/linter/rules/no-return-assign",
diff --git a/crates/biome_js_analyze/src/lint/nursery/no_react_native_literal_colors.rs b/crates/biome_js_analyze/src/lint/nursery/no_react_native_literal_colors.rs
new file mode 100644
index 000000000000..501fdaed4915
--- /dev/null
+++ b/crates/biome_js_analyze/src/lint/nursery/no_react_native_literal_colors.rs
@@ -0,0 +1,193 @@
+use crate::frameworks::is_framework_api_reference;
+use crate::services::semantic::Semantic;
+use biome_analyze::{
+ Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
+};
+use biome_console::markup;
+use biome_js_syntax::{
+ AnyJsExpression, AnyJsLiteralExpression, JsCallExpression, JsPropertyObjectMember, JsxAttribute,
+};
+use biome_rowan::{AstNode, TextRange, declare_node_union};
+use biome_rule_options::no_react_native_literal_colors::NoReactNativeLiteralColorsOptions;
+use biome_string_case::StrLikeExtension;
+
+declare_lint_rule! {
+ /// Disallow color literals in React Native styles.
+ ///
+ /// Hard-coding colors inside styles makes it harder to keep them consistent
+ /// across components and to swap the palette when the design system evolves.
+ /// Extracting colors into named constants or a shared theme module produces
+ /// more maintainable code.
+ ///
+ /// This rule reports properties whose name contains `color` (case-insensitive)
+ /// and whose value is a string literal, when they appear inside a
+ /// `StyleSheet.create` call or inside a JSX attribute whose name contains
+ /// `style` (case-insensitive). A ternary expression is also reported when
+ /// either branch is a string literal.
+ ///
+ /// ## Examples
+ ///
+ /// ### Invalid
+ ///
+ /// ```jsx,expect_diagnostic
+ /// const Hello = () => hi;
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// const styles = StyleSheet.create({
+ /// text: { color: 'red' }
+ /// });
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// const Hello = (flag) => (
+ /// hi
+ /// );
+ /// ```
+ ///
+ /// ### Valid
+ ///
+ /// ```jsx
+ /// const red = '#f00';
+ /// const styles = StyleSheet.create({
+ /// text: { color: red }
+ /// });
+ /// ```
+ ///
+ /// ```jsx
+ /// const Hello = () => (
+ /// hi
+ /// );
+ /// ```
+ ///
+ pub NoReactNativeLiteralColors {
+ version: "next",
+ name: "noReactNativeLiteralColors",
+ language: "js",
+ sources: &[RuleSource::EslintReactNative("no-color-literals").same()],
+ domains: &[RuleDomain::ReactNative],
+ recommended: false,
+ }
+}
+
+impl Rule for NoReactNativeLiteralColors {
+ type Query = Semantic;
+ type State = TextRange;
+ type Signals = Vec;
+ type Options = NoReactNativeLiteralColorsOptions;
+
+ fn run(ctx: &RuleContext) -> Self::Signals {
+ let node = ctx.query();
+ match node {
+ AnyStyleSink::JsxAttribute(attribute) => {
+ if !is_style_attribute(attribute) {
+ return Vec::new();
+ }
+ node.collect_color_literal_properties()
+ }
+ AnyStyleSink::JsCallExpression(call) => {
+ if !is_stylesheet_create(call, ctx.model()) {
+ return Vec::new();
+ }
+ node.collect_color_literal_properties()
+ }
+ }
+ }
+
+ fn diagnostic(_ctx: &RuleContext, range: &Self::State) -> Option {
+ Some(
+ RuleDiagnostic::new(
+ rule_category!(),
+ range,
+ markup! {
+ "Color literals are not allowed inside styles."
+ },
+ )
+ .note(markup! {
+ "Inline colors are hard to keep consistent across screens and to adapt when the design palette changes."
+ })
+ .note(markup! {
+ "Extract the color into a named constant or a shared theme module, and reference it from the style."
+ }),
+ )
+ }
+}
+
+declare_node_union! {
+ /// The two places where React Native style objects can appear: a JSX
+ /// attribute like `style={...}` or a call like `StyleSheet.create(...)`.
+ pub AnyStyleSink = JsxAttribute | JsCallExpression
+}
+
+impl AnyStyleSink {
+ /// Walks all descendant `JsPropertyObjectMember` nodes and returns the text
+ /// range of each one whose name contains `color` and whose value is a color
+ /// literal (a string, or a ternary where at least one branch is a string).
+ fn collect_color_literal_properties(&self) -> Vec {
+ self.syntax()
+ .descendants()
+ .filter_map(JsPropertyObjectMember::cast)
+ .filter(|property| {
+ property
+ .name()
+ .ok()
+ .and_then(|name| name.name())
+ .is_some_and(|name| name.contains_ignore_ascii_case("color"))
+ })
+ .filter(|property| {
+ property
+ .value()
+ .ok()
+ .is_some_and(|value| has_color_literal_value(&value))
+ })
+ .map(|property| property.range())
+ .collect()
+ }
+}
+
+fn has_color_literal_value(value: &AnyJsExpression) -> bool {
+ match value {
+ AnyJsExpression::AnyJsLiteralExpression(
+ AnyJsLiteralExpression::JsStringLiteralExpression(_),
+ ) => true,
+ AnyJsExpression::JsConditionalExpression(conditional) => {
+ conditional
+ .consequent()
+ .ok()
+ .is_some_and(|consequent| consequent.is_string_literal())
+ || conditional
+ .alternate()
+ .ok()
+ .is_some_and(|alternate| alternate.is_string_literal())
+ }
+ _ => false,
+ }
+}
+
+fn is_style_attribute(attribute: &JsxAttribute) -> bool {
+ attribute
+ .name()
+ .ok()
+ .and_then(|name| name.name().ok())
+ .is_some_and(|token| token.text_trimmed().contains_ignore_ascii_case("style"))
+}
+
+/// Returns `true` when `call` is a call to `StyleSheet.create` where
+/// `StyleSheet` is either imported from `react-native`/`react-native-web` or
+/// is an unresolved global with that name. A `StyleSheet` identifier bound to
+/// a user declaration (local variable, import from another package, …) is
+/// rejected, so the rule only fires on the real React Native API.
+fn is_stylesheet_create(call: &JsCallExpression, model: &biome_js_semantic::SemanticModel) -> bool {
+ let Ok(callee) = call.callee() else {
+ return false;
+ };
+ is_framework_api_reference(
+ &callee,
+ model,
+ "create",
+ REACT_NATIVE_PACKAGE_NAMES,
+ Some("StyleSheet"),
+ )
+}
+
+const REACT_NATIVE_PACKAGE_NAMES: &[&str] = &["react-native", "react-native-web"];
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx
new file mode 100644
index 000000000000..2fe33f5a441a
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx
@@ -0,0 +1,32 @@
+/* should generate diagnostics */
+
+const Inline = () => hello;
+
+const stylesBasic = StyleSheet.create({
+ text: { fontColor: '#000' },
+});
+
+const MultipleInSheet = StyleSheet.create({
+ primary: { color: 'red' },
+ secondary: { borderBottomColor: 'blue' },
+});
+
+const InArray = () => (
+ hello
+);
+
+const InLogical = ({ active }) => (
+ hello
+);
+
+const TernaryBothLiterals = ({ active }) => (
+ hello
+);
+
+const TernaryOneLiteral = ({ active }) => (
+ hello
+);
+
+const CustomStyleAttribute = () => (
+ hello
+);
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx.snap
new file mode 100644
index 000000000000..470f5a2f771e
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx.snap
@@ -0,0 +1,223 @@
+---
+source: crates/biome_js_analyze/tests/spec_tests.rs
+expression: invalid.jsx
+---
+# Input
+```jsx
+/* should generate diagnostics */
+
+const Inline = () => hello;
+
+const stylesBasic = StyleSheet.create({
+ text: { fontColor: '#000' },
+});
+
+const MultipleInSheet = StyleSheet.create({
+ primary: { color: 'red' },
+ secondary: { borderBottomColor: 'blue' },
+});
+
+const InArray = () => (
+ hello
+);
+
+const InLogical = ({ active }) => (
+ hello
+);
+
+const TernaryBothLiterals = ({ active }) => (
+ hello
+);
+
+const TernaryOneLiteral = ({ active }) => (
+ hello
+);
+
+const CustomStyleAttribute = () => (
+ hello
+);
+
+```
+
+# Diagnostics
+```
+invalid.jsx:3:37 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 1 │ /* should generate diagnostics */
+ 2 │
+ > 3 │ const Inline = () => hello;
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 4 │
+ 5 │ const stylesBasic = StyleSheet.create({
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
+
+```
+invalid.jsx:6:10 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 5 │ const stylesBasic = StyleSheet.create({
+ > 6 │ text: { fontColor: '#000' },
+ │ ^^^^^^^^^^^^^^^^^
+ 7 │ });
+ 8 │
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
+
+```
+invalid.jsx:10:13 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 9 │ const MultipleInSheet = StyleSheet.create({
+ > 10 │ primary: { color: 'red' },
+ │ ^^^^^^^^^^^^
+ 11 │ secondary: { borderBottomColor: 'blue' },
+ 12 │ });
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
+
+```
+invalid.jsx:11:15 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 9 │ const MultipleInSheet = StyleSheet.create({
+ 10 │ primary: { color: 'red' },
+ > 11 │ secondary: { borderBottomColor: 'blue' },
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^
+ 12 │ });
+ 13 │
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
+
+```
+invalid.jsx:15:31 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 14 │ const InArray = () => (
+ > 15 │ hello
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 16 │ );
+ 17 │
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
+
+```
+invalid.jsx:19:41 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 18 │ const InLogical = ({ active }) => (
+ > 19 │ hello
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 20 │ );
+ 21 │
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
+
+```
+invalid.jsx:23:17 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 22 │ const TernaryBothLiterals = ({ active }) => (
+ > 23 │ hello
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 24 │ );
+ 25 │
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
+
+```
+invalid.jsx:27:17 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 26 │ const TernaryOneLiteral = ({ active }) => (
+ > 27 │ hello
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 28 │ );
+ 29 │
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
+
+```
+invalid.jsx:31:33 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 30 │ const CustomStyleAttribute = () => (
+ > 31 │ hello
+ │ ^^^^^^^^^^^^
+ 32 │ );
+ 33 │
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx
new file mode 100644
index 000000000000..f62d3a2ef1b5
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx
@@ -0,0 +1,7 @@
+/* should generate diagnostics */
+
+import { StyleSheet } from 'react-native';
+
+const styles = StyleSheet.create({
+ text: { color: 'red' },
+});
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx.snap
new file mode 100644
index 000000000000..a5cf05b8d07f
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx.snap
@@ -0,0 +1,36 @@
+---
+source: crates/biome_js_analyze/tests/spec_tests.rs
+expression: invalidReactNativeImport.jsx
+---
+# Input
+```jsx
+/* should generate diagnostics */
+
+import { StyleSheet } from 'react-native';
+
+const styles = StyleSheet.create({
+ text: { color: 'red' },
+});
+
+```
+
+# Diagnostics
+```
+invalidReactNativeImport.jsx:6:10 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i Color literals are not allowed inside styles.
+
+ 5 │ const styles = StyleSheet.create({
+ > 6 │ text: { color: 'red' },
+ │ ^^^^^^^^^^^^
+ 7 │ });
+ 8 │
+
+ i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes.
+
+ i Extract the color into a named constant or a shared theme module, and reference it from the style.
+
+ i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
+
+
+```
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx
new file mode 100644
index 000000000000..e2aae5048864
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx
@@ -0,0 +1,48 @@
+/* should not generate diagnostics */
+
+const red = '#f00';
+const blue = '#00f';
+
+const stylesFromVars = StyleSheet.create({
+ title: { color: red },
+ subtitle: { color: blue },
+});
+
+const Themed = () => hello;
+
+const ConditionalVars = ({ isDanger }) => {
+ const trueColor = '#fff';
+ const falseColor = '#000';
+ return (
+
+ );
+};
+
+const NonColorLiteral = StyleSheet.create({
+ box: { fontFamily: 'Arial', padding: 10 },
+});
+
+const ShorthandProperty = ({ color }) => (
+ hello
+);
+
+const OutsideStyleContext = {
+ backgroundColor: '#fff',
+};
+
+function paintBackground() {
+ return { backgroundColor: '#fff' };
+}
+
+const NonStyleSheetCreate = MyThing.create({
+ box: { backgroundColor: '#fff' },
+});
+
+const NonStyleAttribute = () => (
+ hello
+);
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx.snap
new file mode 100644
index 000000000000..74bae1393e6b
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx.snap
@@ -0,0 +1,56 @@
+---
+source: crates/biome_js_analyze/tests/spec_tests.rs
+expression: valid.jsx
+---
+# Input
+```jsx
+/* should not generate diagnostics */
+
+const red = '#f00';
+const blue = '#00f';
+
+const stylesFromVars = StyleSheet.create({
+ title: { color: red },
+ subtitle: { color: blue },
+});
+
+const Themed = () => hello;
+
+const ConditionalVars = ({ isDanger }) => {
+ const trueColor = '#fff';
+ const falseColor = '#000';
+ return (
+
+ );
+};
+
+const NonColorLiteral = StyleSheet.create({
+ box: { fontFamily: 'Arial', padding: 10 },
+});
+
+const ShorthandProperty = ({ color }) => (
+ hello
+);
+
+const OutsideStyleContext = {
+ backgroundColor: '#fff',
+};
+
+function paintBackground() {
+ return { backgroundColor: '#fff' };
+}
+
+const NonStyleSheetCreate = MyThing.create({
+ box: { backgroundColor: '#fff' },
+});
+
+const NonStyleAttribute = () => (
+ hello
+);
+
+```
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx
new file mode 100644
index 000000000000..5f2641fb028f
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx
@@ -0,0 +1,7 @@
+/* should not generate diagnostics */
+
+import { StyleSheet } from 'my-custom-lib';
+
+const FromOtherPackage = StyleSheet.create({
+ box: { backgroundColor: '#fff' },
+});
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx.snap
new file mode 100644
index 000000000000..a025c52b9d07
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx.snap
@@ -0,0 +1,15 @@
+---
+source: crates/biome_js_analyze/tests/spec_tests.rs
+expression: validCustomStyleSheet.jsx
+---
+# Input
+```jsx
+/* should not generate diagnostics */
+
+import { StyleSheet } from 'my-custom-lib';
+
+const FromOtherPackage = StyleSheet.create({
+ box: { backgroundColor: '#fff' },
+});
+
+```
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx
new file mode 100644
index 000000000000..116124a084b7
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx
@@ -0,0 +1,7 @@
+/* should not generate diagnostics */
+
+const StyleSheet = { create: (value) => value };
+
+const LocalSheet = StyleSheet.create({
+ box: { backgroundColor: '#fff' },
+});
diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx.snap
new file mode 100644
index 000000000000..fb1fdb5321e2
--- /dev/null
+++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx.snap
@@ -0,0 +1,15 @@
+---
+source: crates/biome_js_analyze/tests/spec_tests.rs
+expression: validLocalStyleSheet.jsx
+---
+# Input
+```jsx
+/* should not generate diagnostics */
+
+const StyleSheet = { create: (value) => value };
+
+const LocalSheet = StyleSheet.create({
+ box: { backgroundColor: '#fff' },
+});
+
+```
diff --git a/crates/biome_js_syntax/src/expr_ext.rs b/crates/biome_js_syntax/src/expr_ext.rs
index 1984d9bf839d..ca93b86d270c 100644
--- a/crates/biome_js_syntax/src/expr_ext.rs
+++ b/crates/biome_js_syntax/src/expr_ext.rs
@@ -1367,6 +1367,13 @@ impl AnyJsExpression {
.as_ref()
.and_then(Self::inner_expression)
}
+
+ pub fn is_string_literal(&self) -> bool {
+ matches!(
+ self,
+ Self::AnyJsLiteralExpression(AnyJsLiteralExpression::JsStringLiteralExpression(_),)
+ )
+ }
}
/// Returns `true` if this node is a transparent wrapper expression.
diff --git a/crates/biome_rule_options/src/lib.rs b/crates/biome_rule_options/src/lib.rs
index 763ef5c0cacb..d0a1eac88fe2 100644
--- a/crates/biome_rule_options/src/lib.rs
+++ b/crates/biome_rule_options/src/lib.rs
@@ -201,6 +201,7 @@ pub mod no_quickfix_biome;
pub mod no_qwik_use_visible_task;
pub mod no_re_export_all;
pub mod no_react_forward_ref;
+pub mod no_react_native_literal_colors;
pub mod no_react_native_raw_text;
pub mod no_react_prop_assignments;
pub mod no_react_specific_props;
diff --git a/crates/biome_rule_options/src/no_react_native_literal_colors.rs b/crates/biome_rule_options/src/no_react_native_literal_colors.rs
new file mode 100644
index 000000000000..435a57871faa
--- /dev/null
+++ b/crates/biome_rule_options/src/no_react_native_literal_colors.rs
@@ -0,0 +1,6 @@
+use biome_deserialize_macros::{Deserializable, Merge};
+use serde::{Deserialize, Serialize};
+#[derive(Default, Clone, Debug, Deserialize, Deserializable, Merge, Eq, PartialEq, Serialize)]
+#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
+#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
+pub struct NoReactNativeLiteralColorsOptions {}
diff --git a/crates/biome_string_case/src/lib.rs b/crates/biome_string_case/src/lib.rs
index f9bb07de46cd..34bb1afbb961 100644
--- a/crates/biome_string_case/src/lib.rs
+++ b/crates/biome_string_case/src/lib.rs
@@ -659,6 +659,14 @@ pub trait StrLikeExtension: ToOwned {
///
/// This orders Unicode code points based on their positions in the code charts.
fn lexicographic_cmp(&self, other: &Self) -> Ordering;
+
+ /// Returns `true` when `needle` appears anywhere inside `haystack`, treating
+ /// uppercase and lowercase ASCII letters as equal. For example, searching for
+ /// `"color"` inside `"backgroundColor"` returns `true`.
+ ///
+ /// Only ASCII letters are folded. Any other character (digits, punctuation,
+ /// or non-ASCII letters like `É`) must match byte-for-byte.
+ fn contains_ignore_ascii_case(&self, needle: &Self) -> bool;
}
pub trait StrOnlyExtension: ToOwned {
@@ -688,6 +696,20 @@ impl StrLikeExtension for str {
fn lexicographic_cmp(&self, other: &Self) -> Ordering {
self.as_bytes().lexicographic_cmp(other.as_bytes())
}
+
+ fn contains_ignore_ascii_case(&self, needle: &Self) -> bool {
+ let haystack = self.as_bytes();
+ let needle = needle.as_bytes();
+ if needle.is_empty() {
+ return true;
+ }
+ if haystack.len() < needle.len() {
+ return false;
+ }
+ haystack
+ .windows(needle.len())
+ .any(|window| window.eq_ignore_ascii_case(needle))
+ }
}
impl StrOnlyExtension for str {
@@ -744,6 +766,11 @@ impl StrLikeExtension for std::ffi::OsStr {
fn lexicographic_cmp(&self, other: &Self) -> Ordering {
self.as_encoded_bytes().cmp(other.as_encoded_bytes())
}
+
+ fn contains_ignore_ascii_case(&self, needle: &Self) -> bool {
+ self.as_encoded_bytes()
+ .contains_ignore_ascii_case(needle.as_encoded_bytes())
+ }
}
impl StrLikeExtension for [u8] {
@@ -763,6 +790,17 @@ impl StrLikeExtension for [u8] {
fn lexicographic_cmp(&self, other: &Self) -> Ordering {
self.iter().copied().cmp(other.iter().copied())
}
+
+ fn contains_ignore_ascii_case(&self, needle: &Self) -> bool {
+ if needle.is_empty() {
+ return true;
+ }
+ if self.len() < needle.len() {
+ return false;
+ }
+ self.windows(needle.len())
+ .any(|window| window.eq_ignore_ascii_case(needle))
+ }
}
// TODO: Once trait-alias are stabilized it would be enough to `use` this trait instead of individual ones.
@@ -1249,3 +1287,146 @@ mod tests {
assert_eq!("0".ascii_nat_cmp("a"), Ordering::Less);
}
}
+
+#[cfg(test)]
+mod contains_ignore_ascii_case_str {
+ use super::StrLikeExtension;
+
+ #[test]
+ fn matches_exactly() {
+ assert!("color".contains_ignore_ascii_case("color"));
+ }
+
+ #[test]
+ fn matches_at_start() {
+ assert!("colorScheme".contains_ignore_ascii_case("color"));
+ }
+
+ #[test]
+ fn matches_at_end() {
+ assert!("backgroundcolor".contains_ignore_ascii_case("color"));
+ }
+
+ #[test]
+ fn matches_in_the_middle() {
+ assert!("myColorValue".contains_ignore_ascii_case("color"));
+ }
+
+ #[test]
+ fn ignores_ascii_case() {
+ assert!("backgroundColor".contains_ignore_ascii_case("color"));
+ assert!("BACKGROUNDCOLOR".contains_ignore_ascii_case("color"));
+ assert!("background".contains_ignore_ascii_case("GROUND"));
+ }
+
+ #[test]
+ fn rejects_missing_needle() {
+ assert!(!"padding".contains_ignore_ascii_case("color"));
+ }
+
+ #[test]
+ fn empty_needle_always_matches() {
+ assert!("anything".contains_ignore_ascii_case(""));
+ assert!("".contains_ignore_ascii_case(""));
+ }
+
+ #[test]
+ fn needle_longer_than_haystack() {
+ assert!(!"hi".contains_ignore_ascii_case("hello"));
+ assert!(!"".contains_ignore_ascii_case("color"));
+ }
+
+ #[test]
+ fn non_ascii_is_not_case_folded() {
+ assert!(!"café".contains_ignore_ascii_case("CAFÉ"));
+ assert!("café".contains_ignore_ascii_case("café"));
+ }
+
+ #[test]
+ fn punctuation_and_digits_must_match_exactly() {
+ assert!("color-2".contains_ignore_ascii_case("COLOR-2"));
+ assert!(!"color-2".contains_ignore_ascii_case("color_2"));
+ }
+}
+
+#[cfg(test)]
+mod contains_ignore_ascii_case_bytes {
+ use super::StrLikeExtension;
+
+ #[test]
+ fn matches_exactly() {
+ assert!(b"color".contains_ignore_ascii_case(b"color".as_slice()));
+ }
+
+ #[test]
+ fn ignores_ascii_case() {
+ assert!(b"backgroundColor".contains_ignore_ascii_case(b"color".as_slice()));
+ assert!(b"BACKGROUNDCOLOR".contains_ignore_ascii_case(b"color".as_slice()));
+ assert!(b"background".contains_ignore_ascii_case(b"GROUND".as_slice()));
+ }
+
+ #[test]
+ fn rejects_missing_needle() {
+ assert!(!b"padding".contains_ignore_ascii_case(b"color".as_slice()));
+ }
+
+ #[test]
+ fn empty_needle_always_matches() {
+ assert!(b"anything".contains_ignore_ascii_case(b"".as_slice()));
+ assert!(b"".contains_ignore_ascii_case(b"".as_slice()));
+ }
+
+ #[test]
+ fn needle_longer_than_haystack() {
+ assert!(!b"hi".contains_ignore_ascii_case(b"hello".as_slice()));
+ assert!(!b"".contains_ignore_ascii_case(b"color".as_slice()));
+ }
+
+ #[test]
+ fn punctuation_and_digits_must_match_exactly() {
+ assert!(b"color-2".contains_ignore_ascii_case(b"COLOR-2".as_slice()));
+ assert!(!b"color-2".contains_ignore_ascii_case(b"color_2".as_slice()));
+ }
+}
+
+#[cfg(test)]
+mod contains_ignore_ascii_case_osstr {
+ use std::ffi::OsStr;
+
+ use super::StrLikeExtension;
+
+ #[test]
+ fn matches_exactly() {
+ assert!(OsStr::new("color").contains_ignore_ascii_case(OsStr::new("color")));
+ }
+
+ #[test]
+ fn ignores_ascii_case() {
+ assert!(OsStr::new("backgroundColor").contains_ignore_ascii_case(OsStr::new("color")));
+ assert!(OsStr::new("BACKGROUNDCOLOR").contains_ignore_ascii_case(OsStr::new("color")));
+ assert!(OsStr::new("background").contains_ignore_ascii_case(OsStr::new("GROUND")));
+ }
+
+ #[test]
+ fn rejects_missing_needle() {
+ assert!(!OsStr::new("padding").contains_ignore_ascii_case(OsStr::new("color")));
+ }
+
+ #[test]
+ fn empty_needle_always_matches() {
+ assert!(OsStr::new("anything").contains_ignore_ascii_case(OsStr::new("")));
+ assert!(OsStr::new("").contains_ignore_ascii_case(OsStr::new("")));
+ }
+
+ #[test]
+ fn needle_longer_than_haystack() {
+ assert!(!OsStr::new("hi").contains_ignore_ascii_case(OsStr::new("hello")));
+ assert!(!OsStr::new("").contains_ignore_ascii_case(OsStr::new("color")));
+ }
+
+ #[test]
+ fn punctuation_and_digits_must_match_exactly() {
+ assert!(OsStr::new("color-2").contains_ignore_ascii_case(OsStr::new("COLOR-2")));
+ assert!(!OsStr::new("color-2").contains_ignore_ascii_case(OsStr::new("color_2")));
+ }
+}
diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts
index 0bb87abcd2ef..194666a7dbec 100644
--- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts
+++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts
@@ -2298,6 +2298,11 @@ See https://biomejs.dev/linter/rules/no-proto
*/
noProto?: NoProtoConfiguration;
/**
+ * Disallow color literals in React Native styles.
+See https://biomejs.dev/linter/rules/no-react-native-literal-colors
+ */
+ noReactNativeLiteralColors?: NoReactNativeLiteralColorsConfiguration;
+ /**
* Disallow raw text outside \ components in React Native.
See https://biomejs.dev/linter/rules/no-react-native-raw-text
*/
@@ -4355,6 +4360,9 @@ export type NoPlaywrightWaitForTimeoutConfiguration =
export type NoProtoConfiguration =
| RulePlainConfiguration
| RuleWithNoProtoOptions;
+export type NoReactNativeLiteralColorsConfiguration =
+ | RulePlainConfiguration
+ | RuleWithNoReactNativeLiteralColorsOptions;
export type NoReactNativeRawTextConfiguration =
| RulePlainConfiguration
| RuleWithNoReactNativeRawTextOptions;
@@ -6126,6 +6134,10 @@ export interface RuleWithNoProtoOptions {
level: RulePlainConfiguration;
options?: NoProtoOptions;
}
+export interface RuleWithNoReactNativeLiteralColorsOptions {
+ level: RulePlainConfiguration;
+ options?: NoReactNativeLiteralColorsOptions;
+}
export interface RuleWithNoReactNativeRawTextOptions {
level: RulePlainConfiguration;
options?: NoReactNativeRawTextOptions;
@@ -7743,6 +7755,7 @@ export type NoPlaywrightWaitForNavigationOptions = {};
export type NoPlaywrightWaitForSelectorOptions = {};
export type NoPlaywrightWaitForTimeoutOptions = {};
export type NoProtoOptions = {};
+export type NoReactNativeLiteralColorsOptions = {};
export interface NoReactNativeRawTextOptions {
/**
* Names of additional components that are allowed to contain raw text.
@@ -8919,6 +8932,7 @@ export type Category =
| "lint/nursery/noPlaywrightWaitForSelector"
| "lint/nursery/noPlaywrightWaitForTimeout"
| "lint/nursery/noProto"
+ | "lint/nursery/noReactNativeLiteralColors"
| "lint/nursery/noReactNativeRawText"
| "lint/nursery/noRedundantDefaultExport"
| "lint/nursery/noReturnAssign"
diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json
index c9f0bcb6a836..6eba06eb2f1f 100644
--- a/packages/@biomejs/biome/configuration_schema.json
+++ b/packages/@biomejs/biome/configuration_schema.json
@@ -4740,6 +4740,16 @@
"type": "object",
"additionalProperties": false
},
+ "NoReactNativeLiteralColorsConfiguration": {
+ "oneOf": [
+ { "$ref": "#/$defs/RulePlainConfiguration" },
+ { "$ref": "#/$defs/RuleWithNoReactNativeLiteralColorsOptions" }
+ ]
+ },
+ "NoReactNativeLiteralColorsOptions": {
+ "type": "object",
+ "additionalProperties": false
+ },
"NoReactNativeRawTextConfiguration": {
"oneOf": [
{ "$ref": "#/$defs/RulePlainConfiguration" },
@@ -6351,6 +6361,13 @@
{ "type": "null" }
]
},
+ "noReactNativeLiteralColors": {
+ "description": "Disallow color literals in React Native styles.\nSee https://biomejs.dev/linter/rules/no-react-native-literal-colors",
+ "anyOf": [
+ { "$ref": "#/$defs/NoReactNativeLiteralColorsConfiguration" },
+ { "type": "null" }
+ ]
+ },
"noReactNativeRawText": {
"description": "Disallow raw text outside \\ components in React Native.\nSee https://biomejs.dev/linter/rules/no-react-native-raw-text",
"anyOf": [
@@ -9308,6 +9325,15 @@
"additionalProperties": false,
"required": ["level"]
},
+ "RuleWithNoReactNativeLiteralColorsOptions": {
+ "type": "object",
+ "properties": {
+ "level": { "$ref": "#/$defs/RulePlainConfiguration" },
+ "options": { "$ref": "#/$defs/NoReactNativeLiteralColorsOptions" }
+ },
+ "additionalProperties": false,
+ "required": ["level"]
+ },
"RuleWithNoReactNativeRawTextOptions": {
"type": "object",
"properties": {