From 24fb82a713416d395ce4f25b776242b75db4a0b3 Mon Sep 17 00:00:00 2001
From: Evan Simpson <25159851+e-simpson@users.noreply.github.com>
Date: Mon, 27 Jul 2026 21:45:03 -0400
Subject: [PATCH] fix: preserve opacity in scheme modifiers
---
docs/src/content/docs/guides/color-scheme.md | 15 ++++
src/parser/colors.test.ts | 17 ++++
src/parser/colors.ts | 18 ++---
src/parser/modifiers.test.ts | 82 ++++++++++++++++++++
src/parser/modifiers.ts | 11 ++-
src/utils/colorUtils.test.ts | 60 +++++++++++++-
src/utils/colorUtils.ts | 63 +++++++++++----
7 files changed, 239 insertions(+), 27 deletions(-)
diff --git a/docs/src/content/docs/guides/color-scheme.md b/docs/src/content/docs/guides/color-scheme.md
index 5080cb6..9a74102 100644
--- a/docs/src/content/docs/guides/color-scheme.md
+++ b/docs/src/content/docs/guides/color-scheme.md
@@ -172,6 +172,21 @@ The `scheme:` modifier only works with color utilities:
- ✅ `scheme:border-{color}` — Border colors
- ❌ Other utilities — Ignored with development warning
+Opacity modifiers are preserved when the semantic color expands:
+
+```tsx
+
+// dark:bg-primary-dark/25 light:bg-primary-light/25
+
+
+// dark:text-systemLabel-dark/80 light:text-systemLabel-light/80
+
+
+// dark:bg-primary-dark/[.37] light:bg-primary-light/[.37]
+```
+
+Named opacity modifiers use percentages from `/0` through `/100`. Arbitrary modifiers accept a raw alpha such as `/[.37]` or an explicit percentage such as `/[37%]`. If a configured color already includes an alpha channel, the modifier composes with that alpha instead of producing an invalid color.
+
### Use Cases
**Semantic color names:**
diff --git a/src/parser/colors.test.ts b/src/parser/colors.test.ts
index 15b8bd6..29c59c7 100644
--- a/src/parser/colors.test.ts
+++ b/src/parser/colors.test.ts
@@ -320,6 +320,21 @@ describe("parseColor - opacity modifiers", () => {
expect(parseColor("border-[#abc]/60")).toEqual({ borderColor: "#AABBCC99" });
});
+ it("should handle arbitrary raw and percentage opacity modifiers", () => {
+ expect(parseColor("bg-black/[.37]")).toEqual({ backgroundColor: "#0000005E" });
+ expect(parseColor("text-white/[37%]")).toEqual({ color: "#FFFFFF5E" });
+ expect(parseColor("border-red-500/[.5]")).toEqual({
+ borderColor: applyOpacity(COLORS["red-500"], 50),
+ });
+ });
+
+ it("should multiply opacity for colors with an existing alpha channel", () => {
+ expect(parseColor("bg-[#edecf7af]/25")).toEqual({ backgroundColor: "#EDECF72C" });
+ expect(parseColor("text-translucent/[.5]", { translucent: "#11223380" })).toEqual({
+ color: "#11223340",
+ });
+ });
+
it("should handle opacity modifier with custom colors", () => {
const customColors = { "brand-primary": "#FF6B6B" };
expect(parseColor("bg-brand-primary/50", customColors)).toEqual({ backgroundColor: "#FF6B6B80" });
@@ -355,6 +370,8 @@ describe("parseColor - opacity modifiers", () => {
expect(parseColor("bg-black/101")).toBeNull(); // > 100
expect(parseColor("bg-black/-1")).toBeNull(); // < 0
expect(parseColor("bg-black/150")).toBeNull(); // Way over 100
+ expect(parseColor("bg-black/[1.1]")).toBeNull();
+ expect(parseColor("bg-black/[101%]")).toBeNull();
});
it("should return null for malformed opacity syntax", () => {
diff --git a/src/parser/colors.ts b/src/parser/colors.ts
index 5631e17..7431fbc 100644
--- a/src/parser/colors.ts
+++ b/src/parser/colors.ts
@@ -3,7 +3,7 @@
*/
import type { StyleObject } from "../types";
-import { COLORS, applyOpacity, parseArbitraryColor } from "../utils/colorUtils";
+import { COLORS, applyOpacity, parseArbitraryColor, parseColorOpacityModifier } from "../utils/colorUtils";
// Re-export COLORS for backward compatibility and tests
export { COLORS };
@@ -21,18 +21,16 @@ export function parseColor(cls: string, customColors?: Record):
// Helper to parse color with optional opacity modifier
// Uses internal implementation to preserve warnings for invalid arbitrary colors
const parseColorWithOpacity = (colorKey: string): string | null => {
- // Check for opacity modifier: blue-500/50
- const opacityMatch = colorKey.match(/^(.+)\/(\d+)$/);
- if (opacityMatch) {
- const baseColorKey = opacityMatch[1];
- const opacity = Number.parseInt(opacityMatch[2], 10);
-
- // Validate opacity range (0-100)
- if (opacity < 0 || opacity > 100) {
+ // Check for opacity modifier: blue-500/50, blue-500/[.5], blue-500/[50%]
+ const opacityModifier = parseColorOpacityModifier(colorKey);
+ if (opacityModifier) {
+ const { baseColorKey, opacity } = opacityModifier;
+ if (opacity === null) {
/* v8 ignore next 5 */
if (process.env.NODE_ENV !== "production") {
console.warn(
- `[react-native-tailwind] Invalid opacity value: ${opacity}. Opacity must be between 0 and 100.`,
+ `[react-native-tailwind] Invalid opacity modifier: ${opacityModifier.suffix}. ` +
+ "Use an integer percentage from 0 to 100, a raw arbitrary alpha from 0 to 1, or an arbitrary percentage.",
);
}
return null;
diff --git a/src/parser/modifiers.test.ts b/src/parser/modifiers.test.ts
index ca4fe63..afc0aab 100644
--- a/src/parser/modifiers.test.ts
+++ b/src/parser/modifiers.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
+import { applyOpacity } from "../utils/colorUtils";
+import { parseColor } from "./colors";
import type { ParsedModifier } from "./modifiers";
import {
expandSchemeModifier,
@@ -482,6 +484,73 @@ describe("expandSchemeModifier", () => {
});
});
+ it("should preserve opacity modifiers when expanding scheme colors", () => {
+ const modifier = { modifier: "scheme" as const, baseClass: "bg-primary/25" };
+ const result = expandSchemeModifier(modifier, customColors);
+
+ expect(result).toEqual([
+ { modifier: "dark", baseClass: "bg-primary-dark/25" },
+ { modifier: "light", baseClass: "bg-primary-light/25" },
+ ]);
+ });
+
+ it("should produce parsable color variants with the requested opacity", () => {
+ const [dark, light] = expandSchemeModifier(
+ { modifier: "scheme", baseClass: "bg-primary/25" },
+ customColors,
+ );
+
+ expect(parseColor(dark.baseClass, customColors)).toEqual({
+ backgroundColor: applyOpacity(customColors["primary-dark"], 25),
+ });
+ expect(parseColor(light.baseClass, customColors)).toEqual({
+ backgroundColor: applyOpacity(customColors["primary-light"], 25),
+ });
+ });
+
+ it("should preserve arbitrary opacity modifiers when expanding scheme colors", () => {
+ const result = expandSchemeModifier({ modifier: "scheme", baseClass: "bg-primary/[.37]" }, customColors);
+
+ expect(result).toEqual([
+ { modifier: "dark", baseClass: "bg-primary-dark/[.37]" },
+ { modifier: "light", baseClass: "bg-primary-light/[.37]" },
+ ]);
+ expect(parseColor(result[0].baseClass, customColors)).toEqual({
+ backgroundColor: applyOpacity(customColors["primary-dark"], 37),
+ });
+ });
+
+ it("should compose scheme opacity with configured colors that already have alpha", () => {
+ const colors = {
+ "glass-dark": "#11223380",
+ "glass-light": "#EDECF7AF",
+ };
+ const [dark, light] = expandSchemeModifier({ modifier: "scheme", baseClass: "bg-glass/50" }, colors);
+
+ expect(parseColor(dark.baseClass, colors)).toEqual({ backgroundColor: "#11223340" });
+ expect(parseColor(light.baseClass, colors)).toEqual({ backgroundColor: "#EDECF758" });
+ });
+
+ it("should preserve edge opacity values for every supported color prefix", () => {
+ expect(expandSchemeModifier({ modifier: "scheme", baseClass: "text-systemGray/0" }, customColors)).toEqual(
+ [
+ { modifier: "dark", baseClass: "text-systemGray-dark/0" },
+ { modifier: "light", baseClass: "text-systemGray-light/0" },
+ ],
+ );
+ expect(expandSchemeModifier({ modifier: "scheme", baseClass: "border-accent/100" }, customColors)).toEqual(
+ [
+ { modifier: "dark", baseClass: "border-accent-dark/100" },
+ { modifier: "light", baseClass: "border-accent-light/100" },
+ ],
+ );
+ });
+
+ it("should validate scheme variants without including the opacity suffix", () => {
+ const modifier = { modifier: "scheme" as const, baseClass: "bg-missing/25" };
+ expect(expandSchemeModifier(modifier, customColors)).toEqual([]);
+ });
+
it("should use custom suffixes when provided", () => {
const modifier = { modifier: "scheme" as const, baseClass: "text-systemGray" };
const _result = expandSchemeModifier(modifier, customColors, "-darkMode", "-lightMode");
@@ -494,6 +563,19 @@ describe("expandSchemeModifier", () => {
expect(expandSchemeModifier(modifier, expectedColors, "-darkMode", "-lightMode")).toHaveLength(2);
});
+ it("should combine opacity with custom scheme suffixes", () => {
+ const modifier = { modifier: "scheme" as const, baseClass: "text-systemGray/50" };
+ const colors = {
+ "systemGray-night": "#333333",
+ "systemGray-day": "#CCCCCC",
+ };
+
+ expect(expandSchemeModifier(modifier, colors, "-night", "-day")).toEqual([
+ { modifier: "dark", baseClass: "text-systemGray-night/50" },
+ { modifier: "light", baseClass: "text-systemGray-day/50" },
+ ]);
+ });
+
it("should return empty array for non-color classes", () => {
const modifier = { modifier: "scheme" as const, baseClass: "m-4" };
const result = expandSchemeModifier(modifier, customColors);
diff --git a/src/parser/modifiers.ts b/src/parser/modifiers.ts
index d2d7eab..7111e1e 100644
--- a/src/parser/modifiers.ts
+++ b/src/parser/modifiers.ts
@@ -6,6 +6,8 @@
* - Directional modifiers: rtl:, ltr: (RTL-aware styling)
*/
+import { parseColorOpacityModifier } from "../utils/colorUtils";
+
export type StateModifierType = "active" | "hover" | "focus" | "disabled" | "placeholder";
export type PlatformModifierType = "ios" | "android" | "web";
export type ColorSchemeModifierType = "dark" | "light";
@@ -225,7 +227,10 @@ export function expandSchemeModifier(
return [];
}
- const [, prefix, colorName] = match;
+ const [, prefix, colorWithOpacity] = match;
+ const opacityModifier = parseColorOpacityModifier(colorWithOpacity);
+ const colorName = opacityModifier?.baseColorKey ?? colorWithOpacity;
+ const opacitySuffix = opacityModifier?.suffix ?? "";
// Build variant class names
const darkColorName = `${colorName}${darkSuffix}`;
@@ -252,11 +257,11 @@ export function expandSchemeModifier(
return [
{
modifier: "dark" as ColorSchemeModifierType,
- baseClass: `${prefix}-${darkColorName}`,
+ baseClass: `${prefix}-${darkColorName}${opacitySuffix}`,
},
{
modifier: "light" as ColorSchemeModifierType,
- baseClass: `${prefix}-${lightColorName}`,
+ baseClass: `${prefix}-${lightColorName}${opacitySuffix}`,
},
];
}
diff --git a/src/utils/colorUtils.test.ts b/src/utils/colorUtils.test.ts
index 4e1e08a..08f22e7 100644
--- a/src/utils/colorUtils.test.ts
+++ b/src/utils/colorUtils.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
-import { COLORS, applyOpacity, parseArbitraryColor, parseColorValue } from "./colorUtils";
+import {
+ COLORS,
+ applyOpacity,
+ parseArbitraryColor,
+ parseColorOpacityModifier,
+ parseColorValue,
+} from "./colorUtils";
describe("COLORS", () => {
it("should include basic colors", () => {
@@ -31,6 +37,12 @@ describe("applyOpacity", () => {
expect(applyOpacity("#00f", 75)).toBe("#0000FFBF");
});
+ it("should multiply existing alpha on 8-digit hex colors", () => {
+ expect(applyOpacity("#ff000080", 50)).toBe("#FF000040");
+ expect(applyOpacity("#edecf7af", 25)).toBe("#EDECF72C");
+ expect(applyOpacity("#edecf7af", 100)).toBe("#EDECF7AF");
+ });
+
it("should handle various opacity values", () => {
expect(applyOpacity("#000000", 0)).toBe("#00000000");
expect(applyOpacity("#000000", 25)).toBe("#00000040");
@@ -52,6 +64,39 @@ describe("applyOpacity", () => {
});
});
+describe("parseColorOpacityModifier", () => {
+ it("should parse named percentages and arbitrary alpha values", () => {
+ expect(parseColorOpacityModifier("card/35")).toEqual({
+ baseColorKey: "card",
+ opacity: 35,
+ suffix: "/35",
+ });
+ expect(parseColorOpacityModifier("card/[.37]")).toEqual({
+ baseColorKey: "card",
+ opacity: 37,
+ suffix: "/[.37]",
+ });
+ expect(parseColorOpacityModifier("card/[37%]")).toEqual({
+ baseColorKey: "card",
+ opacity: 37,
+ suffix: "/[37%]",
+ });
+ });
+
+ it("should preserve recognized but out-of-range modifiers for validation", () => {
+ expect(parseColorOpacityModifier("card/101")).toEqual({
+ baseColorKey: "card",
+ opacity: null,
+ suffix: "/101",
+ });
+ expect(parseColorOpacityModifier("card/[1.1]")).toEqual({
+ baseColorKey: "card",
+ opacity: null,
+ suffix: "/[1.1]",
+ });
+ });
+});
+
describe("parseArbitraryColor", () => {
it("should parse 6-digit hex colors", () => {
expect(parseArbitraryColor("[#ff0000]")).toBe("#ff0000");
@@ -144,6 +189,17 @@ describe("parseColorValue", () => {
expect(parseColorValue("[#0000ff]/80")).toBe("#0000FFCC");
});
+ it("should apply arbitrary raw and percentage opacity values", () => {
+ expect(parseColorValue("red-500/[.37]")).toBe(applyOpacity(COLORS["red-500"], 37));
+ expect(parseColorValue("black/[37%]")).toBe("#0000005E");
+ expect(parseColorValue("[#ff0000]/[.5]")).toBe("#FF000080");
+ });
+
+ it("should compose opacity with colors that already have alpha", () => {
+ expect(parseColorValue("[#ff000080]/50")).toBe("#FF000040");
+ expect(parseColorValue("translucent/[.25]", { translucent: "#edecf7af" })).toBe("#EDECF72C");
+ });
+
it("should handle edge opacity values", () => {
expect(parseColorValue("red-500/0")).toBe(applyOpacity(COLORS["red-500"], 0));
expect(parseColorValue("red-500/100")).toBe(applyOpacity(COLORS["red-500"], 100));
@@ -153,6 +209,8 @@ describe("parseColorValue", () => {
expect(parseColorValue("red-500/101")).toBeNull();
expect(parseColorValue("red-500/-1")).toBeNull();
expect(parseColorValue("red-500/abc")).toBeNull();
+ expect(parseColorValue("red-500/[1.1]")).toBeNull();
+ expect(parseColorValue("red-500/[101%]")).toBeNull();
});
it("should keep transparent unchanged with opacity", () => {
diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts
index 27ecbca..9740cc7 100644
--- a/src/utils/colorUtils.ts
+++ b/src/utils/colorUtils.ts
@@ -16,8 +16,9 @@ export const COLORS: Record = {
};
/**
- * Apply opacity to hex color by appending alpha channel
- * @param hex - Hex color string (e.g., "#ff0000", "#f00", or "transparent")
+ * Apply opacity to a hex color. Existing alpha is multiplied, matching
+ * Tailwind's color-mix-with-transparent semantics.
+ * @param hex - Hex color string (e.g., "#ff0000", "#f00", "#ff000080", or "transparent")
* @param opacity - Opacity value 0-100 (e.g., 50 for 50%)
* @returns 8-digit hex with alpha (e.g., "#FF000080") or transparent
*/
@@ -27,7 +28,7 @@ export function applyOpacity(hex: string, opacity: number): string {
}
const cleanHex = hex.replace(/^#/, "");
- const fullHex =
+ const normalizedHex =
cleanHex.length === 3
? cleanHex
.split("")
@@ -35,10 +36,49 @@ export function applyOpacity(hex: string, opacity: number): string {
.join("")
: cleanHex;
- const alpha = Math.round((opacity / 100) * 255);
+ const rgbHex = normalizedHex.slice(0, 6);
+ const existingAlpha = normalizedHex.length === 8 ? Number.parseInt(normalizedHex.slice(6), 16) : 255;
+ const alpha = Math.round((opacity / 100) * existingAlpha);
const alphaHex = alpha.toString(16).padStart(2, "0").toUpperCase();
- return `#${fullHex.toUpperCase()}${alphaHex}`;
+ return `#${rgbHex.toUpperCase()}${alphaHex}`;
+}
+
+export type ColorOpacityModifier = {
+ baseColorKey: string;
+ opacity: number | null;
+ suffix: string;
+};
+
+const COLOR_OPACITY_MODIFIER_PATTERN = /^(.+)\/(\d+|\[((?:\d+(?:\.\d*)?|\.\d+)(?:%)?)\])$/;
+
+/**
+ * Split and parse a Tailwind color opacity modifier.
+ * Named values are percentages (`/50`), while arbitrary values accept a raw
+ * alpha (`/[.5]`) or an explicit percentage (`/[50%]`).
+ */
+export function parseColorOpacityModifier(colorKey: string): ColorOpacityModifier | null {
+ const match = colorKey.match(COLOR_OPACITY_MODIFIER_PATTERN);
+ if (!match) {
+ return null;
+ }
+
+ const [, baseColorKey, modifier, arbitraryValue] = match;
+ let opacity: number;
+
+ if (arbitraryValue === undefined) {
+ opacity = Number.parseInt(modifier, 10);
+ } else if (arbitraryValue.endsWith("%")) {
+ opacity = Number.parseFloat(arbitraryValue.slice(0, -1));
+ } else {
+ opacity = Number.parseFloat(arbitraryValue) * 100;
+ }
+
+ return {
+ baseColorKey,
+ opacity: Number.isFinite(opacity) && opacity >= 0 && opacity <= 100 ? opacity : null,
+ suffix: `/${modifier}`,
+ };
}
/**
@@ -77,14 +117,11 @@ export function parseColorValue(colorKey: string, customColors?: Record 100) {
+ // Check for opacity modifier: red-500/50, red-500/[.5], [#ff0000]/[50%]
+ const opacityModifier = parseColorOpacityModifier(colorKey);
+ if (opacityModifier) {
+ const { baseColorKey, opacity } = opacityModifier;
+ if (opacity === null) {
return null;
}