diff --git a/docs/src/content/docs/guides/color-scheme.md b/docs/src/content/docs/guides/color-scheme.md
index 5080cb6..9c72784 100644
--- a/docs/src/content/docs/guides/color-scheme.md
+++ b/docs/src/content/docs/guides/color-scheme.md
@@ -170,8 +170,24 @@ The `scheme:` modifier only works with color utilities:
- ✅ `scheme:text-{color}` — Text colors
- ✅ `scheme:bg-{color}` — Background colors
- ✅ `scheme:border-{color}` — Border colors
+- ✅ `scheme:outline-{color}` — Outline 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/docs/src/content/docs/reference/api.md b/docs/src/content/docs/reference/api.md
index fe5d564..9be3258 100644
--- a/docs/src/content/docs/reference/api.md
+++ b/docs/src/content/docs/reference/api.md
@@ -5,6 +5,82 @@ description: Access the parser and constants programmatically
Access the parser and constants programmatically for advanced use cases.
+## Compile-Time Raw Colors
+
+Use `twColor` for a static color string that can be declared at module scope. It accepts the same palette names, utility-form names, slash opacity, and arbitrary hex values as color utilities:
+
+```tsx
+import { twColor } from "@mgcrea/react-native-tailwind";
+
+const blue = twColor`blue-500`;
+const translucentBlue = twColor`blue-500/35`;
+const customHex = twColor`[#50d71e]`;
+```
+
+Each tag is replaced with a string literal and has no runtime cost. Interpolations and `scheme:` tokens produce a compile error. A static tag cannot react to a runtime color scheme, so use `useTwColor` for scheme-aware colors.
+
+Use `useTwColor` when a React Native API needs a reactive color string instead of a `style` object, such as navigation options, gradients, SVG, or Skia:
+
+```tsx
+import { useTwColor, useTwColors } from "@mgcrea/react-native-tailwind";
+
+function Header() {
+ const tintColor = useTwColor("scheme:accent");
+
+ return ;
+}
+```
+
+Hook tokens use the same configured palette as color classes. Prefix a token with `scheme:` to resolve its configured light and dark variants reactively:
+
+```tsx
+const card = useTwColor("scheme:card");
+const fallback = useTwColor("blue-500");
+```
+
+The Babel plugin replaces each call with literal color strings. Scheme tokens reuse the plugin's configured `colorScheme` hook, including custom application theme hooks.
+When that hook returns `"dark"`, the dark variant is used; `"light"`, `null`, and other non-dark values use the light variant, matching the default light appearance before a dark preference is active.
+
+Use `useTwColors` to resolve several named colors with one injected scheme hook:
+
+```tsx
+function Screen() {
+ const colors = useTwColors({
+ background: "scheme:background",
+ text: "scheme:text",
+ accent: "accent",
+ });
+
+ return (
+
+ Hello
+
+ );
+}
+```
+
+Both helpers require static string literals and must be called unconditionally inside a function component. Unknown colors, unsupported modifiers, dynamic expressions, and module-scope calls produce a compile error. Utility-form tokens such as `bg-card` and `text-accent` are accepted, but raw palette names are preferred when the consumer needs only a string.
+
+### Type Checking Custom Theme Tokens
+
+The Babel plugin validates every token against the actual Tailwind configuration, but the exported TypeScript signature accepts `string` because a library declaration cannot automatically infer another project's Babel-loaded configuration or arbitrary-value grammar. Applications can keep config-derived autocomplete and typo checking with a direct `satisfies` expression:
+
+```tsx
+import tailwindConfig from "./tailwind.config";
+
+type ThemeColor = keyof typeof tailwindConfig.theme.extend.colors;
+type SchemeThemeColor = `scheme:${ThemeColor}`;
+
+function Card() {
+ const background = useTwColor("scheme:card" satisfies SchemeThemeColor);
+ const colors = useTwColors({
+ text: "scheme:text" satisfies SchemeThemeColor,
+ });
+}
+```
+
+TypeScript checks the literal against the application's config-derived union, then the Babel plugin removes the type-only expression and compiles the color normally. Keep the literal directly inside `useTwColor` or `useTwColors`; variables and wrapper functions are intentionally rejected so the compiler can prove the token statically.
+
## parseClassName
Parse className strings to React Native styles:
@@ -166,7 +242,7 @@ function ThemedComponent() {
## Important Notes
-- The programmatic API parses styles at **runtime**, not compile-time
+- `twColor`, `useTwColor`, and `useTwColors` are compile-time APIs; `parseClassName` parses styles at runtime
- For production apps, prefer using `className` prop for compile-time optimization
- Use the programmatic API for:
- Testing
diff --git a/docs/src/content/docs/reference/outlines.md b/docs/src/content/docs/reference/outlines.md
index 7593e08..8367b36 100644
--- a/docs/src/content/docs/reference/outlines.md
+++ b/docs/src/content/docs/reference/outlines.md
@@ -24,6 +24,8 @@ Utilities for controlling the outline style of an element.
// outlineColor: '#3B82F6'
// outlineColor: '#ff0000'
// outlineColor: '#EF4444' (50% opacity)
+ // Theme-aware outline color
+ // Expands to light/dark variants
```
## Outline Style
diff --git a/src/babel/plugin.ts b/src/babel/plugin.ts
index 078f8c6..046d098 100644
--- a/src/babel/plugin.ts
+++ b/src/babel/plugin.ts
@@ -13,6 +13,7 @@ import { jsxAttributeVisitor } from "./plugin/visitors/className.js";
import { importDeclarationVisitor } from "./plugin/visitors/imports.js";
import { programEnter, programExit } from "./plugin/visitors/program.js";
import { callExpressionVisitor, taggedTemplateVisitor } from "./plugin/visitors/tw.js";
+import { twColorCallExpressionVisitor, twColorTaggedTemplateVisitor } from "./plugin/visitors/twColor.js";
import { scanForColorSchemeModifiers } from "./utils/preInjection.js";
import { injectColorSchemeHook } from "./utils/styleInjection.js";
@@ -80,6 +81,12 @@ export default function reactNativeTailwindBabelPlugin(
const importedName = spec.imported.name;
if (importedName === "tw" || importedName === "twStyle") {
state.twImportNames.add(spec.local.name);
+ } else if (
+ importedName === "twColor" ||
+ importedName === "useTwColor" ||
+ importedName === "useTwColors"
+ ) {
+ state.twColorImportNames.set(spec.local.name, importedName);
}
}
}
@@ -100,6 +107,11 @@ export default function reactNativeTailwindBabelPlugin(
state.attributePatterns,
state.twImportNames,
t,
+ new Set(
+ [...state.twColorImportNames]
+ .filter(([, helper]) => helper !== "twColor")
+ .map(([localName]) => localName),
+ ),
)
) {
injectColorSchemeHook(
@@ -130,10 +142,12 @@ export default function reactNativeTailwindBabelPlugin(
TaggedTemplateExpression(path, state) {
taggedTemplateVisitor(path, state, t);
+ twColorTaggedTemplateVisitor(path, state, t);
},
CallExpression(path, state) {
callExpressionVisitor(path, state, t);
+ twColorCallExpressionVisitor(path, state, t);
},
JSXAttribute(path, state) {
diff --git a/src/babel/plugin/state.ts b/src/babel/plugin/state.ts
index 91d8971..d7d15d5 100644
--- a/src/babel/plugin/state.ts
+++ b/src/babel/plugin/state.ts
@@ -116,6 +116,8 @@ export type PluginState = PluginPass & {
// Track tw/twStyle imports from main package
twImportNames: Set; // e.g., ['tw', 'twStyle'] or ['tw as customTw']
hasTwImport: boolean;
+ twColorImportNames: Map;
+ hasTwColorImport: boolean;
// Track react-native import path for conditional StyleSheet/Platform injection
reactNativeImportPath?: NodePath;
// Track function components that need colorScheme hook injection
@@ -179,6 +181,8 @@ export function createInitialState(
stylesIdentifier,
twImportNames: new Set(),
hasTwImport: false,
+ twColorImportNames: new Map(),
+ hasTwColorImport: false,
reactNativeImportPath: undefined,
functionComponentsNeedingColorScheme: new Set(),
functionComponentsNeedingWindowDimensions: new Set(),
diff --git a/src/babel/plugin/visitors/imports.ts b/src/babel/plugin/visitors/imports.ts
index 3dad7f2..0c595e0 100644
--- a/src/babel/plugin/visitors/imports.ts
+++ b/src/babel/plugin/visitors/imports.ts
@@ -110,6 +110,12 @@ export function importDeclarationVisitor(
const localName = spec.local.name;
state.twImportNames.add(localName);
// Don't set hasTwImport yet - only set it when we successfully transform a call
+ } else if (
+ importedName === "twColor" ||
+ importedName === "useTwColor" ||
+ importedName === "useTwColors"
+ ) {
+ state.twColorImportNames.set(spec.local.name, importedName);
}
}
});
diff --git a/src/babel/plugin/visitors/program.ts b/src/babel/plugin/visitors/program.ts
index 251c474..f8d4919 100644
--- a/src/babel/plugin/visitors/program.ts
+++ b/src/babel/plugin/visitors/program.ts
@@ -16,6 +16,7 @@ import {
injectStylesAtTop,
injectWindowDimensionsHook,
} from "../../utils/styleInjection.js";
+import { removeTwColorImports } from "../../utils/twColorProcessing.js";
import { removeTwImports } from "../../utils/twProcessing.js";
import type { PluginState } from "../state.js";
@@ -41,6 +42,9 @@ export function programExit(
if (state.hasTwImport) {
removeTwImports(path, t);
}
+ if (state.hasTwColorImport) {
+ removeTwColorImports(path, t);
+ }
// If no classNames were found and no hooks/imports needed, skip processing
if (
diff --git a/src/babel/plugin/visitors/twColor.test.ts b/src/babel/plugin/visitors/twColor.test.ts
new file mode 100644
index 0000000..6055fac
--- /dev/null
+++ b/src/babel/plugin/visitors/twColor.test.ts
@@ -0,0 +1,285 @@
+import { describe, expect, it } from "vitest";
+
+import { transform } from "../../../../test/helpers/babelTransform.js";
+import { COLORS } from "../../../parser/colors.js";
+import { applyOpacity } from "../../../utils/colorUtils.js";
+
+describe("twColor tag", () => {
+ it("should compile a module-level static token to a string literal", () => {
+ const output = transform(`
+ import { twColor } from '@mgcrea/react-native-tailwind';
+ export const color = twColor\`blue-500\`;
+ `);
+
+ expect(output).toContain(`export const color = "${COLORS["blue-500"]}"`);
+ expect(output).not.toContain("twColor");
+ expect(output).not.toContain("useColorScheme");
+ });
+
+ it("should support utility-form tokens, opacity, arbitrary hex, and aliased imports", () => {
+ const output = transform(`
+ import { twColor as color } from '@mgcrea/react-native-tailwind';
+ export const background = color\`bg-red-500/35\`;
+ export const foreground = color\`[#abcdef]\`;
+ `);
+
+ expect(output).toContain('export const background = "#FB2C3659"');
+ expect(output).toContain('export const foreground = "#abcdef"');
+ expect(output).not.toContain("twColor");
+ expect(output).not.toContain("color`");
+ });
+
+ it("should support arbitrary opacity from the shared color grammar", () => {
+ const output = transform(`
+ import { twColor } from '@mgcrea/react-native-tailwind';
+ export const color = twColor\`blue-500/[.37]\`;
+ `);
+
+ expect(output).toContain('export const color = "#2B7FFF5E"');
+ });
+
+ it("should reject scheme tokens with an actionable useTwColor error", () => {
+ expect(() =>
+ transform(`
+ import { twColor } from '@mgcrea/react-native-tailwind';
+ export const color = twColor\`scheme:not-configured\`;
+ `),
+ ).toThrow(/cannot resolve a runtime color scheme.*Use useTwColor/);
+ });
+
+ it("should reject interpolations, empty tags, unknown colors, and function-call syntax", () => {
+ expect(() =>
+ transform(`
+ import { twColor } from '@mgcrea/react-native-tailwind';
+ const shade = 500;
+ export const color = twColor\`blue-\${shade}\`;
+ `),
+ ).toThrow(/without interpolations/);
+
+ expect(() =>
+ transform(`
+ import { twColor } from '@mgcrea/react-native-tailwind';
+ export const color = twColor\`\`;
+ `),
+ ).toThrow(/requires one static color token/);
+
+ expect(() =>
+ transform(`
+ import { twColor } from '@mgcrea/react-native-tailwind';
+ export const color = twColor\`not-a-real-color\`;
+ `),
+ ).toThrow(/Unknown or unsupported color token/);
+
+ expect(() =>
+ transform(`
+ import { twColor } from '@mgcrea/react-native-tailwind';
+ export const color = twColor('blue-500');
+ `),
+ ).toThrow(/must be used as a tagged template/);
+ });
+});
+
+describe("raw color hooks", () => {
+ it("should compile a static color token to a string literal", () => {
+ const output = transform(`
+ import { useTwColor } from '@mgcrea/react-native-tailwind';
+ export function Component() {
+ const color = useTwColor('blue-500');
+ return color;
+ }
+ `);
+
+ expect(output).toContain(`const color = "${COLORS["blue-500"]}"`);
+ expect(output).not.toContain("useTwColor");
+ expect(output).not.toContain("useColorScheme");
+ });
+
+ it("should support utility-form tokens, opacity, and arbitrary hex", () => {
+ const output = transform(`
+ import { useTwColors } from '@mgcrea/react-native-tailwind';
+ export function Component() {
+ return useTwColors({
+ background: 'bg-red-500/35',
+ foreground: 'text-[#abcdef]',
+ });
+ }
+ `);
+
+ expect(output).toContain('background: "#FB2C3659"');
+ expect(output).toContain('foreground: "#abcdef"');
+ expect(output).not.toContain("useTwColors");
+ });
+
+ it("should compile a scheme token with the configured hook", () => {
+ const output = transform(
+ `
+ import { useTwColor } from '@mgcrea/react-native-tailwind';
+ export function Component() {
+ return useTwColor('scheme:gray');
+ }
+ `,
+ {
+ colorScheme: {
+ importFrom: "@/theme/useColorScheme",
+ importName: "useAppColorScheme",
+ },
+ schemeModifier: {
+ darkSuffix: "-900",
+ lightSuffix: "-100",
+ },
+ },
+ );
+
+ expect(output).toContain('from "@/theme/useColorScheme"');
+ expect(output).toContain("useAppColorScheme()");
+ expect(output).toContain('_twColorScheme === "dark"');
+ expect(output).toContain(`? "${COLORS["gray-900"]}"`);
+ expect(output).toContain(`: "${COLORS["gray-100"]}"`);
+ });
+
+ it("should compile scheme outline colors with arbitrary opacity", () => {
+ const output = transform(
+ `
+ import { useTwColor } from '@mgcrea/react-native-tailwind';
+ export function Component() {
+ return useTwColor('scheme:outline-gray/[.37]');
+ }
+ `,
+ { schemeModifier: { darkSuffix: "-900", lightSuffix: "-100" } },
+ );
+
+ expect(output).toContain(`? "${applyOpacity(COLORS["gray-900"], 37)}"`);
+ expect(output).toContain(`: "${applyOpacity(COLORS["gray-100"], 37)}"`);
+ });
+
+ it("should preserve TypeScript satisfies checks around direct static tokens", () => {
+ const output = transform(
+ `
+ import { useTwColor, useTwColors } from '@mgcrea/react-native-tailwind';
+ type AppColor = 'card' | 'text';
+ type AppSchemeColor = \`scheme:\${AppColor}\`;
+
+ export function Component() {
+ const card = useTwColor('scheme:gray' satisfies string);
+ const colors = useTwColors({ text: 'black' satisfies string });
+ return [card, colors.text];
+ }
+ `,
+ { schemeModifier: { darkSuffix: "-900", lightSuffix: "-100" } },
+ true,
+ );
+
+ expect(output).toContain(`? "${COLORS["gray-900"]}"`);
+ expect(output).toContain('text: "#000000"');
+ expect(output).not.toContain("satisfies");
+ });
+
+ it("should inject one scheme hook for a color object", () => {
+ const output = transform(
+ `
+ import { useTwColors } from '@mgcrea/react-native-tailwind';
+ export function Component() {
+ return useTwColors({
+ background: 'scheme:gray',
+ foreground: 'scheme:slate',
+ accent: 'blue-500',
+ });
+ }
+ `,
+ {
+ schemeModifier: {
+ darkSuffix: "-900",
+ lightSuffix: "-100",
+ },
+ },
+ );
+
+ expect(output.match(/_twColorScheme\s*=\s*useColorScheme\(\)/g)).toHaveLength(1);
+ expect(output).toContain("background:");
+ expect(output).toContain("foreground:");
+ expect(output).toContain("accent:");
+ });
+
+ it("should pre-inject the scheme hook for a concise arrow component", () => {
+ const output = transform(
+ `
+ import { useTwColor } from '@mgcrea/react-native-tailwind';
+ export const Component = () => useTwColor('scheme:gray');
+ `,
+ {
+ schemeModifier: {
+ darkSuffix: "-900",
+ lightSuffix: "-100",
+ },
+ },
+ );
+
+ expect(output.match(/_twColorScheme\s*=\s*useColorScheme\(\)/g)).toHaveLength(1);
+ expect(output).toContain('_twColorScheme === "dark"');
+ expect(output).toContain(`? "${COLORS["gray-900"]}"`);
+ expect(output).toContain(`: "${COLORS["gray-100"]}"`);
+ });
+
+ it("should transform aliased imports and preserve unrelated imports", () => {
+ const output = transform(`
+ import { parseColor, useTwColor as useColor } from '@mgcrea/react-native-tailwind';
+ export function Component() {
+ return [useColor('black'), parseColor];
+ }
+ `);
+
+ expect(output).toContain("parseColor");
+ expect(output).not.toContain("useTwColor");
+ expect(output).not.toContain("useColor as");
+ expect(output).toContain('"#000000"');
+ });
+
+ it("should reject unknown, dynamic, and module-scope tokens", () => {
+ expect(() =>
+ transform(`
+ import { useTwColor } from '@mgcrea/react-native-tailwind';
+ export function Component() {
+ return useTwColor('not-a-real-color');
+ }
+ `),
+ ).toThrow(/Unknown or unsupported color token/);
+
+ expect(() =>
+ transform(`
+ import { useTwColor } from '@mgcrea/react-native-tailwind';
+ export function Component({ token }) {
+ return useTwColor(token);
+ }
+ `),
+ ).toThrow(/requires a static string literal/);
+
+ expect(() =>
+ transform(
+ `
+ import { useTwColor } from '@mgcrea/react-native-tailwind';
+ export const color = useTwColor('scheme:gray');
+ `,
+ { schemeModifier: { darkSuffix: "-900", lightSuffix: "-100" } },
+ ),
+ ).toThrow(/must be called inside a React function component/);
+
+ expect(() =>
+ transform(`
+ import { useTwColor } from '@mgcrea/react-native-tailwind';
+ export const color = useTwColor('blue-500');
+ `),
+ ).toThrow(/must be called inside a React function component/);
+ });
+
+ it("should reject runtime references to compile-only color helpers", () => {
+ expect(() =>
+ transform(`
+ import { useTwColor } from '@mgcrea/react-native-tailwind';
+ const resolveColor = useTwColor;
+ export function Component() {
+ return useTwColor('blue-500');
+ }
+ `),
+ ).toThrow(/must be called directly/);
+ });
+});
diff --git a/src/babel/plugin/visitors/twColor.ts b/src/babel/plugin/visitors/twColor.ts
new file mode 100644
index 0000000..2e95eb2
--- /dev/null
+++ b/src/babel/plugin/visitors/twColor.ts
@@ -0,0 +1,155 @@
+/** CallExpression visitor for compile-time raw color hooks. */
+
+import type { NodePath } from "@babel/core";
+import type * as BabelTypes from "@babel/types";
+
+import {
+ assertTwColorComponentScope,
+ ensureTwColorSchemeHook,
+ resolveTwColorToken,
+ twColorTokenToExpression,
+} from "../../utils/twColorProcessing.js";
+import type { ResolvedTwColorToken } from "../../utils/twColorProcessing.js";
+import type { PluginState } from "../state.js";
+
+function invalidCall(path: NodePath, message: string): never {
+ throw path.buildCodeFrameError(`[react-native-tailwind] ${message}`);
+}
+
+function invalidTag(path: NodePath, message: string): never {
+ throw path.buildCodeFrameError(`[react-native-tailwind] ${message}`);
+}
+
+function getStaticStringLiteral(node: BabelTypes.Node, t: typeof BabelTypes): BabelTypes.StringLiteral | null {
+ if (t.isStringLiteral(node)) {
+ return node;
+ }
+
+ if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isTSTypeAssertion(node)) {
+ return getStaticStringLiteral(node.expression, t);
+ }
+
+ return null;
+}
+
+/** Compile static twColor`...` tokens to native color string literals. */
+export function twColorTaggedTemplateVisitor(
+ path: NodePath,
+ state: PluginState,
+ t: typeof BabelTypes,
+): void {
+ if (!t.isIdentifier(path.node.tag) || state.twColorImportNames.get(path.node.tag.name) !== "twColor") {
+ return;
+ }
+
+ if (path.node.quasi.expressions.length > 0) {
+ invalidTag(path, "twColor`...` only supports one static color token without interpolations.");
+ }
+
+ const tokenValue = path.node.quasi.quasis[0]?.value.cooked?.trim() ?? "";
+ if (!tokenValue) {
+ invalidTag(path, "twColor`...` requires one static color token.");
+ }
+
+ if (tokenValue.startsWith("scheme:")) {
+ invalidTag(
+ path,
+ `twColor\`${tokenValue}\` cannot resolve a runtime color scheme. ` +
+ `Use useTwColor("${tokenValue}") inside a function component.`,
+ );
+ }
+
+ const token = resolveTwColorToken(tokenValue, state);
+ if (!token || token.kind !== "static") {
+ invalidTag(path, `Unknown or unsupported color token: "${tokenValue}".`);
+ }
+
+ path.replaceWith(t.stringLiteral(token.color));
+ state.hasTwColorImport = true;
+}
+
+export function twColorCallExpressionVisitor(
+ path: NodePath,
+ state: PluginState,
+ t: typeof BabelTypes,
+): void {
+ if (!t.isIdentifier(path.node.callee)) {
+ return;
+ }
+
+ const helper = state.twColorImportNames.get(path.node.callee.name);
+ if (!helper) {
+ return;
+ }
+
+ if (helper === "twColor") {
+ invalidCall(path, "twColor must be used as a tagged template: twColor`blue-500`.");
+ }
+
+ if (path.node.arguments.length !== 1) {
+ invalidCall(path, `${helper}() expects exactly one argument.`);
+ }
+
+ assertTwColorComponentScope(path, t);
+
+ const argument = path.node.arguments[0];
+ let needsScheme = false;
+
+ if (helper === "useTwColor") {
+ const stringArgument = getStaticStringLiteral(argument, t);
+ if (!stringArgument) {
+ invalidCall(path, "useTwColor() requires a static string literal.");
+ }
+
+ const token = resolveTwColorToken(stringArgument.value, state);
+ if (!token) {
+ invalidCall(path, `Unknown or unsupported color token: "${stringArgument.value}".`);
+ }
+
+ needsScheme = token.kind === "scheme";
+ if (needsScheme) {
+ ensureTwColorSchemeHook(path, state, t);
+ }
+ path.replaceWith(twColorTokenToExpression(token, state, t));
+ } else {
+ if (!t.isObjectExpression(argument)) {
+ invalidCall(path, "useTwColors() requires an object literal of static color tokens.");
+ }
+
+ const properties: BabelTypes.ObjectProperty[] = [];
+ const tokens: Array<{ property: BabelTypes.ObjectProperty; token: ResolvedTwColorToken }> = [];
+
+ for (const property of argument.properties) {
+ if (
+ !t.isObjectProperty(property) ||
+ property.computed ||
+ (!t.isIdentifier(property.key) && !t.isStringLiteral(property.key)) ||
+ !getStaticStringLiteral(property.value, t)
+ ) {
+ invalidCall(path, "useTwColors() only supports plain object properties with static string values.");
+ }
+
+ const stringValue = getStaticStringLiteral(property.value, t);
+ if (!stringValue) {
+ invalidCall(path, "useTwColors() only supports plain object properties with static string values.");
+ }
+ const token = resolveTwColorToken(stringValue.value, state);
+ if (!token) {
+ invalidCall(path, `Unknown or unsupported color token: "${stringValue.value}".`);
+ }
+ needsScheme ||= token.kind === "scheme";
+ tokens.push({ property, token });
+ }
+
+ if (needsScheme) {
+ ensureTwColorSchemeHook(path, state, t);
+ }
+
+ for (const { property, token } of tokens) {
+ properties.push(t.objectProperty(t.cloneNode(property.key), twColorTokenToExpression(token, state, t)));
+ }
+ path.replaceWith(t.objectExpression(properties));
+ }
+
+ state.hasTwColorImport = true;
+}
diff --git a/src/babel/utils/preInjection.test.ts b/src/babel/utils/preInjection.test.ts
new file mode 100644
index 0000000..2169f73
--- /dev/null
+++ b/src/babel/utils/preInjection.test.ts
@@ -0,0 +1,57 @@
+import { parseSync } from "@babel/core";
+import * as BabelTypes from "@babel/types";
+import { describe, expect, it } from "vitest";
+
+import { scanForColorSchemeModifiers } from "./preInjection";
+
+describe("scanForColorSchemeModifiers", () => {
+ it("should detect typed scheme tokens before React Compiler analysis", () => {
+ const ast = parseSync(
+ `
+ function Component() {
+ return useTwColor("scheme:card" satisfies AppThemeColor);
+ }
+ `,
+ { parserOpts: { plugins: ["typescript"] } },
+ );
+ const declaration = ast?.program.body[0];
+ expect(BabelTypes.isFunctionDeclaration(declaration)).toBe(true);
+ if (!BabelTypes.isFunctionDeclaration(declaration)) return;
+
+ expect(
+ scanForColorSchemeModifiers(
+ declaration.body,
+ new Set(),
+ [],
+ new Set(),
+ BabelTypes,
+ new Set(["useTwColor"]),
+ ),
+ ).toBe(true);
+ });
+
+ it("should detect typed scheme values in useTwColors objects", () => {
+ const ast = parseSync(
+ `
+ function Component() {
+ return useTwColors({ card: "scheme:card" as AppThemeColor });
+ }
+ `,
+ { parserOpts: { plugins: ["typescript"] } },
+ );
+ const declaration = ast?.program.body[0];
+ expect(BabelTypes.isFunctionDeclaration(declaration)).toBe(true);
+ if (!BabelTypes.isFunctionDeclaration(declaration)) return;
+
+ expect(
+ scanForColorSchemeModifiers(
+ declaration.body,
+ new Set(),
+ [],
+ new Set(),
+ BabelTypes,
+ new Set(["useTwColors"]),
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/src/babel/utils/preInjection.ts b/src/babel/utils/preInjection.ts
index 99461ba..e6ef79f 100644
--- a/src/babel/utils/preInjection.ts
+++ b/src/babel/utils/preInjection.ts
@@ -14,6 +14,15 @@ import type * as BabelTypes from "@babel/types";
const COLOR_SCHEME_PATTERN = /(?:^|\s)(?:dark:|light:|scheme:)/;
+function getStaticStringValue(node: BabelTypes.Node | undefined, t: typeof BabelTypes): string | null {
+ if (!node) return null;
+ if (t.isStringLiteral(node)) return node.value;
+ if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isTSTypeAssertion(node)) {
+ return getStaticStringValue(node.expression, t);
+ }
+ return null;
+}
+
/**
* Scan an AST node tree for color scheme modifiers in class name contexts.
*
@@ -26,8 +35,9 @@ export function scanForColorSchemeModifiers(
attributePatterns: RegExp[],
twImportNames: Set,
t: typeof BabelTypes,
+ twColorImportNames: Set = new Set(),
): boolean {
- return walkNode(node, supportedAttributes, attributePatterns, twImportNames, t);
+ return walkNode(node, supportedAttributes, attributePatterns, twImportNames, t, twColorImportNames);
}
function walkNode(
@@ -36,6 +46,7 @@ function walkNode(
attributePatterns: RegExp[],
twImportNames: Set,
t: typeof BabelTypes,
+ twColorImportNames: Set,
): boolean {
// Check JSXAttribute with color scheme class names
if (t.isJSXAttribute(node) && t.isJSXIdentifier(node.name)) {
@@ -65,6 +76,22 @@ function walkNode(
return true;
}
}
+
+ if (twColorImportNames.has(node.callee.name)) {
+ const arg = node.arguments[0];
+ const token = arg && !t.isArgumentPlaceholder(arg) ? getStaticStringValue(arg, t) : null;
+ if (token && COLOR_SCHEME_PATTERN.test(token)) {
+ return true;
+ }
+ if (t.isObjectExpression(arg)) {
+ for (const property of arg.properties) {
+ const propertyToken = t.isObjectProperty(property) ? getStaticStringValue(property.value, t) : null;
+ if (propertyToken && COLOR_SCHEME_PATTERN.test(propertyToken)) {
+ return true;
+ }
+ }
+ }
+ }
}
// Walk children, skipping nested functions
@@ -80,7 +107,7 @@ function walkNode(
if (isASTNode(item)) {
// Skip nested functions (they have their own scope)
if (t.isFunction(item)) continue;
- if (walkNode(item, supportedAttributes, attributePatterns, twImportNames, t)) {
+ if (walkNode(item, supportedAttributes, attributePatterns, twImportNames, t, twColorImportNames)) {
return true;
}
}
@@ -88,7 +115,7 @@ function walkNode(
} else if (isASTNode(child)) {
// Skip nested functions
if (t.isFunction(child)) continue;
- if (walkNode(child, supportedAttributes, attributePatterns, twImportNames, t)) {
+ if (walkNode(child, supportedAttributes, attributePatterns, twImportNames, t, twColorImportNames)) {
return true;
}
}
diff --git a/src/babel/utils/twColorProcessing.test.ts b/src/babel/utils/twColorProcessing.test.ts
new file mode 100644
index 0000000..060c7f7
--- /dev/null
+++ b/src/babel/utils/twColorProcessing.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import type { PluginState } from "../plugin/state";
+import { resolveTwColorToken } from "./twColorProcessing";
+
+const state = {
+ customTheme: {
+ colors: {
+ card: "#ffffff",
+ "card-dark": "#222222",
+ "card-light": "#ffffff",
+ },
+ fontFamily: {},
+ fontSize: {},
+ spacing: {},
+ },
+ schemeModifierConfig: {
+ darkSuffix: "-dark",
+ lightSuffix: "-light",
+ },
+} as unknown as PluginState;
+
+describe("resolveTwColorToken", () => {
+ it("should resolve custom and preset raw tokens", () => {
+ expect(resolveTwColorToken("card", state)).toEqual({ kind: "static", color: "#ffffff" });
+ expect(resolveTwColorToken("black", state)).toEqual({ kind: "static", color: "#000000" });
+ });
+
+ it("should resolve scheme variants from flattened theme colors", () => {
+ expect(resolveTwColorToken("scheme:card", state)).toEqual({
+ kind: "scheme",
+ darkColor: "#222222",
+ lightColor: "#ffffff",
+ });
+ });
+
+ it("should reject unsupported modifiers, multiple tokens, and unknown colors", () => {
+ expect(resolveTwColorToken("dark:card", state)).toBeNull();
+ expect(resolveTwColorToken("card text", state)).toBeNull();
+ expect(resolveTwColorToken("missing", state)).toBeNull();
+ });
+});
diff --git a/src/babel/utils/twColorProcessing.ts b/src/babel/utils/twColorProcessing.ts
new file mode 100644
index 0000000..fa5d48d
--- /dev/null
+++ b/src/babel/utils/twColorProcessing.ts
@@ -0,0 +1,163 @@
+/** Compile-time processing for raw Tailwind color tokens. */
+
+import type { NodePath } from "@babel/core";
+import type * as BabelTypes from "@babel/types";
+
+import { COLORS, parseColor } from "../../parser/colors.js";
+import { expandSchemeModifier } from "../../parser/modifiers.js";
+import { findComponentScope } from "../plugin/componentScope.js";
+import type { PluginState } from "../plugin/state.js";
+import { injectColorSchemeHook } from "./styleInjection.js";
+
+export type ResolvedTwColorToken =
+ | { kind: "static"; color: string }
+ | { kind: "scheme"; darkColor: string; lightColor: string };
+
+const COLOR_UTILITY_PATTERN = /^(?:bg|text|border|outline)-/;
+
+function normalizeColorUtility(token: string): string {
+ return COLOR_UTILITY_PATTERN.test(token) ? token : `text-${token}`;
+}
+
+function extractNativeColor(style: ReturnType): string | null {
+ if (!style) {
+ return null;
+ }
+
+ const colors = Object.entries(style)
+ .filter(([key, value]) => key.toLowerCase().includes("color") && typeof value === "string")
+ .map(([, value]) => value as string);
+ const uniqueColors = [...new Set(colors)];
+ return uniqueColors.length === 1 ? uniqueColors[0] : null;
+}
+
+function resolveUtilityColor(utility: string, state: PluginState): string | null {
+ return extractNativeColor(parseColor(utility, state.customTheme.colors));
+}
+
+export function resolveTwColorToken(token: string, state: PluginState): ResolvedTwColorToken | null {
+ const normalized = token.trim();
+ if (!normalized || /\s/.test(normalized)) {
+ return null;
+ }
+
+ if (normalized.startsWith("scheme:")) {
+ const utility = normalizeColorUtility(normalized.slice(7));
+ const availableColors = { ...COLORS, ...state.customTheme.colors };
+ const variants = expandSchemeModifier(
+ { modifier: "scheme", baseClass: utility },
+ availableColors,
+ state.schemeModifierConfig.darkSuffix,
+ state.schemeModifierConfig.lightSuffix,
+ );
+
+ if (variants.length !== 2) {
+ return null;
+ }
+
+ const darkColor = resolveUtilityColor(variants[0].baseClass, state);
+ const lightColor = resolveUtilityColor(variants[1].baseClass, state);
+ return darkColor && lightColor ? { kind: "scheme", darkColor, lightColor } : null;
+ }
+
+ if (normalized.includes(":")) {
+ return null;
+ }
+
+ const color = resolveUtilityColor(normalizeColorUtility(normalized), state);
+ return color ? { kind: "static", color } : null;
+}
+
+export function twColorTokenToExpression(
+ token: ResolvedTwColorToken,
+ state: PluginState,
+ t: typeof BabelTypes,
+): BabelTypes.Expression {
+ if (token.kind === "static") {
+ return t.stringLiteral(token.color);
+ }
+
+ return t.conditionalExpression(
+ t.binaryExpression("===", t.identifier(state.colorSchemeVariableName), t.stringLiteral("dark")),
+ t.stringLiteral(token.darkColor),
+ t.stringLiteral(token.lightColor),
+ );
+}
+
+export function ensureTwColorSchemeHook(
+ path: NodePath,
+ state: PluginState,
+ t: typeof BabelTypes,
+): void {
+ const componentScope = findComponentScope(path, t);
+ if (!componentScope) {
+ throw path.buildCodeFrameError(
+ "[react-native-tailwind] useTwColor/useTwColors must be called inside a React function component.",
+ );
+ }
+
+ state.functionComponentsNeedingColorScheme.add(componentScope);
+ state.needsColorSchemeImport = true;
+ injectColorSchemeHook(
+ componentScope,
+ state.colorSchemeVariableName,
+ state.colorSchemeHookName,
+ state.colorSchemeLocalIdentifier,
+ t,
+ );
+}
+
+export function assertTwColorComponentScope(
+ path: NodePath,
+ t: typeof BabelTypes,
+): void {
+ if (!findComponentScope(path, t)) {
+ throw path.buildCodeFrameError(
+ "[react-native-tailwind] useTwColor/useTwColors must be called inside a React function component.",
+ );
+ }
+}
+
+export function removeTwColorImports(path: NodePath, t: typeof BabelTypes): void {
+ // Refresh bindings after call expressions have been replaced. If a helper is
+ // also referenced as a runtime value, removing its import would leave an
+ // unbound identifier in the generated module.
+ path.scope.crawl();
+
+ path.traverse({
+ ImportDeclaration(importPath) {
+ if (importPath.node.source.value !== "@mgcrea/react-native-tailwind") {
+ return;
+ }
+
+ const remainingSpecifiers = importPath.node.specifiers.filter((specifier) => {
+ if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported)) {
+ const isTwColorHelper =
+ specifier.imported.name === "twColor" ||
+ specifier.imported.name === "useTwColor" ||
+ specifier.imported.name === "useTwColors";
+ if (!isTwColorHelper) {
+ return true;
+ }
+
+ const binding = path.scope.getBinding(specifier.local.name);
+ const remainingReference = binding?.referencePaths.find((referencePath) => !referencePath.removed);
+ if (remainingReference) {
+ throw remainingReference.buildCodeFrameError(
+ `[react-native-tailwind] ${specifier.imported.name} must be called directly so it can be replaced at compile time.`,
+ );
+ }
+
+ return false;
+ }
+ return true;
+ });
+
+ if (remainingSpecifiers.length === 0) {
+ importPath.remove();
+ } else if (remainingSpecifiers.length < importPath.node.specifiers.length) {
+ importPath.node.specifiers = remainingSpecifiers;
+ }
+ },
+ });
+}
diff --git a/src/index.ts b/src/index.ts
index 0bd46f6..b26ee91 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,6 +5,7 @@
// Compile-time tw/twStyle functions (transformed by Babel plugin)
export { tw, twStyle } from "./stubs/tw";
+export { twColor, useTwColor, useTwColors } from "./stubs/twColor";
// Main parser functions
export { parseClass, parseClassName } from "./parser";
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..d9493e3 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,
@@ -418,6 +420,23 @@ describe("isColorClass", () => {
expect(isColorClass("border-black")).toBe(true);
});
+ it("should return true for outline color classes", () => {
+ expect(isColorClass("outline-red-500")).toBe(true);
+ expect(isColorClass("outline-systemGray")).toBe(true);
+ expect(isColorClass("outline-black")).toBe(true);
+ });
+
+ it("should return false for non-color outline classes", () => {
+ expect(isColorClass("outline-none")).toBe(false);
+ expect(isColorClass("outline-solid")).toBe(false);
+ expect(isColorClass("outline-dashed")).toBe(false);
+ expect(isColorClass("outline-dotted")).toBe(false);
+ expect(isColorClass("outline-hidden")).toBe(false);
+ expect(isColorClass("outline-2")).toBe(false);
+ expect(isColorClass("outline-offset-2")).toBe(false);
+ expect(isColorClass("outline-[3px]")).toBe(false);
+ });
+
it("should return false for non-color classes", () => {
expect(isColorClass("m-4")).toBe(false);
expect(isColorClass("p-2")).toBe(false);
@@ -482,6 +501,118 @@ 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 expand outline color scheme modifier", () => {
+ const modifier = { modifier: "scheme" as const, baseClass: "outline-primary" };
+ const result = expandSchemeModifier(modifier, customColors);
+
+ expect(result).toHaveLength(2);
+ expect((result as [ParsedModifier, ParsedModifier])[0]).toEqual({
+ modifier: "dark",
+ baseClass: "outline-primary-dark",
+ });
+ expect((result as [ParsedModifier, ParsedModifier])[1]).toEqual({
+ modifier: "light",
+ baseClass: "outline-primary-light",
+ });
+ });
+
+ it("should produce parsable outline color variants", () => {
+ const [dark, light] = expandSchemeModifier(
+ { modifier: "scheme", baseClass: "outline-primary" },
+ customColors,
+ );
+
+ expect(parseColor(dark.baseClass, customColors)).toEqual({ outlineColor: "#1E40AF" });
+ expect(parseColor(light.baseClass, customColors)).toEqual({ outlineColor: "#BFDBFE" });
+ });
+
+ it("should preserve integer and arbitrary opacity for scheme outline colors", () => {
+ expect(
+ expandSchemeModifier({ modifier: "scheme", baseClass: "outline-primary/25" }, customColors),
+ ).toEqual([
+ { modifier: "dark", baseClass: "outline-primary-dark/25" },
+ { modifier: "light", baseClass: "outline-primary-light/25" },
+ ]);
+
+ const [dark, light] = expandSchemeModifier(
+ { modifier: "scheme", baseClass: "outline-primary/[.37]" },
+ customColors,
+ );
+ expect(parseColor(dark.baseClass, customColors)).toEqual({
+ outlineColor: applyOpacity(customColors["primary-dark"], 37),
+ });
+ expect(parseColor(light.baseClass, customColors)).toEqual({
+ outlineColor: applyOpacity(customColors["primary-light"], 37),
+ });
+ });
+
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 +625,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..e36231b 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";
@@ -172,9 +174,18 @@ export function isDirectionalModifier(modifier: ModifierType): modifier is Direc
* Check if a class name is a color-based utility class
*
* @param className - Class name to check
- * @returns true if class is color-based (text-*, bg-*, border-*)
+ * @returns true if class is color-based (text-*, bg-*, border-*, outline-*)
*/
export function isColorClass(className: string): boolean {
+ if (className.startsWith("outline-")) {
+ const value = className.substring(8);
+ const isStyle = ["solid", "dashed", "dotted", "none", "hidden"].includes(value);
+ const isWidthOrOffset = /^\d/.test(value) || value.startsWith("offset-");
+ const isNonColorArbitrary = value.startsWith("[") && !value.startsWith("[#");
+
+ return !isStyle && !isWidthOrOffset && !isNonColorArbitrary;
+ }
+
return className.startsWith("text-") || className.startsWith("bg-") || className.startsWith("border-");
}
@@ -211,7 +222,7 @@ export function expandSchemeModifier(
if (!isColorClass(baseClass)) {
if (process.env.NODE_ENV !== "production") {
console.warn(
- `[react-native-tailwind] scheme: modifier only supports color classes (text-*, bg-*, border-*). ` +
+ `[react-native-tailwind] scheme: modifier only supports color classes (text-*, bg-*, border-*, outline-*). ` +
`Found: "${baseClass}". This modifier will be ignored.`,
);
}
@@ -220,12 +231,15 @@ export function expandSchemeModifier(
// Extract the color name from the class
// e.g., "text-systemGray" -> "systemGray"
- const match = baseClass.match(/^(text|bg|border)-(.+)$/);
+ const match = baseClass.match(/^(text|bg|border|outline)-(.+)$/);
if (!match) {
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 +266,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/stubs/twColor.test.ts b/src/stubs/twColor.test.ts
new file mode 100644
index 0000000..8d8376c
--- /dev/null
+++ b/src/stubs/twColor.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+
+import { twColor, useTwColor, useTwColors } from "./twColor";
+
+describe("raw color stubs", () => {
+ it("should throw when twColor is not transformed", () => {
+ expect(() => twColor`blue-500`).toThrow(
+ "twColor/useTwColor/useTwColors must be transformed by the Babel plugin",
+ );
+ });
+
+ it("should throw when useTwColor is not transformed", () => {
+ expect(() => useTwColor("blue-500")).toThrow(
+ "twColor/useTwColor/useTwColors must be transformed by the Babel plugin",
+ );
+ });
+
+ it("should throw when useTwColors is not transformed", () => {
+ expect(() => useTwColors({ accent: "blue-500" })).toThrow(
+ "twColor/useTwColor/useTwColors must be transformed by the Babel plugin",
+ );
+ });
+});
diff --git a/src/stubs/twColor.ts b/src/stubs/twColor.ts
new file mode 100644
index 0000000..734a631
--- /dev/null
+++ b/src/stubs/twColor.ts
@@ -0,0 +1,25 @@
+/**
+ * Compile-time raw color helpers.
+ *
+ * Calls are replaced by the Babel plugin with literal React Native color
+ * strings. These stubs throw when the plugin is not configured.
+ */
+
+const transformError =
+ "twColor/useTwColor/useTwColors must be transformed by the Babel plugin. " +
+ "Ensure @mgcrea/react-native-tailwind/babel is configured in your babel.config.js.";
+
+/** Resolve one static theme token to a native color string at compile time. */
+export function twColor(_strings: TemplateStringsArray, ..._values: unknown[]): string {
+ throw new Error(transformError);
+}
+
+/** Resolve one reactive theme token to a native color string at compile time. */
+export function useTwColor(_token: string): string {
+ throw new Error(transformError);
+}
+
+/** Resolve a named object of static theme tokens at compile time. */
+export function useTwColors>(_tokens: T): { [K in keyof T]: string } {
+ throw new Error(transformError);
+}
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;
}