A modern phone drawn in CSS: titanium band, side buttons, Dynamic Island, status bar and home indicator. Children render as the screen, laid out at a phone resolution and scaled to fit.
Right-to-left: Hardware buttons and bezels sit on fixed physical edges of the device, so the frame keeps its shape; the screen content you place inside follows the page direction.
Wallet
Balance
$12,480.20
Latest
import { ArrowDownLeft, ArrowUpRight, Coffee, Plus, ShoppingBag, Train } from "lucide-react"
import { PhoneFrame } from "@/components/ballmac/phone-frame"
const activity = [
{ icon: Coffee, name: "Corner Café", detail: "Today, 8:12", amount: "−$4.80" },
{ icon: ArrowDownLeft, name: "From Sam", detail: "Yesterday", amount: "+$120.00", incoming: true },
{ icon: Train, name: "City Transit", detail: "Yesterday", amount: "−$2.75" },
{ icon: ShoppingBag, name: "Market Hall", detail: "Mon", amount: "−$38.12" },
]
function Wallet() {
return (
<div className="flex h-full flex-col px-5 pb-8 text-[15px]">
<div className="flex items-center justify-between pt-2">
<p className="text-[28px] font-bold tracking-tight">Wallet</p>
<span className="flex size-9 items-center justify-center rounded-full bg-muted text-foreground">
<Plus className="size-4" aria-hidden="true" />
</span>
</div>
{/* Stacked cards */}
<div className="relative mt-4 h-[236px]">
<div className="absolute inset-x-3 top-0 h-[200px] rounded-2xl bg-[linear-gradient(135deg,var(--chart-2),var(--chart-1))] opacity-60" />
<div className="absolute inset-x-1.5 top-3 h-[200px] rounded-2xl bg-[linear-gradient(135deg,var(--chart-3),var(--chart-5))] opacity-80" />
<div className="absolute inset-x-0 top-7 flex h-[208px] flex-col overflow-hidden rounded-2xl bg-[linear-gradient(140deg,var(--chart-1),var(--chart-4)_70%,var(--chart-5))] p-5 text-white shadow-[0_18px_40px_-16px_rgb(0_0_0/0.5)]">
<span className="pointer-events-none absolute -top-16 -end-10 size-48 rounded-full bg-white/15 blur-2xl" />
<div className="relative flex items-center justify-between text-[13px] font-medium text-white/85">
<span>Acme Card</span>
<span className="font-mono">•••• 4821</span>
</div>
<p className="relative mt-auto text-[12px] text-white/75">Balance</p>
<p className="relative text-[32px] font-semibold tracking-tight tabular-nums">$12,480.20</p>
</div>
</div>
<div className="mt-5 grid grid-cols-3 gap-2.5">
{[
{ icon: ArrowUpRight, label: "Send" },
{ icon: ArrowDownLeft, label: "Request" },
{ icon: Plus, label: "Top up" },
].map(({ icon: Icon, label }) => (
<span key={label} className="flex flex-col items-center gap-1.5 rounded-xl bg-muted/70 py-3 text-[12px] font-medium">
<Icon className="size-4" aria-hidden="true" />
{label}
</span>
))}
</div>
<p className="mt-6 text-[13px] font-semibold text-muted-foreground">Latest</p>
<div className="mt-2 divide-y rounded-xl border bg-card">
{activity.map(({ icon: Icon, name, detail, amount, incoming }) => (
<div key={name} className="flex items-center gap-3 px-3.5 py-3">
<span className="flex size-9 items-center justify-center rounded-full bg-muted">
<Icon className="size-4" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-medium">{name}</span>
<span className="block text-[12px] text-muted-foreground">{detail}</span>
</span>
<span className={`text-[14px] font-medium tabular-nums ${incoming ? "text-chart-2" : ""}`}>{amount}</span>
</div>
))}
</div>
</div>
)
}
export default function PhoneFrameDemo() {
return (
<div className="flex w-full justify-center py-4">
<PhoneFrame screenWidth={390} className="w-[248px]">
<Wallet />
</PhoneFrame>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/phone-frameInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/mediaCopy the source into your project.
components/ballmac/phone-frame.tsx// Ballmac UI: Phone Frame. https://ui.ballmac.com/components/phone-frame "use client" import * as React from "react" import { useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { Media } from "@/components/ballmac/media" type PhoneFrameProps = Omit<React.ComponentProps<"div">, "children"> & { /** Screen content, laid out below the status bar. Ignored when `src` or `videoSrc` is set. */ children?: React.ReactNode /** Finish of the frame. "auto" is natural titanium in light mode and black titanium in dark mode. */ variant?: "auto" | "natural" | "black" /** Image shown on the screen, cropped to fill it (status bar and island stay on top). */ src?: string /** Alternative text for `src`. */ alt?: string /** Video shown on the screen: muted, looping, and paused under reduced motion. */ videoSrc?: string /** Poster image for `videoSrc`. */ poster?: string /** * Width in CSS pixels that the screen content is laid out at (393 matches a 6.1" phone); it is * then scaled to fit. Omit to lay content out at the frame's actual size. */ screenWidth?: number /** Show the status bar (time, signal, Wi-Fi, battery). */ statusBar?: boolean /** Time shown in the status bar. */ time?: string /** Show the home indicator bar at the bottom of the screen. */ homeIndicator?: boolean /** Class names for the screen element (inside the bezel). Use "dark" to give the screen its own theme. */ screenClassName?: string } const NATURAL = "[--pf-hi:color-mix(in_oklch,white_92%,black)] [--pf-mid:color-mix(in_oklch,white_74%,black)] [--pf-lo:color-mix(in_oklch,white_56%,black)] [--pf-btn:color-mix(in_oklch,white_66%,black)]" const BLACK = "[--pf-hi:color-mix(in_oklch,white_42%,black)] [--pf-mid:color-mix(in_oklch,white_20%,black)] [--pf-lo:color-mix(in_oklch,white_10%,black)] [--pf-btn:color-mix(in_oklch,white_24%,black)]" const BLACK_DARK = "dark:[--pf-hi:color-mix(in_oklch,white_42%,black)] dark:[--pf-mid:color-mix(in_oklch,white_20%,black)] dark:[--pf-lo:color-mix(in_oklch,white_10%,black)] dark:[--pf-btn:color-mix(in_oklch,white_24%,black)]" /** Lays screen content out at `screenWidth` CSS pixels and scales it to fit, without re-rendering. */ function useScreenScale(screenWidth: number | undefined) { const ref = React.useRef<HTMLDivElement>(null) React.useEffect(() => { const screen = ref.current if (!screen || !screenWidth) return const measure = () => screen.style.setProperty("--screen-scale", String(screen.clientWidth / screenWidth)) measure() const observer = new ResizeObserver(measure) observer.observe(screen) return () => observer.disconnect() }, [screenWidth]) return ref } function StatusBar({ time }: { time: string }) { return ( <div data-slot="phone-frame-status-bar" className="relative z-20 flex h-[13cqw] shrink-0 items-center justify-between px-[8.5cqw] pt-[1.2cqw] text-[4.1cqw] font-semibold tracking-tight text-foreground" > <span className="w-[20cqw] text-center tabular-nums">{time}</span> <span aria-hidden="true" className="flex items-center gap-[1.4cqw]"> {/* Cellular */} <svg viewBox="0 0 18 12" className="h-[2.9cqw]" fill="currentColor"> <rect x="0" y="8" width="3" height="4" rx="0.8" /> <rect x="5" y="5.5" width="3" height="6.5" rx="0.8" /> <rect x="10" y="3" width="3" height="9" rx="0.8" /> <rect x="15" y="0" width="3" height="12" rx="0.8" /> </svg> {/* Wi-Fi */} <svg viewBox="0 0 16 12" className="h-[2.9cqw]" fill="currentColor"> <path d="M8 2.2c2.4 0 4.6.9 6.2 2.5l1.3-1.3A10.6 10.6 0 0 0 8 .4 10.6 10.6 0 0 0 .5 3.4l1.3 1.3A8.8 8.8 0 0 1 8 2.2Z" /> <path d="M8 5.8c1.4 0 2.7.5 3.7 1.4L13 5.9A7 7 0 0 0 8 4a7 7 0 0 0-5 1.9l1.3 1.3c1-.9 2.3-1.4 3.7-1.4Z" /> <path d="M8 9.3c.5 0 1 .2 1.3.5L8 11.6 6.7 9.8c.3-.3.8-.5 1.3-.5Z" /> </svg> {/* Battery */} <svg viewBox="0 0 27 13" className="h-[3.1cqw]"> <rect x="0.5" y="0.5" width="23" height="12" rx="3.6" fill="none" stroke="currentColor" strokeOpacity="0.4" /> <rect x="2" y="2" width="17" height="9" rx="2.2" fill="currentColor" /> <path d="M25 4.5v4c.8-.3 1.4-1.1 1.4-2s-.6-1.7-1.4-2Z" fill="currentColor" fillOpacity="0.45" /> </svg> </span> </div> ) } function PhoneFrame({ variant = "auto", src, alt = "", videoSrc, poster, screenWidth, statusBar = true, time = "9:41", homeIndicator = true, screenClassName, className, children, ...props }: PhoneFrameProps) { const media = !!(src || videoSrc) const viewportRef = useScreenScale(media ? undefined : screenWidth) const reduceMotion = !!useReducedMotion() return ( <div data-slot="phone-frame" data-variant={variant} className={cn( "@container relative aspect-[71.6/146.6] w-full max-w-[320px] select-none", variant === "natural" && NATURAL, variant === "black" && BLACK, variant === "auto" && cn(NATURAL, BLACK_DARK), className )} {...props} > {/* Buttons: action and volume on the left, side button on the right. */} {[ "left-[-0.9cqw] top-[18%] h-[5.2%] rounded-l-[0.6cqw]", "left-[-0.9cqw] top-[26.5%] h-[8.6%] rounded-l-[0.6cqw]", "left-[-0.9cqw] top-[37%] h-[8.6%] rounded-l-[0.6cqw]", "right-[-0.9cqw] top-[29%] h-[13.5%] rounded-r-[0.6cqw]", ].map((position) => ( <span key={position} aria-hidden="true" className={cn( "absolute w-[1.4cqw] bg-[linear-gradient(90deg,var(--pf-lo),var(--pf-btn)_40%,var(--pf-hi)_55%,var(--pf-lo))]", position )} /> ))} {/* Titanium band */} <div data-slot="phone-frame-body" className="absolute inset-0 rounded-[16.5cqw] bg-[linear-gradient(135deg,var(--pf-hi),var(--pf-mid)_30%,var(--pf-lo)_55%,var(--pf-mid)_80%,var(--pf-hi))] p-[1.25cqw] shadow-[inset_0_0_0_0.25cqw_rgb(255_255_255/0.12),0_2cqw_6cqw_-1cqw_rgb(0_0_0/0.35),0_0.5cqw_1.2cqw_rgb(0_0_0/0.18)]" > {/* Black border glass */} <div className="size-full rounded-[15.3cqw] bg-black p-[3.1cqw] shadow-[inset_0_0_0_0.35cqw_rgb(255_255_255/0.07)]"> <div data-slot="phone-frame-screen" className={cn( "relative isolate flex size-full flex-col overflow-hidden rounded-[12.4cqw] bg-background text-foreground", screenClassName )} > {media ? ( videoSrc ? ( <video data-slot="phone-frame-video" className="absolute inset-0 size-full object-cover" src={videoSrc} poster={poster} autoPlay={!reduceMotion} muted loop playsInline /> ) : ( // eslint-disable-next-line @next/next/no-img-element <Media data-slot="phone-frame-image" media={src} alt={alt} fill className="absolute inset-0" /> ) ) : null} {statusBar && <StatusBar time={time} />} {!media && ( <div ref={viewportRef} data-slot="phone-frame-content" className="relative min-h-0 flex-1"> {screenWidth ? ( <div className="absolute top-0 left-0 origin-top-left" style={{ width: screenWidth, height: "calc(100% / var(--screen-scale, 1))", transform: "scale(var(--screen-scale, 1))", }} > {children} </div> ) : ( children )} </div> )} {/* Dynamic Island */} <span aria-hidden="true" data-slot="phone-frame-island" className="pointer-events-none absolute top-[2.6cqw] left-1/2 z-30 flex h-[8.8cqw] w-[30cqw] -translate-x-1/2 items-center justify-end rounded-full bg-black pr-[2.6cqw]" > <span className="size-[3cqw] rounded-full bg-[radial-gradient(circle_at_35%_35%,rgb(90_110_200/0.6),rgb(255_255_255/0.05)_50%,transparent_72%)] ring-[0.3cqw] ring-white/5" /> </span> {homeIndicator && ( <span aria-hidden="true" data-slot="phone-frame-home-indicator" className="pointer-events-none absolute bottom-[2.2cqw] left-1/2 z-30 h-[1.35cqw] w-[36cqw] -translate-x-1/2 rounded-full bg-foreground/85" /> )} {/* Glass reflection */} <span aria-hidden="true" className="pointer-events-none absolute inset-0 z-40 bg-[linear-gradient(120deg,rgb(255_255_255/0.08)_0%,rgb(255_255_255/0.02)_36%,transparent_36.2%)]" /> </div> </div> </div> </div> ) } export { PhoneFrame, type PhoneFrameProps }Update the import paths to match your project setup.
Usage
import { PhoneFrame } from "@/components/ballmac/phone-frame"The full example is in the Code tab above.
Examples
Black, dark screen
Today 9:38
Morning! How did the review go?
Approved. Two nits, both fixed.
Is the new build on TestFlight yet?
Uploading now. Crash fix for the share sheet is in.
Nice. I'll run it on the old iPad too.
Build 214 is live 🎉
Delivered
import { ChevronLeft, Mic, Plus, Video } from "lucide-react"
import { PhoneFrame } from "@/components/ballmac/phone-frame"
const messages = [
{ from: "them", text: "Morning! How did the review go?" },
{ from: "me", text: "Approved. Two nits, both fixed." },
{ from: "them", text: "Is the new build on TestFlight yet?" },
{ from: "me", text: "Uploading now. Crash fix for the share sheet is in." },
{ from: "them", text: "Nice. I'll run it on the old iPad too." },
{ from: "me", text: "Build 214 is live 🎉" },
]
function Chat() {
return (
<div className="flex h-full flex-col text-[15px]">
<div className="flex items-center gap-2 border-b px-3 pb-3">
<ChevronLeft className="size-6 text-chart-1 rtl:rotate-180" aria-hidden="true" />
<span className="flex size-9 items-center justify-center rounded-full bg-[linear-gradient(135deg,var(--chart-2),var(--chart-1))] text-[13px] font-semibold text-white">
RL
</span>
<span className="flex-1">
<span className="block text-[14px] font-semibold">Release team</span>
<span className="block text-[11px] text-muted-foreground">3 members</span>
</span>
<Video className="size-5 text-chart-1" aria-hidden="true" />
</div>
<div className="flex flex-1 flex-col justify-end gap-2 px-3 py-4">
<p className="mb-2 text-center text-[11px] text-muted-foreground">Today 9:38</p>
{messages.map((m, i) => (
<p
key={i}
className={
m.from === "me"
? "max-w-[78%] self-end rounded-[20px] rounded-ee-md bg-chart-1 px-3.5 py-2 text-white"
: "max-w-[78%] self-start rounded-[20px] rounded-es-md bg-muted px-3.5 py-2"
}
>
{m.text}
</p>
))}
<p className="self-end text-[11px] text-muted-foreground">Delivered</p>
</div>
<div className="flex items-center gap-2 px-3 pb-9">
<span className="flex size-8 items-center justify-center rounded-full bg-muted">
<Plus className="size-4" aria-hidden="true" />
</span>
<span className="flex h-9 flex-1 items-center justify-between rounded-full border px-3.5 text-muted-foreground">
Message
<Mic className="size-4" aria-hidden="true" />
</span>
</div>
</div>
)
}
export default function PhoneFrameChat() {
return (
<div className="flex w-full justify-center py-4">
<PhoneFrame variant="black" screenWidth={390} screenClassName="dark" className="w-[248px]">
<Chat />
</PhoneFrame>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
childrenScreen content, laid out below the status bar. Ignored when `src` or `videoSrc` is set. | React.ReactNode | — |
variantFinish of the frame. "auto" is natural titanium in light mode and black titanium in dark mode. | "auto" | "natural" | "black" | "auto" |
srcImage shown on the screen, cropped to fill it (status bar and island stay on top). | string | — |
altAlternative text for `src`. | string | "" |
videoSrcVideo shown on the screen: muted, looping, and paused under reduced motion. | string | — |
posterPoster image for `videoSrc`. | string | — |
screenWidthWidth in CSS pixels that the screen content is laid out at (393 matches a 6.1" phone); it is then scaled to fit. Omit to lay content out at the frame's actual size. | number | — |
statusBarShow the status bar (time, signal, Wi-Fi, battery). | boolean | true |
timeTime shown in the status bar. | string | "9:41" |
homeIndicatorShow the home indicator bar at the bottom of the screen. | boolean | true |
screenClassNameClass names for the screen element (inside the bezel). Use "dark" to give the screen its own theme. | string | — |
Also accepts the standard attributes of its root element.
Use with AI
Wrap a mobile screen in <PhoneFrame screenWidth={390}>: children render below a status bar at 390 CSS px wide and are scaled to the frame's width. Set the frame width with className (e.g. w-64); height follows the phone's aspect ratio. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Phone Frame (@ballmac/phone-frame) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing a mobile app or responsive site on a landing page
- App Store-style feature sections with live React screens
- Pairing with laptop-frame to show desktop and mobile together
Not for
- Desktop apps or websites at desktop width (use laptop-frame or browser-frame)
- Real device screenshots that already include the bezel
Registry JSON: https://ui.ballmac.com/r/phone-frame.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/mediashadcn/utils
Pairs well with
Laptop Frame
A notched aluminium laptop drawn in CSS that scales with its container. Renders children, an image or a video as the screen, and can open its lid when it scrolls into view.
Android Frame
A modern Android phone drawn in CSS: satin frame in porcelain, obsidian or sage, centered punch-hole camera, Material-style status bar and gesture or three-button navigation. Children render at phone resolution, scaled to fit.
Browser Frame
A Safari-style window with traffic lights, a padlocked address field and optional tabs. Children render as the page, either at natural size or laid out at desktop width and scaled to fit.
Tablet Frame
A modern tablet drawn in CSS: brushed aluminum frame, thin glass bezel, front camera, status bar and home indicator. Landscape or portrait with the same proportions; children render as the screen at tablet resolution, scaled to fit.