device frame.

iPhone and iPad frames drawn entirely from one CSS length, so they resize as real layout rather than as a scaled picture.

css / container queries / layout

Loading component...

live component, try it

what to expect.

Tap the device to switch between the iPhone and the iPad
Resize the window: the frame reflows, it never scales
Look at the corners: the bezel stays even all the way round

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>
  );
}