diff --git a/package.json b/package.json
index 7dd3962d..be5d51ef 100644
--- a/package.json
+++ b/package.json
@@ -203,6 +203,7 @@
"react-test-renderer": "^19.1.0",
"release-it": "^19.0.4",
"tailwindcss": "^4.1.12",
+ "tailwindcss-safe-area": "^1.1.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0"
},
diff --git a/src/__tests__/vendor/tailwind/_tailwind.tsx b/src/__tests__/vendor/tailwind/_tailwind.tsx
index 098b6aab..d7a1395f 100644
--- a/src/__tests__/vendor/tailwind/_tailwind.tsx
+++ b/src/__tests__/vendor/tailwind/_tailwind.tsx
@@ -60,7 +60,7 @@ export async function render(
css += `@import "tailwindcss/preflight.css" layer(base);\n`;
}
- css += `@import "tailwindcss/utilities.css" layer(utilities) source(none);\n`;
+ css += `@import "tailwindcss/utilities.css" layer(utilities) source(none);\n@import "tailwindcss-safe-area";`;
}
css += sourceInline
diff --git a/src/__tests__/vendor/tailwind/safe-area.test.tsx b/src/__tests__/vendor/tailwind/safe-area.test.tsx
new file mode 100644
index 00000000..4ef24b01
--- /dev/null
+++ b/src/__tests__/vendor/tailwind/safe-area.test.tsx
@@ -0,0 +1,357 @@
+import type { ViewProps } from "react-native";
+
+import { SafeAreaProvider } from "react-native-css/components/react-native-safe-area-context";
+import { type Metrics } from "react-native-safe-area-context";
+
+import { renderCurrentTest } from "./_tailwind";
+
+const initialMetrics: Metrics = {
+ insets: {
+ top: 10,
+ bottom: 10,
+ left: 10,
+ right: 10,
+ },
+ frame: {
+ x: 0,
+ y: 0,
+ height: 100,
+ width: 100,
+ },
+};
+
+function Wrapper({ children }: ViewProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+test("m-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ marginBottom: 10,
+ marginLeft: 10,
+ marginRight: 10,
+ marginTop: 10,
+ },
+ },
+ });
+});
+
+test("mx-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ marginLeft: 10,
+ marginRight: 10,
+ },
+ },
+ });
+});
+test("my-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ marginBottom: 10,
+ marginTop: 10,
+ },
+ },
+ });
+});
+test("ms-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ marginInlineStart: 10,
+ },
+ },
+ });
+});
+test("me-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ marginInlineEnd: 10,
+ },
+ },
+ });
+});
+test("mt-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ marginTop: 10,
+ },
+ },
+ });
+});
+test("mr-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ marginRight: 10,
+ },
+ },
+ });
+});
+test("mb-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ marginBottom: 10,
+ },
+ },
+ });
+});
+
+test("ml-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ marginLeft: 10,
+ },
+ },
+ });
+});
+
+test("p-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ paddingBottom: 10,
+ paddingLeft: 10,
+ paddingRight: 10,
+ paddingTop: 10,
+ },
+ },
+ });
+});
+
+test("px-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ paddingLeft: 10,
+ paddingRight: 10,
+ },
+ },
+ });
+});
+test("py-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ paddingBottom: 10,
+ paddingTop: 10,
+ },
+ },
+ });
+});
+test("ps-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ paddingInlineStart: 10,
+ },
+ },
+ });
+});
+test("pe-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ paddingInlineEnd: 10,
+ },
+ },
+ });
+});
+test("pt-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ paddingTop: 10,
+ },
+ },
+ });
+});
+test("pr-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ paddingRight: 10,
+ },
+ },
+ });
+});
+test("pb-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ paddingBottom: 10,
+ },
+ },
+ });
+});
+test("pl-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ paddingLeft: 10,
+ },
+ },
+ });
+});
+
+test("min-h-screen-safe", async () => {
+ // calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)));
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ minHeight: 1314,
+ },
+ },
+ });
+});
+
+test("max-h-screen-safe", async () => {
+ // calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)));
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ maxHeight: 1314,
+ },
+ },
+ });
+});
+
+test("h-screen-safe", async () => {
+ // calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)));
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ height: 1314,
+ },
+ },
+ });
+});
+
+test("inset-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ bottom: 10,
+ left: 10,
+ right: 10,
+ top: 10,
+ },
+ },
+ });
+});
+test("inset-x-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ left: 10,
+ right: 10,
+ },
+ },
+ });
+});
+test("inset-y-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ bottom: 10,
+ top: 10,
+ },
+ },
+ });
+});
+test("start-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ insetInlineStart: 10,
+ },
+ },
+ });
+});
+test("end-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ insetInlineEnd: 10,
+ },
+ },
+ });
+});
+test("top-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ top: 10,
+ },
+ },
+ });
+});
+test("right-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ right: 10,
+ },
+ },
+ });
+});
+test("bottom-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ bottom: 10,
+ },
+ },
+ });
+});
+test("left-safe", async () => {
+ expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({
+ props: {
+ style: {
+ left: 10,
+ },
+ },
+ });
+});
+
+test("pb-safe-offset-4", async () => {
+ // calc(env(safe-area-inset-right) + 2rem);
+ expect(
+ await renderCurrentTest({
+ wrapper: Wrapper,
+ }),
+ ).toStrictEqual({
+ props: {
+ style: {
+ paddingBottom: 24, // 10 + 14
+ },
+ },
+ });
+});
+
+test("pb-safe-or-20", async () => {
+ // --spacing: 3.5
+ // max(env(safe-area-inset-bottom), calc(var(--spacing) * 20));
+ expect(
+ await renderCurrentTest({
+ wrapper: Wrapper,
+ }),
+ ).toStrictEqual({
+ props: {
+ style: {
+ paddingBottom: 70,
+ },
+ },
+ });
+});
diff --git a/src/components/react-native-safe-area-context.native.tsx b/src/components/react-native-safe-area-context.native.tsx
new file mode 100644
index 00000000..14d6e003
--- /dev/null
+++ b/src/components/react-native-safe-area-context.native.tsx
@@ -0,0 +1,40 @@
+import { useContext, useMemo, type PropsWithChildren } from "react";
+
+import {
+ SafeAreaProvider as OriginalSafeAreaProvider,
+ useSafeAreaInsets,
+ type SafeAreaProviderProps,
+} from "react-native-safe-area-context";
+
+import { VariableContext } from "../runtime/native/reactivity";
+
+export * from "react-native-safe-area-context";
+
+export function SafeAreaProvider({
+ children,
+ ...props
+}: SafeAreaProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SafeAreaEnv({ children }: PropsWithChildren) {
+ const insets = useSafeAreaInsets();
+ const parentVars = useContext(VariableContext);
+
+ const value = useMemo(
+ () => ({
+ ...parentVars,
+ "--react-native-css-safe-area-inset-bottom": insets.bottom,
+ "--react-native-css-safe-area-inset-left": insets.left,
+ "--react-native-css-safe-area-inset-right": insets.right,
+ "--react-native-css-safe-area-inset-top": insets.top,
+ }),
+ [parentVars, insets],
+ );
+
+ return {children};
+}
diff --git a/src/components/react-native-safe-area-context.tsx b/src/components/react-native-safe-area-context.tsx
new file mode 100644
index 00000000..8f6206d4
--- /dev/null
+++ b/src/components/react-native-safe-area-context.tsx
@@ -0,0 +1 @@
+export * from "react-native-safe-area-context";
diff --git a/src/metro/resolver.ts b/src/metro/resolver.ts
index 0cf731a7..3ce12e1f 100644
--- a/src/metro/resolver.ts
+++ b/src/metro/resolver.ts
@@ -45,6 +45,14 @@ export function nativeResolver(
return resolver(context, `react-native-css/components`, platform);
}
+ if (moduleName === "react-native-safe-area-context") {
+ return resolver(
+ context,
+ `react-native-css/components/react-native-safe-area-context`,
+ platform,
+ );
+ }
+
// We only care about `react-native/Library/Components/.js` components
const segments = resolution.filePath.split(sep);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
diff --git a/src/runtime/native/styles/functions/index.ts b/src/runtime/native/styles/functions/index.ts
index fe2ec44c..3e41961e 100644
--- a/src/runtime/native/styles/functions/index.ts
+++ b/src/runtime/native/styles/functions/index.ts
@@ -1,6 +1,7 @@
export * from "./animation-timing";
export * from "./calc";
export * from "./filters";
+export * from "./numeric-functions";
export * from "./platform-functions";
export * from "./string-functions";
export * from "./transform-functions";
diff --git a/src/runtime/native/styles/functions/numeric-functions.ts b/src/runtime/native/styles/functions/numeric-functions.ts
new file mode 100644
index 00000000..6ccc9dc9
--- /dev/null
+++ b/src/runtime/native/styles/functions/numeric-functions.ts
@@ -0,0 +1,38 @@
+import type { StyleFunctionResolver } from "../resolve";
+
+export const max: StyleFunctionResolver = (resolveValue, value) => {
+ const args = resolveValue(value[2]);
+
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "number")) {
+ return;
+ }
+
+ return Math.max(...(args as number[]));
+};
+
+export const min: StyleFunctionResolver = (resolveValue, value) => {
+ const args = resolveValue(value[2]);
+
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "number")) {
+ return;
+ }
+
+ return Math.min(...(args as number[]));
+};
+
+export const clamp: StyleFunctionResolver = (resolveValue, value) => {
+ const args = resolveValue(value[2]);
+
+ const [clampValue, min, max] = args as number[];
+
+ if (
+ !Array.isArray(args) ||
+ typeof clampValue !== "number" ||
+ typeof min !== "number" ||
+ typeof max !== "number"
+ ) {
+ return;
+ }
+
+ return Math.min(Math.max(clampValue, min), max);
+};
diff --git a/yarn.lock b/yarn.lock
index d43c4745..72e4172b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11370,6 +11370,7 @@ __metadata:
react-test-renderer: "npm:^19.1.0"
release-it: "npm:^19.0.4"
tailwindcss: "npm:^4.1.12"
+ tailwindcss-safe-area: "npm:^1.1.0"
typescript: "npm:^5.9.2"
typescript-eslint: "npm:^8.40.0"
peerDependencies:
@@ -12532,6 +12533,15 @@ __metadata:
languageName: node
linkType: hard
+"tailwindcss-safe-area@npm:^1.1.0":
+ version: 1.1.0
+ resolution: "tailwindcss-safe-area@npm:1.1.0"
+ peerDependencies:
+ tailwindcss: ^4.0.0
+ checksum: 10c0/93e48186ebb06f7034f5a01160abad916fcce90f331017d7f350d7de5fd934d742fd7186819e48ff9987d4c2c8a6c4ba6da572af72a125312d8b3786c8415162
+ languageName: node
+ linkType: hard
+
"tailwindcss@npm:4.1.11":
version: 4.1.11
resolution: "tailwindcss@npm:4.1.11"