diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx
index b7a08fc..34af052 100644
--- a/src/__tests__/native/transform.test.tsx
+++ b/src/__tests__/native/transform.test.tsx
@@ -55,8 +55,10 @@ describe("scale", () => {
,
).getByTestId(testID);
+ // Scale is unitless in RN — a percentage var resolves to the fraction
+ // (2% → 0.02), never the string "2%" (which crashes the transform validator).
expect(component.props.style).toStrictEqual({
- transform: [{ scaleX: "2%" }, { scaleY: "2%" }],
+ transform: [{ scaleX: 0.02 }, { scaleY: 0.02 }],
});
});
@@ -75,6 +77,90 @@ describe("scale", () => {
transform: [{ scaleX: 2 }, { scaleY: 3 }],
});
});
+
+ // nativewind/react-native-css#216 — CSS `scale` is unitless in React Native
+ // (75% → 0.75), never the "75%" string that crashes the transform validator.
+ // Both code paths are covered: direct percentages (compile-time
+ // parseScaleValue) and var()-resolved percentages (runtime scale() resolver,
+ // the shape Tailwind v4's `scale-*` utilities actually emit).
+ const scaleStyle = (css: string): unknown => {
+ registerCSS(`.my-class { ${css} }`);
+ return render().getByTestId(
+ testID,
+ ).props.style;
+ };
+
+ test("percentage — single value applies to both axes", () => {
+ expect(scaleStyle("scale: 75%;")).toStrictEqual({
+ transform: [{ scaleX: 0.75 }, { scaleY: 0.75 }],
+ });
+ });
+
+ test("percentage — identity (100% → 1)", () => {
+ expect(scaleStyle("scale: 100%;")).toStrictEqual({
+ transform: [{ scaleX: 1 }, { scaleY: 1 }],
+ });
+ });
+
+ test("percentage — zero (0% → 0)", () => {
+ expect(scaleStyle("scale: 0%;")).toStrictEqual({
+ transform: [{ scaleX: 0 }, { scaleY: 0 }],
+ });
+ });
+
+ test("percentage — negative flips (-50% → -0.5)", () => {
+ expect(scaleStyle("scale: -50%;")).toStrictEqual({
+ transform: [{ scaleX: -0.5 }, { scaleY: -0.5 }],
+ });
+ });
+
+ test("percentage — greater than 100% (150% → 1.5)", () => {
+ expect(scaleStyle("scale: 150%;")).toStrictEqual({
+ transform: [{ scaleX: 1.5 }, { scaleY: 1.5 }],
+ });
+ });
+
+ test("percentage — fractional (12.5% → 0.125)", () => {
+ expect(scaleStyle("scale: 12.5%;")).toStrictEqual({
+ transform: [{ scaleX: 0.125 }, { scaleY: 0.125 }],
+ });
+ });
+
+ test("percentage — different per-axis values (75% 50%)", () => {
+ expect(scaleStyle("scale: 75% 50%;")).toStrictEqual({
+ transform: [{ scaleX: 0.75 }, { scaleY: 0.5 }],
+ });
+ });
+
+ test("mixed number and percentage per-axis (2 50% → 2, 0.5)", () => {
+ // Both types coexist: the number stays a number, the percentage becomes
+ // its unitless fraction — nothing about number handling changes.
+ expect(scaleStyle("scale: 2 50%;")).toStrictEqual({
+ transform: [{ scaleX: 2 }, { scaleY: 0.5 }],
+ });
+ });
+
+ test("percentage via var() — the Tailwind v4 scale-* shape", () => {
+ expect(
+ scaleStyle(
+ "--tw-scale-x: 75%; --tw-scale-y: 75%; scale: var(--tw-scale-x) var(--tw-scale-y);",
+ ),
+ ).toStrictEqual({ transform: [{ scaleX: 0.75 }, { scaleY: 0.75 }] });
+ });
+
+ test("percentage via var() — different per-axis values", () => {
+ expect(
+ scaleStyle(
+ "--tw-scale-x: 50%; --tw-scale-y: 75%; scale: var(--tw-scale-x) var(--tw-scale-y);",
+ ),
+ ).toStrictEqual({ transform: [{ scaleX: 0.5 }, { scaleY: 0.75 }] });
+ });
+
+ test("unitless number is unchanged — no regression (2 → 2)", () => {
+ expect(scaleStyle("scale: 2;")).toStrictEqual({
+ transform: [{ scaleX: 2 }, { scaleY: 2 }],
+ });
+ });
});
describe("transform", () => {
diff --git a/src/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts
index a056663..2a7cf23 100644
--- a/src/__tests__/vendor/tailwind/transform.test.ts
+++ b/src/__tests__/vendor/tailwind/transform.test.ts
@@ -26,7 +26,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
- transform: [{ scale: "0%" }],
+ transform: [{ scale: 0 }],
},
},
});
@@ -35,7 +35,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
- transform: [{ scaleX: "50%" }, { scaleY: 1 }],
+ transform: [{ scaleX: 0.5 }, { scaleY: 1 }],
},
},
});
@@ -44,7 +44,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
- transform: [{ scaleX: 1 }, { scaleY: "50%" }],
+ transform: [{ scaleX: 1 }, { scaleY: 0.5 }],
},
},
});
@@ -53,7 +53,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
- transform: [{ scale: "50%" }],
+ transform: [{ scale: 0.5 }],
},
},
});
diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts
index 1301364..6281dda 100644
--- a/src/compiler/declarations.ts
+++ b/src/compiler/declarations.ts
@@ -842,7 +842,17 @@ export function parseScaleValue(
return 0;
}
- return parseLength(translate[prop], builder);
+ const value = translate[prop];
+ // Scale is unitless in React Native's transform API — CSS `scale: 75%` must
+ // become `{ scale: 0.75 }`, not `{ scale: "75%" }`. lightningcss parses "75%"
+ // as { type: "percentage", value: 0.75 }, so the decimal is already in
+ // `value`; parseLength would format it back to the string "75%" (correct for
+ // layout props, wrong for transforms). Short-circuit percentages here.
+ if (typeof value === "object" && value.type === "percentage") {
+ return round(value.value);
+ }
+
+ return parseLength(value, builder);
}
function parseLetterSpacing(
diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts
index c9826db..8d3ad79 100644
--- a/src/native/styles/functions/transform-functions.ts
+++ b/src/native/styles/functions/transform-functions.ts
@@ -2,15 +2,30 @@ import { isStyleDescriptorArray } from "react-native-css/utilities";
import type { StyleFunctionResolver } from "../resolve";
+// CSS `scale` accepts unitless numbers AND percentage strings per CSSWG, but
+// React Native's transform validator only accepts unitless numbers. Tailwind v4
+// emits `scale: var(--tw-scale-x) var(--tw-scale-y)`, whose vars resolve to
+// strings like "100%" / "75%" at runtime — normalize "N%" → N/100 before the
+// type guards. (rotate keeps "Ndeg", translate keeps "N%"; only scale is unitless.)
+const normalizeScaleArg = (value: unknown): unknown => {
+ if (typeof value === "string" && value.endsWith("%")) {
+ const fraction = parseFloat(value);
+ if (!Number.isNaN(fraction)) {
+ return fraction / 100;
+ }
+ }
+ return value;
+};
+
export const scale: StyleFunctionResolver = (resolveValue, descriptor) => {
const args = descriptor[2];
if (!isStyleDescriptorArray(args)) {
- return { scale: resolveValue(args) };
+ return { scale: normalizeScaleArg(resolveValue(args)) };
}
- const x = resolveValue(args[0]);
- const y = resolveValue(args[1]);
+ const x = normalizeScaleArg(resolveValue(args[0]));
+ const y = normalizeScaleArg(resolveValue(args[1]));
const isXValid = typeof x === "string" || typeof x === "number";
const isYValid = typeof y === "string" || typeof y === "number";