import { type CSSProperties, type HTMLAttributes, type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { useLook } from './looks'
import './phone.css'
// An iPhone drawn from the real geometry, the iPhone 18 Pro in points, as
// Apple's iOS 27 UI kit lays it out: screen 402 × 874, status bar 62 tall,
// Dynamic Island 95 × 37 sitting 14 from the top, home indicator 144 × 5
// sitting 8 from the bottom. The screen's corner is traced from the cut-out
// in Apple's own product bezel (the kit's screens are square and the bezel
// rounds them): a superellipse 71pt across, exponent 2.31, within 0.7pt of
// Apple's curve all the way round.
//
// The screen is laid out at true points and scaled down as a whole, so type
// inside it renders at the size iOS uses (15pt, not 8px) and keeps the right
// letter shapes and spacing. Inside the screen, 1pt is 1px; `--pt` is kept
// at 1px there so calc(var(--pt) * n) still works.
const IPHONE = { w: 402, h: 874, r: 64 }
const CORNER = { a: 71, n: 2.31 }
// soft: the illustrative phone marketing pages draw, measured from one (a
// 250 × 527 screen, 28 corners): a little squatter, tighter corners
const SOFT = { w: 393, h: 829, r: 42 }
// Pixel 8: 412 × 915dp, corners about 38, a 24dp punch-hole camera 14 from the top.
const PIXEL = { w: 412, h: 915, r: 38 }
/**
* photo: an optional caller-provided product bezel; falls back to pro without one.
* pro: titanium band and black glass, island, status bar. The default.
* bezel: a plain black border, no band. Flatter, more illustrative.
* soft: a frosted white frame with no island or status bar, for a lighter, illustrative phone.
* bare: the screen alone, rounded, on a shadow.
* silver: light titanium around black glass, for light pages where black reads heavy.
* clay: a soft matte frame in one light color (--ss-phone-clay), lit like a rendered model.
* outline: line art, a stroke around the screen. For blueprint and minimal looks.
* android: a Pixel. Its own screen size, tighter corners, a punch-hole camera.
*/
export type PhoneFrame = 'pro' | 'photo' | 'bezel' | 'soft' | 'bare' | 'silver' | 'clay' | 'outline' | 'android'
const FRAMES: Record = {
pro: { band: 3, glass: 10 },
// Apple's photograph of the iPhone 18 Pro (its product bezel): the body runs
// 16pt past the screen on every side; the image itself is drawn over it
photo: { band: 0, glass: 16 },
bezel: { band: 0, glass: 12 },
soft: { band: 0, glass: 12.5 },
bare: { band: 0, glass: 0 },
silver: { band: 4, glass: 9 },
clay: { band: 0, glass: 16 },
outline: { band: 0, glass: 8 },
android: { band: 2, glass: 9 },
}
// The iOS frames draw their bands as strokes of the screen's own outline,
// each centered on the screen's edge, widest first: every band then stays
// exactly parallel to the screen, corners included. Each is [reach from the
// screen's edge in points, paint]; a paint is a color or one of the
// gradients Phone defines (url(#…-band), url(#…-clay))
const BANDS: Partial> = {
pro: [[13, 'band'], [10, 'var(--ss-phone-rim)'], [9.25, 'var(--ss-phone-glass)']],
silver: [[13, 'band'], [9, 'var(--ss-phone-glass)']],
bezel: [[12, 'var(--ss-phone-glass)']],
clay: [[16, 'clay'], [1, 'var(--ss-phone-seam)']],
}
/** A rounded rectangle with continuous (superellipse) corners, as an SVG
* path: corners `a` across with exponent `n` (2 is a circle). */
function superellipsePath(w: number, h: number, a: number, n: number) {
const steps = 32
const e = 2 / n
const pts: string[] = []
const corners: [number, number, number, number][] = [
[w - a, a, 1, -1],
[w - a, h - a, 1, 1],
[a, h - a, -1, 1],
[a, a, -1, -1],
]
corners.forEach(([cx, cy, sx, sy], c) => {
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * (Math.PI / 2)
// Clockwise: top-right runs top to right, bottom-right right to bottom, and so on
const u = c % 2 === 0 ? Math.sin(t) : Math.cos(t)
const v = c % 2 === 0 ? Math.cos(t) : Math.sin(t)
pts.push(`${(cx + sx * a * Math.pow(u, e)).toFixed(2)} ${(cy + sy * a * Math.pow(v, e)).toFixed(2)}`)
}
})
return `M${pts.join('L')}Z`
}
export interface PhoneProps extends HTMLAttributes {
/** Outer width in px. Height follows the real proportions. */
width: number
frame?: PhoneFrame
/** Status bar time. */
time?: string
/** The Dynamic Island. On by default for pro, silver, bezel and android. */
island?: boolean
/** The status bar (time, signal, battery). On by default for pro, silver, bezel and android. With it off, content still starts below a 44pt safe area. */
status?: boolean
/** Light content (white status bar, home indicator) for dark screens and wallpapers. */
dark?: boolean
/** Any CSS background for the screen: a color, gradient or image. The screen is white by default, black with `dark`. */
wallpaper?: string
children?: ReactNode
}
export function Phone({ width, frame: asked = 'pro', time = '9:41', island, status, dark, wallpaper, className, style, children, ...rest }: PhoneProps) {
// The photo frame uses a caller-provided bezel. Without a configured image,
// or if it fails to load, the phone draws the pro frame
// instead of standing as an empty shell
const photoRef = useRef(null)
const [photoOk, setPhotoOk] = useState(true)
useEffect(() => {
if (asked !== 'photo' || !photoRef.current) return
const bg = getComputedStyle(photoRef.current).backgroundImage
const url = bg.match(/url\(["']?([^"')]+)["']?\)/)?.[1]
if (!url) return void setPhotoOk(false)
let live = true
const img = new Image()
img.onerror = () => live && setPhotoOk(false)
img.src = url
return () => {
live = false
}
}, [asked, dark])
const frame: PhoneFrame = asked === 'photo' && !photoOk ? 'pro' : asked
const { band, glass } = FRAMES[frame]
const { fidelity } = useLook()
const SCREEN = frame === 'android' ? PIXEL : frame === 'soft' ? SOFT : IPHONE
const edge = band + glass
const s = width / (SCREEN.w + edge * 2)
const pt = (n: number) => n * s
const id = useId().replace(/:/g, '')
const ios = frame !== 'android' && frame !== 'soft'
const outline = ios ? superellipsePath(pt(SCREEN.w), pt(SCREEN.h), pt(CORNER.a), CORNER.n) : null
const bands = BANDS[frame]
const chrome = frame === 'pro' || frame === 'photo' || frame === 'bezel' || frame === 'silver' || frame === 'android'
// The photo has its own island, lens and all
const showIsland = island ?? (chrome && frame !== 'photo')
const showStatus = status ?? chrome
return (
{frame === 'photo' && (
// The bezel image is 450 × 920pt with the screen at (24, 23): laid over
// the body so its cut-out frames the screen exactly
)}
)
}
export interface LockScreenProps extends HTMLAttributes {
date?: string
time?: string
/** The flashlight and camera buttons at the bottom. */
controls?: boolean
/** Where notifications stack. 'bottom' is how iOS does it; 'top' puts them
* under the clock, for a phone cropped by the stage's bottom edge. */
stack?: 'bottom' | 'top'
/**
* satellite: a phone beside a window. The controls go and notifications
* stack under the clock. Nothing is scaled: notifications stay true iOS
* size, so if one wouldn't read, make the phone bigger (and crop it), never
* the notification.
*/
size?: 'full' | 'satellite'
/** The date and clock. Off for a crop that shows only the notifications. */
clock?: boolean
/** Notifications. They stack at the bottom of the screen, as iOS does. */
children?: ReactNode
}
/** The default lock screen wallpaper: a soft blue to lilac gradient with a
* faint light at the top, after the iPhone's own defaults. Quiet enough to
* sit behind any demo; white type and frosted banners read on it. */
export const WALLPAPER =
'radial-gradient(120% 70% at 25% 0%, rgba(255, 255, 255, 0.3), transparent 65%), linear-gradient(172deg, #8ea9d6 0%, #9aa1d4 50%, #aca3cf 100%)'
/** The iOS lock screen: date, large clock, notifications stacked at the bottom. Put it inside a Phone with `dark` and a `wallpaper`. */
export function LockScreen({ date = 'Tuesday, October 21', time = '9:41', controls = true, stack = 'bottom', size = 'full', clock = true, className, children, ...rest }: LockScreenProps) {
return (
{clock &&
{date}
}
{clock &&
{time}
}
{children}
{controls && size === 'full' && (
{/* Drawn after SF Symbols flashlight.off.fill and camera.fill: solid
glyphs with cut-outs, not outlines */}
)}
)
}
export interface NotificationProps extends Omit, 'title'> {
icon?: ReactNode
title: ReactNode
body?: ReactNode
time?: string
/** Where it shows. Leave it out on a LockScreen, which stacks notifications
* at the bottom as iOS does. banner: the phone is unlocked (in an app or on
* the home screen), so it drops in at the top of the screen, under the
* island, over whatever is there. */
banner?: boolean
}
/**
* An iOS notification banner, in points. Place it inside a Phone (or a
* LockScreen). The body wraps to three lines, as iOS shows it. It is native
* width, the screen minus 18pt each side, and nothing narrows it: a phone
* cropped at a side is composed so the banner shows whole.
*/
export function Notification({ icon, title, body, time = 'now', banner, className, ...rest }: NotificationProps) {
const el = useRef(null)
return (
{icon}{title}{time}
{body && {body}}
)
}
// The status bar glyphs, taken from Apple's iOS 27 UI kit for Figma
// (Status bar, iPhone): exact paths and sizes in points
function Signal() {
// Four bars 3.33 wide on a 5.67 pitch, fully rounded, 6.33 to 13.67 tall
return (
)
}
function Wifi() {
return (
)
}
function Battery() {
// iOS 27 draws a full battery as one solid body, with a small contact
return (
)
}