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 (
{(bands || frame === 'outline') && outline && ( {[0, 0.22, 0.48, 0.74, 1].map((o, i) => ( ))} {frame === 'outline' && ( // Line art: a ring between 7 and 8pt out, cut from a mask so it is one hairline )} {frame === 'outline' ? ( <> ) : ( bands!.map(([d, paint]) => ( )) )} )}
{showStatus && (
{fidelity === 'minimal' ? : time}
)} {showIsland && }
{children}
{chrome && }
{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 ( {[6.33, 8.67, 11, 13.67].map((h, i) => ( ))} ) } function Wifi() { return ( ) } function Battery() { // iOS 27 draws a full battery as one solid body, with a small contact return ( ) }