Skip to content

Commit 791b46b

Browse files
fyalavuzCopilot
andcommitted
feat(safe-area): use CSS custom properties for mockup-friendly safe areas
- SafeArea, Header, Footer now use --sa-top/--sa-bottom CSS vars with env(safe-area-inset-*) as fallback — no default fallback value - PhoneMockup injects --sa-top:59px --sa-bottom:34px into iframe - Iframe renders at native iPhone 15 Pro resolution (393×852) with CSS scale transform for correct aspect ratio - Updated useSafeArea hook to read CSS vars first - Updated SafeArea tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3c1b552 commit 791b46b

6 files changed

Lines changed: 92 additions & 73 deletions

File tree

apps/examples/app/_components/phone-mockup.tsx

Lines changed: 68 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
"use client";
22

3-
import { useRef, useState } from "react";
3+
import { useRef, useState, useCallback } from "react";
44
import { cn } from "appshell-react";
55

66
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
77

8+
// iPhone 15 Pro logical dimensions
9+
const DEVICE_W = 393;
10+
const DEVICE_H = 852;
11+
// Safe area insets (Dynamic Island top + home indicator bottom)
12+
const SA_TOP = 59;
13+
const SA_BOTTOM = 34;
14+
// Display size
15+
const FRAME_W = 280;
16+
const SCALE = FRAME_W / DEVICE_W;
17+
const FRAME_H = Math.round(DEVICE_H * SCALE);
18+
819
interface PhoneMockupProps {
920
src: string;
1021
className?: string;
@@ -16,73 +27,85 @@ export function PhoneMockup({ src, className }: PhoneMockupProps) {
1627

1728
const fullSrc = `${basePath}${src}`;
1829

30+
const handleLoad = useCallback(() => {
31+
setLoaded(true);
32+
try {
33+
const doc = iframeRef.current?.contentDocument;
34+
if (!doc) return;
35+
const style = doc.createElement("style");
36+
style.textContent = `:root{--sa-top:${SA_TOP}px;--sa-bottom:${SA_BOTTOM}px;--sa-left:0px;--sa-right:0px}`;
37+
doc.head.appendChild(style);
38+
} catch {
39+
// cross-origin — ignored in production
40+
}
41+
}, []);
42+
1943
return (
2044
<div
2145
className={cn("relative mx-auto select-none", className)}
22-
style={{ width: 300, height: 612 }}
46+
style={{ width: FRAME_W + 16, height: FRAME_H + 16 }}
2347
>
24-
{/* Phone body — titanium finish */}
25-
<div className="absolute inset-0 rounded-[3rem] bg-gradient-to-b from-[#2a2a2e] via-[#1d1d1f] to-[#2a2a2e] shadow-2xl shadow-black/40">
48+
{/* Phone body */}
49+
<div className="absolute inset-0 rounded-[2.8rem] bg-gradient-to-b from-[#2a2a2e] via-[#1d1d1f] to-[#2a2a2e] shadow-2xl shadow-black/40">
2650
{/* Side buttons */}
27-
<div className="absolute -left-[2px] top-[100px] h-8 w-[3px] rounded-l-sm bg-[#3a3a3c]" />
28-
<div className="absolute -left-[2px] top-[145px] h-14 w-[3px] rounded-l-sm bg-[#3a3a3c]" />
29-
<div className="absolute -left-[2px] top-[195px] h-14 w-[3px] rounded-l-sm bg-[#3a3a3c]" />
30-
<div className="absolute -right-[2px] top-[140px] h-16 w-[3px] rounded-r-sm bg-[#3a3a3c]" />
51+
<div className="absolute -left-[2px] top-[80px] h-7 w-[3px] rounded-l-sm bg-[#3a3a3c]" />
52+
<div className="absolute -left-[2px] top-[118px] h-12 w-[3px] rounded-l-sm bg-[#3a3a3c]" />
53+
<div className="absolute -left-[2px] top-[160px] h-12 w-[3px] rounded-l-sm bg-[#3a3a3c]" />
54+
<div className="absolute -right-[2px] top-[115px] h-14 w-[3px] rounded-r-sm bg-[#3a3a3c]" />
55+
56+
{/* Screen area */}
57+
<div className="absolute inset-[8px] rounded-[2.2rem] overflow-hidden bg-black">
58+
{/* Iframe at native resolution, scaled down */}
59+
<div
60+
className="origin-top-left"
61+
style={{ width: DEVICE_W, height: DEVICE_H, transform: `scale(${SCALE})` }}
62+
>
63+
{!loaded && (
64+
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white dark:bg-zinc-950">
65+
<div className="size-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-600" />
66+
</div>
67+
)}
68+
<iframe
69+
ref={iframeRef}
70+
src={fullSrc}
71+
title="Example preview"
72+
className={cn(
73+
"h-full w-full border-0 transition-opacity duration-300",
74+
loaded ? "opacity-100" : "opacity-0"
75+
)}
76+
onLoad={handleLoad}
77+
/>
78+
</div>
3179

32-
{/* Screen bezel */}
33-
<div className="absolute inset-[6px] rounded-[2.5rem] overflow-hidden bg-black">
34-
{/* Status bar */}
35-
<div className="relative z-20 flex h-[52px] items-end justify-between px-7 pb-1 text-white">
36-
{/* Time */}
37-
<span className="text-[15px] font-semibold leading-none">9:41</span>
38-
{/* Dynamic Island */}
39-
<div className="absolute left-1/2 top-[10px] -translate-x-1/2 h-[30px] w-[120px] rounded-full bg-black z-30" />
40-
{/* Status icons */}
41-
<div className="flex items-center gap-[5px]">
42-
{/* Signal */}
43-
<svg width="17" height="12" viewBox="0 0 17 12" fill="white">
80+
{/* Status bar overlay */}
81+
<div
82+
className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-end justify-between px-6 pb-0.5 text-white"
83+
style={{ height: Math.round(SA_TOP * SCALE) }}
84+
>
85+
<span className="text-[11px] font-semibold leading-none">9:41</span>
86+
<div className="absolute left-1/2 top-[3px] -translate-x-1/2 h-[22px] w-[90px] rounded-full bg-black" />
87+
<div className="flex items-center gap-[4px]">
88+
<svg width="14" height="10" viewBox="0 0 17 12" fill="white">
4489
<rect x="0" y="9" width="3" height="3" rx="0.5" opacity="0.4"/>
4590
<rect x="4.5" y="6" width="3" height="6" rx="0.5" opacity="0.6"/>
4691
<rect x="9" y="3" width="3" height="9" rx="0.5" opacity="0.8"/>
4792
<rect x="13.5" y="0" width="3" height="12" rx="0.5"/>
4893
</svg>
49-
{/* WiFi */}
50-
<svg width="16" height="12" viewBox="0 0 16 12" fill="white">
94+
<svg width="13" height="10" viewBox="0 0 16 12" fill="white">
5195
<path d="M8 11.5a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5z"/>
5296
<path d="M4.75 7.75a4.5 4.5 0 016.5 0" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round"/>
5397
<path d="M2 4.75a8 8 0 0112 0" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round"/>
5498
</svg>
55-
{/* Battery */}
56-
<svg width="27" height="12" viewBox="0 0 27 12" fill="none">
99+
<svg width="22" height="10" viewBox="0 0 27 12" fill="none">
57100
<rect x="0.5" y="0.5" width="22" height="11" rx="2.5" stroke="white" strokeOpacity="0.35"/>
58101
<rect x="2" y="2" width="19" height="8" rx="1.5" fill="white"/>
59102
<path d="M24 4v4a2 2 0 000-4z" fill="white" fillOpacity="0.4"/>
60103
</svg>
61104
</div>
62105
</div>
63106

64-
{/* Iframe screen content */}
65-
<div className="absolute inset-0 top-0">
66-
{!loaded && (
67-
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background">
68-
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
69-
</div>
70-
)}
71-
<iframe
72-
ref={iframeRef}
73-
src={fullSrc}
74-
title="Example preview"
75-
className={cn(
76-
"h-full w-full border-0 transition-opacity duration-300",
77-
loaded ? "opacity-100" : "opacity-0"
78-
)}
79-
style={{ pointerEvents: "auto" }}
80-
onLoad={() => setLoaded(true)}
81-
/>
82-
</div>
83-
84107
{/* Home indicator */}
85-
<div className="absolute bottom-[6px] left-1/2 -translate-x-1/2 z-20 h-[5px] w-[130px] rounded-full bg-white/60" />
108+
<div className="pointer-events-none absolute bottom-[4px] left-1/2 -translate-x-1/2 z-20 h-[4px] w-[100px] rounded-full bg-white/60" />
86109
</div>
87110
</div>
88111
</div>

packages/react/__tests__/SafeArea.test.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@ describe("SafeArea", () => {
1212
const { container } = render(<SafeArea>Content</SafeArea>);
1313
const el = container.firstElementChild as HTMLElement;
1414
const styleAttr = el.getAttribute("style") || "";
15-
expect(styleAttr).toContain("padding-top: env(safe-area-inset-top, 0px)");
16-
expect(styleAttr).toContain("padding-bottom: env(safe-area-inset-bottom, 0px)");
17-
expect(styleAttr).toContain("padding-left: env(safe-area-inset-left, 0px)");
18-
expect(styleAttr).toContain("padding-right: env(safe-area-inset-right, 0px)");
15+
expect(styleAttr).toContain("padding-top: var(--sa-top, env(safe-area-inset-top, 0px))");
16+
expect(styleAttr).toContain("padding-bottom: var(--sa-bottom, env(safe-area-inset-bottom, 0px))");
17+
expect(styleAttr).toContain("padding-left: var(--sa-left, env(safe-area-inset-left, 0px))");
18+
expect(styleAttr).toContain("padding-right: var(--sa-right, env(safe-area-inset-right, 0px))");
1919
});
2020

2121
it("applies padding only for specified edges", () => {
2222
const { container } = render(<SafeArea edges={["top", "bottom"]}>Content</SafeArea>);
2323
const el = container.firstElementChild as HTMLElement;
2424
const styleAttr = el.getAttribute("style") || "";
25-
expect(styleAttr).toContain("padding-top: env(safe-area-inset-top, 0px)");
26-
expect(styleAttr).toContain("padding-bottom: env(safe-area-inset-bottom, 0px)");
25+
expect(styleAttr).toContain("padding-top: var(--sa-top, env(safe-area-inset-top, 0px))");
26+
expect(styleAttr).toContain("padding-bottom: var(--sa-bottom, env(safe-area-inset-bottom, 0px))");
2727
expect(styleAttr).not.toContain("padding-left");
2828
expect(styleAttr).not.toContain("padding-right");
2929
});

packages/react/src/Footer.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export const Footer = memo(function Footer({
8181
positionClass,
8282
className
8383
)}
84-
style={{ paddingBottom: "max(calc(env(safe-area-inset-bottom, 0px) + 1.5rem), 1.8rem)" }}
84+
style={{ paddingBottom: "max(calc(var(--sa-bottom, env(safe-area-inset-bottom, 0px)) + 1.5rem), 1.8rem)" }}
8585
>
8686
<AnimatePresence>
8787
{!shouldHide && (
@@ -113,7 +113,7 @@ export const Footer = memo(function Footer({
113113
"fixed bottom-0 left-0 right-0 z-50 min-h-12 border-t bg-background/95 backdrop-blur-xl",
114114
className
115115
)}
116-
style={{ paddingBottom: "max(env(safe-area-inset-bottom, 0px), 1.8rem)" }}
116+
style={{ paddingBottom: "max(var(--sa-bottom, env(safe-area-inset-bottom, 0px)), 1.8rem)" }}
117117
>
118118
<div className="mx-auto flex h-12 max-w-7xl items-center px-4">
119119
{children}
@@ -137,7 +137,7 @@ export const Footer = memo(function Footer({
137137
"fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur-xl",
138138
className
139139
)}
140-
style={{ paddingBottom: "max(env(safe-area-inset-bottom, 0px), 1.8rem)" }}
140+
style={{ paddingBottom: "max(var(--sa-bottom, env(safe-area-inset-bottom, 0px)), 1.8rem)" }}
141141
>
142142
<div className="mx-auto flex max-w-lg items-stretch">
143143
{children}

packages/react/src/Header.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ export const Header = memo(function Header({
271271
t.wrapper,
272272
className
273273
)}
274-
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
274+
style={{ paddingTop: "var(--sa-top, env(safe-area-inset-top, 0px))" }}
275275
>
276276
<HeaderProvider value={{ theme }}>
277277
{renderNavRow()}
@@ -292,7 +292,7 @@ export const Header = memo(function Header({
292292
t.wrapper,
293293
className
294294
)}
295-
style={{ paddingTop: forceSafeAreaTop ? "env(safe-area-inset-top, 0px)" : undefined }}
295+
style={{ paddingTop: forceSafeAreaTop ? "var(--sa-top, env(safe-area-inset-top, 0px))" : undefined }}
296296
>
297297
{renderContent()}
298298
</header>
@@ -311,7 +311,7 @@ export const Header = memo(function Header({
311311
"fixed top-0 left-0 right-0 z-[60] shadow-lg",
312312
t.wrapper
313313
)}
314-
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
314+
style={{ paddingTop: "var(--sa-top, env(safe-area-inset-top, 0px))" }}
315315
>
316316
<HeaderProvider value={{ theme }}>
317317
{shouldShowInOverlay("nav") && renderNavRow()}

packages/react/src/SafeArea.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import type { SafeAreaProps, SafeAreaEdge } from "./types";
55
import { cn } from "./cn";
66

77
const cssMap: Record<SafeAreaEdge, string> = {
8-
top: "padding-top: env(safe-area-inset-top, 0px)",
9-
bottom: "padding-bottom: env(safe-area-inset-bottom, 0px)",
10-
left: "padding-left: env(safe-area-inset-left, 0px)",
11-
right: "padding-right: env(safe-area-inset-right, 0px)",
8+
top: "padding-top: var(--sa-top, env(safe-area-inset-top, 0px))",
9+
bottom: "padding-bottom: var(--sa-bottom, env(safe-area-inset-bottom, 0px))",
10+
left: "padding-left: var(--sa-left, env(safe-area-inset-left, 0px))",
11+
right: "padding-right: var(--sa-right, env(safe-area-inset-right, 0px))",
1212
};
1313

1414
export const SafeArea = memo(function SafeArea({

packages/react/src/hooks/use-safe-area.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,15 @@ export function useSafeArea(edges: SafeAreaEdge[] = ["top", "bottom", "left", "r
2121
useEffect(() => {
2222
const measure = () => {
2323
const style = getComputedStyle(document.documentElement);
24+
const read = (prop: string, envProp: string) =>
25+
parseFloat(style.getPropertyValue(prop) || "0") ||
26+
parseFloat(style.getPropertyValue(envProp) || "0");
27+
2428
setInsets({
25-
top: edges.includes("top")
26-
? parseFloat(style.getPropertyValue("env(safe-area-inset-top)") || "0")
27-
: 0,
28-
bottom: edges.includes("bottom")
29-
? parseFloat(style.getPropertyValue("env(safe-area-inset-bottom)") || "0")
30-
: 0,
31-
left: edges.includes("left")
32-
? parseFloat(style.getPropertyValue("env(safe-area-inset-left)") || "0")
33-
: 0,
34-
right: edges.includes("right")
35-
? parseFloat(style.getPropertyValue("env(safe-area-inset-right)") || "0")
36-
: 0,
29+
top: edges.includes("top") ? read("--sa-top", "env(safe-area-inset-top)") : 0,
30+
bottom: edges.includes("bottom") ? read("--sa-bottom", "env(safe-area-inset-bottom)") : 0,
31+
left: edges.includes("left") ? read("--sa-left", "env(safe-area-inset-left)") : 0,
32+
right: edges.includes("right") ? read("--sa-right", "env(safe-area-inset-right)") : 0,
3733
});
3834
};
3935

0 commit comments

Comments
 (0)