diff --git a/docs/src/content/docs/guides/compile-time-tw.md b/docs/src/content/docs/guides/compile-time-tw.md
index 07c95da..f0a5dbd 100644
--- a/docs/src/content/docs/guides/compile-time-tw.md
+++ b/docs/src/content/docs/guides/compile-time-tw.md
@@ -121,6 +121,52 @@ Both `style` (with runtime conditionals) and `darkStyle`/`lightStyle` properties
- **Manual control**: Access `textStyles.darkStyle` or `textStyles.lightStyle` for custom logic
:::
+### Shared Module-Level Theme Styles
+
+Hooks cannot run at module scope, but theme-aware styles can still be declared there and resolved reactively where they are consumed:
+
+```tsx
+import { View } from "react-native";
+import { tw, useTwStyle } from "@mgcrea/react-native-tailwind";
+
+const cardStyles = tw`bg-white dark:bg-gray-900 light:bg-gray-50`;
+
+export function Card({ selected }: { selected: boolean }) {
+ // Call the hook unconditionally inside the component.
+ const themedCardStyle = useTwStyle(cardStyles);
+
+ return ;
+}
+```
+
+At compile time, the module-level declaration retains every variant without attempting an invalid module-level hook call:
+
+```tsx
+const cardStyles = {
+ style: styles._bg_white,
+ darkStyle: styles._dark_bg_gray_900,
+ lightStyle: styles._light_bg_gray_50,
+};
+```
+
+`useTwStyle` uses the color-scheme hook configured in the Babel plugin, including custom theme-provider hooks. The consuming component re-renders and selects the matching variant when that hook changes. The module-level `style` property remains the base style; it is intentionally not a mutable global value.
+
+For an explicit or controlled scheme value, use the pure resolver:
+
+```tsx
+import { resolveTwStyle } from "@mgcrea/react-native-tailwind";
+import { useAppColorScheme } from "./theme";
+
+export function Card() {
+ const colorScheme = useAppColorScheme();
+ const themedCardStyle = resolveTwStyle(cardStyles, colorScheme);
+
+ return ;
+}
+```
+
+The same API works with `twStyle("...")` declarations.
+
## With Platform Modifiers
Platform modifiers (`ios:`, `android:`, `web:`) work in `tw` calls anywhere:
diff --git a/docs/src/content/docs/reference/api.md b/docs/src/content/docs/reference/api.md
index fe5d564..ebece59 100644
--- a/docs/src/content/docs/reference/api.md
+++ b/docs/src/content/docs/reference/api.md
@@ -39,6 +39,36 @@ function parseClassName(
): ViewStyle | TextStyle | ImageStyle;
```
+## useTwStyle
+
+Reactively resolve a module-level compiled `tw` or `twStyle` object with the color-scheme hook configured in the Babel plugin:
+
+```tsx
+import { tw, useTwStyle } from "@mgcrea/react-native-tailwind";
+
+const sharedStyles = tw`bg-white dark:bg-gray-900`;
+
+function Component() {
+ const style = useTwStyle(sharedStyles);
+ return ;
+}
+```
+
+Call `useTwStyle` unconditionally inside a function component, like any React hook. The Babel plugin injects the configured hook, including custom `colorScheme.importFrom` and `colorScheme.importName` providers.
+
+## resolveTwStyle
+
+Resolve the same compiled object with an explicit scheme. Use this for controlled or imperative theme state when you already have a scheme value:
+
+```tsx
+import { resolveTwStyle } from "@mgcrea/react-native-tailwind";
+
+const style = resolveTwStyle(sharedStyles, "dark");
+// [sharedStyles.style, sharedStyles.darkStyle]
+```
+
+The resolver accepts `"light"`, `"dark"`, React Native's `"unspecified"`, `null`, or `undefined`. It does not mutate the compiled style object or an existing style array.
+
## Constants
Access default color and spacing scales:
@@ -166,7 +196,7 @@ function ThemedComponent() {
## Important Notes
-- The programmatic API parses styles at **runtime**, not compile-time
+- `parseClassName` parses styles at **runtime**; `useTwStyle` and `resolveTwStyle` only select already compiled variants
- For production apps, prefer using `className` prop for compile-time optimization
- Use the programmatic API for:
- Testing
diff --git a/src/babel/plugin.ts b/src/babel/plugin.ts
index 078f8c6..b245a94 100644
--- a/src/babel/plugin.ts
+++ b/src/babel/plugin.ts
@@ -80,6 +80,8 @@ export default function reactNativeTailwindBabelPlugin(
const importedName = spec.imported.name;
if (importedName === "tw" || importedName === "twStyle") {
state.twImportNames.add(spec.local.name);
+ } else if (importedName === "useTwStyle") {
+ state.useTwStyleImportNames.add(spec.local.name);
}
}
}
@@ -99,6 +101,7 @@ export default function reactNativeTailwindBabelPlugin(
state.supportedAttributes,
state.attributePatterns,
state.twImportNames,
+ state.useTwStyleImportNames,
t,
)
) {
diff --git a/src/babel/plugin/state.ts b/src/babel/plugin/state.ts
index 91d8971..8d97fbe 100644
--- a/src/babel/plugin/state.ts
+++ b/src/babel/plugin/state.ts
@@ -115,6 +115,7 @@ export type PluginState = PluginPass & {
stylesIdentifier: string;
// Track tw/twStyle imports from main package
twImportNames: Set; // e.g., ['tw', 'twStyle'] or ['tw as customTw']
+ useTwStyleImportNames: Set;
hasTwImport: boolean;
// Track react-native import path for conditional StyleSheet/Platform injection
reactNativeImportPath?: NodePath;
@@ -178,6 +179,7 @@ export function createInitialState(
attributePatterns: patterns,
stylesIdentifier,
twImportNames: new Set(),
+ useTwStyleImportNames: new Set(),
hasTwImport: false,
reactNativeImportPath: undefined,
functionComponentsNeedingColorScheme: new Set(),
diff --git a/src/babel/plugin/visitors/imports.ts b/src/babel/plugin/visitors/imports.ts
index 3dad7f2..2693405 100644
--- a/src/babel/plugin/visitors/imports.ts
+++ b/src/babel/plugin/visitors/imports.ts
@@ -110,6 +110,8 @@ 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 === "useTwStyle") {
+ state.useTwStyleImportNames.add(spec.local.name);
}
}
});
diff --git a/src/babel/plugin/visitors/tw.test.ts b/src/babel/plugin/visitors/tw.test.ts
index 1ac4a2e..d222158 100644
--- a/src/babel/plugin/visitors/tw.test.ts
+++ b/src/babel/plugin/visitors/tw.test.ts
@@ -212,6 +212,62 @@ describe("twStyle visitor - function transformation", () => {
});
describe("tw/twStyle - color scheme modifiers", () => {
+ it("should make useTwStyle use the configured color scheme hook", () => {
+ const output = transform(
+ `
+ import { tw, useTwStyle } from '@mgcrea/react-native-tailwind';
+ const sharedStyles = tw\`bg-white dark:bg-gray-900\`;
+
+ export function Component() {
+ return useTwStyle(sharedStyles);
+ }
+ `,
+ {
+ colorScheme: {
+ importFrom: "@/theme/useColorScheme",
+ importName: "useAppColorScheme",
+ },
+ },
+ );
+
+ expect(output).toContain('from "@/theme/useColorScheme"');
+ expect(output).toContain("_twColorScheme = useAppColorScheme()");
+ expect(output).toContain("useTwStyle(sharedStyles, _twColorScheme)");
+ expect(output).not.toContain('useColorScheme } from "react-native"');
+ });
+
+ it("should support aliased useTwStyle imports and reuse an existing hook alias", () => {
+ const output = transform(
+ `
+ import { useAppColorScheme as useTheme } from '@/theme/useColorScheme';
+ import { tw, useTwStyle as useSharedStyle } from '@mgcrea/react-native-tailwind';
+ const sharedStyles = tw\`bg-white dark:bg-gray-900\`;
+
+ export const Component = () => useSharedStyle(sharedStyles);
+ `,
+ {
+ colorScheme: {
+ importFrom: "@/theme/useColorScheme",
+ importName: "useAppColorScheme",
+ },
+ },
+ );
+
+ expect(output.match(/useAppColorScheme\s+as\s+useTheme/g)).toHaveLength(1);
+ expect(output).toContain("_twColorScheme = useTheme()");
+ expect(output).toContain("useSharedStyle(sharedStyles, _twColorScheme)");
+ });
+
+ it("should reject useTwStyle outside a function component", () => {
+ expect(() =>
+ transform(`
+ import { tw, useTwStyle } from '@mgcrea/react-native-tailwind';
+ const sharedStyles = tw\`bg-white dark:bg-gray-900\`;
+ export const resolved = useTwStyle(sharedStyles);
+ `),
+ ).toThrow(/useTwStyle\(\) must be called unconditionally inside a React function component/);
+ });
+
it("should transform tw with dark: modifier inside component", () => {
const input = `
import { tw } from '@mgcrea/react-native-tailwind';
@@ -302,7 +358,7 @@ describe("tw/twStyle - color scheme modifiers", () => {
expect(output).toContain("_twStyles._active_bg_blue_500");
});
- it("should warn if tw with color scheme modifiers used outside component", () => {
+ it("should preserve color scheme variants when tw is used outside a component", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const input = `
@@ -313,16 +369,60 @@ describe("tw/twStyle - color scheme modifiers", () => {
const output = transform(input);
- // Should warn about usage outside component
+ // Should explain how to resolve the generated variants at the consumption site
expect(consoleWarnSpy).toHaveBeenCalledWith(
- expect.stringContaining("Color scheme modifiers (dark:, light:) in tw/twStyle calls"),
+ expect.stringContaining("use useTwStyle() or resolveTwStyle() inside the consumer"),
);
// Should not inject hook (no component scope)
expect(output).not.toContain("useColorScheme");
- // Should still generate styles but without runtime conditionals
+ // Should generate the base style and raw dark variant without module-level hook conditionals
expect(output).toContain("_twStyles");
+ expect(output).toContain("style: _twStyles._bg_white");
+ expect(output).toContain("darkStyle: _twStyles._dark_bg_gray_900");
+ expect(output).not.toContain('_twColorScheme === "dark"');
+
+ consoleWarnSpy.mockRestore();
+ });
+
+ it("should preserve both variants for module-level twStyle calls", () => {
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const input = `
+ import { twStyle } from '@mgcrea/react-native-tailwind';
+
+ export const sharedStyles = twStyle('bg-gray-500 dark:bg-gray-900 light:bg-gray-100');
+ `;
+
+ const output = transform(input);
+
+ expect(output).not.toContain("useColorScheme");
+ expect(output).toContain("style: _twStyles._bg_gray_500");
+ expect(output).toContain("darkStyle: _twStyles._dark_bg_gray_900");
+ expect(output).toContain("lightStyle: _twStyles._light_bg_gray_100");
+
+ consoleWarnSpy.mockRestore();
+ });
+
+ it("should preserve the runtime useTwStyle import and inject its scheme argument", () => {
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const input = `
+ import { tw, useTwStyle } from '@mgcrea/react-native-tailwind';
+
+ const sharedStyles = tw\`bg-white dark:bg-gray-900\`;
+
+ export function Component() {
+ const style = useTwStyle(sharedStyles);
+ return style;
+ }
+ `;
+
+ const output = transform(input);
+
+ expect(output).not.toMatch(/import\s*{[^}]*\btw\b/);
+ expect(output).toMatch(/import\s*{\s*useTwStyle\s*}\s*from/);
+ expect(output).toContain("darkStyle: _twStyles._dark_bg_gray_900");
+ expect(output).toContain("useTwStyle(sharedStyles, _twColorScheme)");
consoleWarnSpy.mockRestore();
});
diff --git a/src/babel/plugin/visitors/tw.ts b/src/babel/plugin/visitors/tw.ts
index bf501d0..f3a5404 100644
--- a/src/babel/plugin/visitors/tw.ts
+++ b/src/babel/plugin/visitors/tw.ts
@@ -94,6 +94,26 @@ export function callExpressionVisitor(
}
const calleeName = node.callee.name;
+ if (state.useTwStyleImportNames.has(calleeName)) {
+ if (node.arguments.length !== 1) {
+ throw path.buildCodeFrameError(
+ "[react-native-tailwind] useTwStyle() expects exactly one compiled tw/twStyle object.",
+ );
+ }
+
+ const componentScope = findComponentScope(path, t);
+ if (!componentScope) {
+ throw path.buildCodeFrameError(
+ "[react-native-tailwind] useTwStyle() must be called unconditionally inside a React function component.",
+ );
+ }
+
+ state.functionComponentsNeedingColorScheme.add(componentScope);
+ state.needsColorSchemeImport = true;
+ node.arguments.push(t.identifier(state.colorSchemeVariableName));
+ return;
+ }
+
if (!state.twImportNames.has(calleeName)) {
return;
}
diff --git a/src/babel/utils/preInjection.ts b/src/babel/utils/preInjection.ts
index 99461ba..c7bb530 100644
--- a/src/babel/utils/preInjection.ts
+++ b/src/babel/utils/preInjection.ts
@@ -25,9 +25,10 @@ export function scanForColorSchemeModifiers(
supportedAttributes: Set,
attributePatterns: RegExp[],
twImportNames: Set,
+ useTwStyleImportNames: Set,
t: typeof BabelTypes,
): boolean {
- return walkNode(node, supportedAttributes, attributePatterns, twImportNames, t);
+ return walkNode(node, supportedAttributes, attributePatterns, twImportNames, useTwStyleImportNames, t);
}
function walkNode(
@@ -35,6 +36,7 @@ function walkNode(
supportedAttributes: Set,
attributePatterns: RegExp[],
twImportNames: Set,
+ useTwStyleImportNames: Set,
t: typeof BabelTypes,
): boolean {
// Check JSXAttribute with color scheme class names
@@ -59,6 +61,10 @@ function walkNode(
// Check CallExpression (twStyle("dark:..."))
if (t.isCallExpression(node) && t.isIdentifier(node.callee)) {
+ if (useTwStyleImportNames.has(node.callee.name)) {
+ return true;
+ }
+
if (twImportNames.has(node.callee.name)) {
const arg = node.arguments[0];
if (t.isStringLiteral(arg) && COLOR_SCHEME_PATTERN.test(arg.value)) {
@@ -80,7 +86,9 @@ 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, useTwStyleImportNames, t)
+ ) {
return true;
}
}
@@ -88,7 +96,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, useTwStyleImportNames, t)) {
return true;
}
}
diff --git a/src/babel/utils/twProcessing.ts b/src/babel/utils/twProcessing.ts
index 6647df0..18de68b 100644
--- a/src/babel/utils/twProcessing.ts
+++ b/src/babel/utils/twProcessing.ts
@@ -134,7 +134,8 @@ export function processTwCall(
if (process.env.NODE_ENV !== "production") {
console.warn(
`[react-native-tailwind] Color scheme modifiers (dark:, light:) in tw/twStyle calls ` +
- `must be used inside a React component. Modifiers will be ignored.`,
+ `cannot resolve automatically outside a React component. ` +
+ `darkStyle/lightStyle variants were generated; use useTwStyle() or resolveTwStyle() inside the consumer.`,
);
}
} else {
@@ -182,9 +183,12 @@ export function processTwCall(
// Replace style property with array
objectProperties[0] = t.objectProperty(t.identifier("style"), t.arrayExpression(styleArrayElements));
+ }
- // Also add darkStyle/lightStyle properties for manual processing
- // (e.g., extracting raw hex values for Reanimated animations)
+ // Always expose darkStyle/lightStyle variants. Inside components the style
+ // property is also reactive; outside components these variants can be
+ // resolved by useTwStyle() or resolveTwStyle() at the consumption site.
+ if (hasColorSchemeModifiers) {
const darkModifiers = colorSchemeModifiers.filter((m) => m.modifier === "dark");
const lightModifiers = colorSchemeModifiers.filter((m) => m.modifier === "light");
diff --git a/src/index.ts b/src/index.ts
index 0bd46f6..6eaa0e8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,10 +11,12 @@ export { parseClass, parseClassName } from "./parser";
export { flattenColors } from "./utils/flattenColors";
export { mergeStyles } from "./utils/mergeStyles";
export { generateStyleKey } from "./utils/styleKey";
+export { resolveTwStyle, useTwStyle } from "./useTwStyle";
// Re-export types
export type { StyleObject } from "./types/core";
export type { NativeStyle, TwStyle } from "./types/runtime";
+export type { ResolvedTwStyle, TwColorScheme } from "./useTwStyle";
// Re-export colors
export { TAILWIND_COLORS } from "./config/tailwind";
diff --git a/src/types/runtime.ts b/src/types/runtime.ts
index 3fb2865..663cba5 100644
--- a/src/types/runtime.ts
+++ b/src/types/runtime.ts
@@ -7,7 +7,8 @@ export type NativeStyle = ViewStyle | TextStyle | ImageStyle;
/**
* Return type for tw/twStyle functions with separate style properties for modifiers
- * When color-scheme modifiers (dark:, light:) are present, style becomes an array with runtime conditionals
+ * When color-scheme modifiers (dark:, light:) are used inside a component, style becomes an array with runtime conditionals.
+ * Module-level calls retain a base style and expose darkStyle/lightStyle for useTwStyle or resolveTwStyle.
* When platform modifiers (ios:, android:, web:) are present, style becomes an array with Platform.select()
*/
export type TwStyle = {
diff --git a/src/useTwStyle.test.ts b/src/useTwStyle.test.ts
new file mode 100644
index 0000000..c928e20
--- /dev/null
+++ b/src/useTwStyle.test.ts
@@ -0,0 +1,49 @@
+import type { ViewStyle } from "react-native";
+import { describe, expect, it } from "vitest";
+
+import type { TwStyle } from "./types/runtime";
+import { resolveTwStyle, useTwStyle } from "./useTwStyle";
+
+const baseStyle: ViewStyle = { backgroundColor: "white" };
+const darkStyle: ViewStyle = { backgroundColor: "black" };
+const lightStyle: ViewStyle = { backgroundColor: "ivory" };
+
+const sharedStyles: TwStyle = {
+ style: baseStyle,
+ darkStyle,
+ lightStyle,
+};
+
+describe("resolveTwStyle", () => {
+ it("should append the matching dark or light variant", () => {
+ expect(resolveTwStyle(sharedStyles, "dark")).toEqual([baseStyle, darkStyle]);
+ expect(resolveTwStyle(sharedStyles, "light")).toEqual([baseStyle, lightStyle]);
+ });
+
+ it("should leave the base style unchanged for a null scheme or missing variant", () => {
+ expect(resolveTwStyle(sharedStyles, "unspecified")).toBe(baseStyle);
+ expect(resolveTwStyle(sharedStyles, null)).toBe(baseStyle);
+ expect(resolveTwStyle({ style: baseStyle }, "dark")).toBe(baseStyle);
+ });
+
+ it("should append to an existing style array without mutating it", () => {
+ const baseArray = [baseStyle, false] as Array;
+ const styles: TwStyle = { style: baseArray, darkStyle };
+
+ expect(resolveTwStyle(styles, "dark")).toEqual([baseStyle, false, darkStyle]);
+ expect(baseArray).toEqual([baseStyle, false]);
+ });
+
+ it("should not duplicate a scheme style already resolved by the compiler", () => {
+ const resolvedArray = [baseStyle, darkStyle] as Array;
+ const styles: TwStyle = { style: resolvedArray, darkStyle };
+
+ expect(resolveTwStyle(styles, "dark")).toBe(resolvedArray);
+ });
+});
+
+describe("useTwStyle", () => {
+ it("should fail clearly when the Babel plugin did not inject the configured scheme", () => {
+ expect(() => useTwStyle(sharedStyles)).toThrow(/must be transformed.*configured color-scheme hook/);
+ });
+});
diff --git a/src/useTwStyle.ts b/src/useTwStyle.ts
new file mode 100644
index 0000000..25b7e76
--- /dev/null
+++ b/src/useTwStyle.ts
@@ -0,0 +1,48 @@
+import type { ColorSchemeName } from "react-native";
+
+import type { NativeStyle, TwStyle } from "./types/runtime";
+
+export type ResolvedTwStyle = T | Array;
+export type TwColorScheme = ColorSchemeName | null | undefined;
+
+/**
+ * Resolve a compiled TwStyle object for an explicit color scheme.
+ * Useful with an application's custom theme hook.
+ */
+export function resolveTwStyle(
+ styles: TwStyle,
+ colorScheme: TwColorScheme,
+): ResolvedTwStyle {
+ const schemeStyle =
+ colorScheme === "dark" ? styles.darkStyle : colorScheme === "light" ? styles.lightStyle : undefined;
+
+ if (!schemeStyle) {
+ return styles.style;
+ }
+
+ if (Array.isArray(styles.style)) {
+ // Calls compiled inside a component may already contain the active scheme
+ // style. Preserve that array instead of applying the same variant twice.
+ return styles.style.includes(schemeStyle) ? styles.style : [...styles.style, schemeStyle];
+ }
+
+ return [styles.style, schemeStyle];
+}
+
+/**
+ * Reactively resolve a module-level compiled TwStyle object with the color
+ * scheme hook configured in the Babel plugin. The plugin injects the second
+ * argument at compile time.
+ */
+export function useTwStyle(styles: TwStyle): ResolvedTwStyle;
+export function useTwStyle(
+ styles: TwStyle,
+ colorScheme?: TwColorScheme,
+): ResolvedTwStyle {
+ if (arguments.length < 2) {
+ throw new Error(
+ "useTwStyle() must be transformed by @mgcrea/react-native-tailwind/babel so it can use the configured color-scheme hook.",
+ );
+ }
+ return resolveTwStyle(styles, colorScheme);
+}