device frame.
iPhone and iPad frames drawn entirely from one CSS length, so they resize as real layout rather than as a scaled picture.
live component, try it
what to expect.
how it works.
Three labs here stand a phone in the stage, and the frame under them is 390x844 with every dimension a count of one length, --k. The tempting shortcut is to draw it once at native size and scale it, and the reason not to is not sharpness, since a static transform rasterises at its own scale and stays crisp. It is that a transform makes a coordinate space and not a layout box: the frame keeps reserving its full width, getBoundingClientRect stops agreeing with offsetWidth, and every gesture inside comes out wrong by exactly the scale factor with nothing anywhere reporting an error. The corners are the other half. The screen's radius is the shell's less the rim and the bezel, or the arcs stop being concentric and the gap around the corner opens and closes. The iPad is the same idea at 1000x710 with its own --k, and switching between them is the part to watch: the status bar and island blur out before the shell moves, width, height and every corner then share one spring curve, and the wallpaper is laid out in fractions of the screen, so it reshapes with the glass rather than being redrawn.
the component.
import type { CSSProperties, ReactNode } from "react";
import { BUTTONS, DEVICES, ISLAND, LENS, frameGeometry, type DeviceKind } from "./geometry";
export default function DeviceFrame({
kind = "phone",
width = DEVICES[kind].width,
fill = 1,
bezel = DEVICES[kind].bezel,
radius = DEVICES[kind].radius,
anchor = "center",
tone,
recording = false,
lift = "lab",
bare = false,
time = "9:41",
island,
lens = true,
wallpaper,
children,
}: {
kind?: DeviceKind;
width?: number;
fill?: number;
bezel?: number;
radius?: number;
anchor?: "top" | "center" | "bottom";
/** Ink for the drawn status bar. */
tone?: "light" | "dark";
recording?: boolean;
lift?: "lab" | "card";
/** No shell: the container is the glass. */
bare?: boolean;
time?: string;
island?: ReactNode;
lens?: boolean;
wallpaper?: ReactNode;
children?: ReactNode;
}) {
const spec = DEVICES[kind];
const phone = kind === "phone";
const px = (n: number) => `calc(${n} * var(--k))`;
const g = frameGeometry({ rim: spec.rim, bezel, radius });
const screen = (
<div
className="device-screen"
data-tone={tone}
data-recording={recording || undefined}
style={
{
borderRadius: bare ? "inherit" : px(g.screenRadius),
...(phone && { "--iw": ISLAND.w, "--idle-w": ISLAND.w }),
} as CSSProperties
}
>
{phone &&
(island ?? (
<div
className="device-island"
style={{
top: px(ISLAND.top),
width: px(ISLAND.w),
height: px(ISLAND.h),
}}
>
{recording && (
<span className="device-rec" style={{ left: px(11), width: px(9), height: px(9) }} />
)}
</div>
))}
{wallpaper && <div className="device-wallpaper">{wallpaper}</div>}
{phone && lens && (
<span
className="device-lens"
style={{
top: px(ISLAND.top + ISLAND.h / 2 - LENS / 2),
left: `calc(50% + ${px(ISLAND.w / 2 - ISLAND.h / 2 - LENS / 2)})`,
width: px(LENS),
height: px(LENS),
}}
/>
)}
<div
className="device-status"
aria-hidden="true"
data-ears={phone || undefined}
style={
phone
? { height: px(59), fontSize: px(17) }
: { height: px(26), padding: `0 ${px(20)}`, fontSize: px(13) }
}
>
<span className="device-clock" style={{ gap: px(phone ? 5 : 8) }}>
{time}
{!phone && <span>Mon Sep 21</span>}
{phone && recording && (
<svg
className="device-dnd"
style={{ width: px(13), height: px(13) }}
viewBox="0 0 13 13"
fill="currentColor"
>
<path d="M11.3 8.2A5 5 0 0 1 4.8 1.7 5 5 0 1 0 11.3 8.2Z" />
</svg>
)}
</span>
<span className="device-status-icons" style={{ gap: px(phone ? 6 : 5) }}>
{phone && (
<svg style={{ width: px(18), height: px(12) }} viewBox="0 0 18 12" fill="currentColor">
{[4.6, 7, 9.5, 12].map((h, i) => (
<rect key={h} x={i * 4.9} y={12 - h} width="3.2" height={h} rx="1" />
))}
</svg>
)}
<svg style={{ width: px(17), height: px(12) }} viewBox="0 0 17 12" fill="currentColor">
<path d="M8.5 2.3c2.5 0 4.8 1 6.5 2.6.2.2.5.2.7 0l1-1c.2-.2.2-.5 0-.7A11.7 11.7 0 0 0 8.5 0 11.7 11.7 0 0 0 .3 3.2c-.2.2-.2.5 0 .7l1 1c.2.2.5.2.7 0a9.4 9.4 0 0 1 6.5-2.6Z" />
<path d="M8.5 5.9c1.5 0 2.8.5 3.9 1.5.2.2.5.2.7 0l1-1c.2-.2.2-.5 0-.7a8 8 0 0 0-11.2 0c-.2.2-.2.5 0 .7l1 1c.2.2.5.2.7 0 1.1-1 2.4-1.5 3.9-1.5Z" />
<path d="M11 8.9c.2-.2.2-.5 0-.7a3.6 3.6 0 0 0-5 0c-.2.2-.2.5 0 .7l2.1 2.2c.2.2.6.2.8 0L11 8.9Z" />
</svg>
<svg style={{ width: px(27), height: px(13) }} viewBox="0 0 27 13">
<rect
x="0.5"
y="0.5"
width="23"
height="12"
rx="3.8"
fill="none"
stroke="currentColor"
strokeOpacity="0.35"
/>
<rect x="2" y="2" width="20" height="9" rx="2.3" fill="currentColor" />
<path d="M25 4.4v4.2a2.2 2.2 0 0 0 0-4.2Z" fill="currentColor" fillOpacity="0.4" />
</svg>
</span>
</div>
<div className="device-content">{children}</div>
{!bare && (
<span
className="device-home"
style={
phone
? { bottom: px(8), width: px(134), height: px(5) }
: { bottom: px(9), width: px(236), height: px(6) }
}
/>
)}
</div>
);
return (
<div
className="device-stage"
data-kind={kind}
data-anchor={anchor}
data-bare={bare || undefined}
style={
{
"--device-design-w": `${width}px`,
"--device-native-w": spec.width,
"--device-aspect": spec.width / spec.height,
"--device-fill": fill,
} as CSSProperties
}
>
{bare ? (
screen
) : (
<div className="device-body" style={{ width: "var(--device-w)", height: px(spec.height) }}>
{phone &&
BUTTONS.map((b) => (
<span
key={`${b.top}-${String(b.left)}`}
className="device-button"
data-side={b.left ? "left" : "right"}
style={{
width: px(5),
height: px(b.h),
top: px(b.top),
[b.left ? "left" : "right"]: px(-3),
borderRadius: b.left ? `${px(3)} 0 0 ${px(3)}` : `0 ${px(3)} ${px(3)} 0`,
}}
/>
))}
<div
className="device-shell"
data-lift={lift}
style={{ borderRadius: px(g.shellRadius), padding: px(g.rim) }}
>
<div className="device-bezel" style={{ borderRadius: px(g.bezelRadius), padding: px(g.bezel) }}>
{!phone && (
<span
className="device-lens"
style={{ top: px(g.bezel / 2 - 5), left: `calc(50% - ${px(5)})`, width: px(10), height: px(10) }}
/>
)}
{screen}
</div>
</div>
</div>
)}
</div>
);
}