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.
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.
9:41
Thu, Oct 1 · 18°C
Design review
10:30 · Room 4B
import { Calendar, Camera, Globe, Mail, Map, MessageCircle, Mic, Music, Phone, Search, Settings } from "lucide-react"
import { AndroidFrame } from "@/components/ballmac/android-frame"
const apps = [
{ icon: Phone, name: "Phone", tone: "bg-chart-2" },
{ icon: MessageCircle, name: "Messages", tone: "bg-chart-1" },
{ icon: Globe, name: "Browser", tone: "bg-chart-5" },
{ icon: Camera, name: "Camera", tone: "bg-chart-4" },
{ icon: Mail, name: "Gmail", tone: "bg-destructive" },
{ icon: Map, name: "Maps", tone: "bg-chart-2" },
{ icon: Music, name: "Music", tone: "bg-chart-3" },
{ icon: Settings, name: "Settings", tone: "bg-neutral-600" },
]
function Home() {
return (
<div className="relative flex h-full flex-col bg-[linear-gradient(170deg,oklch(0.38_0.13_265),oklch(0.3_0.1_300)_55%,oklch(0.34_0.1_20))] px-5 pb-16 text-white">
<div className="mt-6 text-center">
<p className="text-[70px] leading-none font-light tracking-tight tabular-nums">9:41</p>
<p className="mt-1 text-[16px] font-medium text-white/90">Thu, Oct 1 · 18°C</p>
</div>
<div className="mt-5 flex items-center gap-3 rounded-full bg-white/15 px-5 py-3.5 backdrop-blur-xl">
<Search className="size-5" aria-hidden="true" />
<span className="flex-1 text-[15px] text-white/90">Search apps and the web</span>
<Mic className="size-5" aria-hidden="true" />
</div>
<div className="mt-6 flex items-center gap-3 rounded-3xl bg-white/15 p-4 backdrop-blur-xl">
<Calendar className="size-6 shrink-0" aria-hidden="true" />
<div className="min-w-0 leading-tight">
<p className="truncate text-[15px] font-semibold">Design review</p>
<p className="text-[13px] text-white/85">10:30 · Room 4B</p>
</div>
</div>
<div className="mt-auto grid grid-cols-4 gap-y-5">
{apps.map((a) => (
<div key={a.name} className="flex flex-col items-center gap-1.5">
<span className={`flex size-14 items-center justify-center rounded-[18px] text-white shadow-lg ${a.tone}`}>
<a.icon className="size-6" aria-hidden="true" />
</span>
<span className="text-[12px] font-medium">{a.name}</span>
</div>
))}
</div>
</div>
)
}
export default function AndroidFrameDemo() {
return (
<div className="flex w-full justify-center px-2 py-6">
<AndroidFrame screenWidth={412} screenClassName="dark" className="max-w-[300px]">
<Home />
</AndroidFrame>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/android-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/android-frame.tsx// Ballmac UI: Android Frame. https://ui.ballmac.com/components/android-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 AndroidFrameProps = 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 porcelain in light mode and obsidian in dark mode. */ variant?: "auto" | "porcelain" | "obsidian" | "sage" /** Image shown on the screen, cropped to fill it. */ 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 (412 matches a 6.3" 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 /** System navigation: the gesture pill, three buttons, or nothing. */ navigation?: "gesture" | "buttons" | "none" /** Class names for the screen element (inside the bezel). Use "dark" to give the screen its own theme. */ screenClassName?: string } const PORCELAIN = "[--af-hi:color-mix(in_oklch,white_95%,black)] [--af-mid:color-mix(in_oklch,white_80%,black)] [--af-lo:color-mix(in_oklch,white_62%,black)]" const OBSIDIAN = "[--af-hi:color-mix(in_oklch,white_34%,black)] [--af-mid:color-mix(in_oklch,white_17%,black)] [--af-lo:color-mix(in_oklch,white_8%,black)]" const OBSIDIAN_DARK = "dark:[--af-hi:color-mix(in_oklch,white_34%,black)] dark:[--af-mid:color-mix(in_oklch,white_17%,black)] dark:[--af-lo:color-mix(in_oklch,white_8%,black)]" const SAGE = "[--af-hi:color-mix(in_oklch,var(--chart-2)_30%,white)] [--af-mid:color-mix(in_oklch,var(--chart-2)_45%,white)] [--af-lo:color-mix(in_oklch,var(--chart-2)_62%,black)]" 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 } /** * A modern Android phone drawn in CSS: flat satin frame, thin even bezels, centered punch-hole camera, Material-style * status bar and a gesture or button navigation bar. Children render as the screen, laid out at a phone resolution and scaled to fit. */ function AndroidFrame({ variant = "auto", src, alt = "", videoSrc, poster, screenWidth, statusBar = true, time = "9:41", navigation = "gesture", screenClassName, className, children, ...props }: AndroidFrameProps) { const media = !!(src || videoSrc) const viewportRef = useScreenScale(media ? undefined : screenWidth) const reduceMotion = !!useReducedMotion() return ( <div data-slot="android-frame" data-variant={variant} className={cn( "@container relative aspect-[72/152.8] w-full max-w-[320px] select-none", variant === "porcelain" && PORCELAIN, variant === "obsidian" && OBSIDIAN, variant === "sage" && SAGE, variant === "auto" && cn(PORCELAIN, OBSIDIAN_DARK), className )} {...props} > {/* Power and volume on the right edge. */} {["right-[-0.9cqw] top-[21%] h-[7.5%] rounded-r-[0.6cqw]", "right-[-0.9cqw] top-[31.5%] h-[13%] rounded-r-[0.6cqw]"].map((position) => ( <span key={position} aria-hidden="true" className={cn("absolute w-[1.4cqw] bg-[linear-gradient(90deg,var(--af-lo),var(--af-hi)_55%,var(--af-lo))]", position)} /> ))} <div data-slot="android-frame-body" className="absolute inset-0 rounded-[13.5cqw] bg-[linear-gradient(135deg,var(--af-hi),var(--af-mid)_34%,var(--af-lo)_60%,var(--af-mid)_84%,var(--af-hi))] p-[1cqw] shadow-[inset_0_0_0_0.25cqw_rgb(255_255_255/0.14),0_2cqw_6cqw_-1cqw_rgb(0_0_0/0.35),0_0.5cqw_1.2cqw_rgb(0_0_0/0.18)]" > <div className="size-full rounded-[12.5cqw] bg-black p-[2.3cqw] shadow-[inset_0_0_0_0.3cqw_rgb(255_255_255/0.06)]"> <div data-slot="android-frame-screen" className={cn( "relative isolate flex size-full flex-col overflow-hidden rounded-[10.3cqw] bg-background text-foreground", screenClassName )} > {media ? ( videoSrc ? ( <video data-slot="android-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="android-frame-image" media={src} alt={alt} fill className="absolute inset-0" /> ) ) : null} {statusBar && ( <div data-slot="android-frame-status-bar" className="relative z-20 flex h-[9.4cqw] shrink-0 items-center justify-between px-[7cqw] pt-[0.6cqw] text-[3.6cqw] font-medium text-foreground" > <span className="tabular-nums">{time}</span> <span aria-hidden="true" className="flex items-center gap-[1.5cqw]"> <svg viewBox="0 0 16 16" className="h-[3.4cqw]" fill="currentColor"> <path d="M8 13.5 15.5 3A12.6 12.6 0 0 0 8 .6 12.6 12.6 0 0 0 .5 3L8 13.5Z" /> </svg> <svg viewBox="0 0 16 16" className="h-[3.4cqw]" fill="currentColor"> <path d="M16 16V0L0 16h16Z" /> </svg> <svg viewBox="0 0 10 16" className="h-[3.6cqw]" fill="currentColor"> <path d="M3 0h4v1.5h1.2c.5 0 .8.4.8.8v12.9c0 .4-.3.8-.8.8H1.8a.8.8 0 0 1-.8-.8V2.3c0-.4.3-.8.8-.8H3V0Z" fillOpacity="0.35" /> <path d="M1 7h8v8.2c0 .4-.3.8-.8.8H1.8a.8.8 0 0 1-.8-.8V7Z" /> </svg> </span> </div> )} {!media && ( <div ref={viewportRef} data-slot="android-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> )} {/* Punch-hole camera */} <span aria-hidden="true" data-slot="android-frame-camera" className="pointer-events-none absolute top-[3.4cqw] left-1/2 z-30 size-[3.4cqw] -translate-x-1/2 rounded-full bg-black ring-[0.35cqw] ring-white/5" > <span className="absolute inset-[0.8cqw] rounded-full bg-[radial-gradient(circle_at_35%_35%,rgb(90_110_200/0.5),transparent_70%)]" /> </span> {navigation === "gesture" && ( <span aria-hidden="true" data-slot="android-frame-gesture" className="pointer-events-none absolute bottom-[2cqw] left-1/2 z-30 h-[1.1cqw] w-[28cqw] -translate-x-1/2 rounded-full bg-foreground/80" /> )} {navigation === "buttons" && ( <span aria-hidden="true" data-slot="android-frame-buttons" className="pointer-events-none relative z-30 flex h-[11cqw] shrink-0 items-center justify-center gap-[17cqw] bg-background/80 text-foreground" > <svg viewBox="0 0 12 12" className="h-[3.4cqw]" fill="currentColor"><path d="M10 .5v11L1.5 6 10 .5Z" /></svg> <span className="size-[3.6cqw] rounded-full border-[0.5cqw] border-current" /> <span className="size-[3.4cqw] rounded-[0.9cqw] border-[0.5cqw] border-current" /> </span> )} <span aria-hidden="true" className="pointer-events-none absolute inset-0 z-40 bg-[linear-gradient(120deg,rgb(255_255_255/0.07)_0%,rgb(255_255_255/0.015)_36%,transparent_36.2%)]" /> </div> </div> </div> </div> ) } export { AndroidFrame, type AndroidFrameProps }Update the import paths to match your project setup.
Usage
import { AndroidFrame } from "@/components/ballmac/android-frame"The full example is in the Code tab above.
Examples
Three-button navigation
Settings
- Network & internetWi-Fi, mobile, data usage
- Connected devicesBluetooth, pairing
- Battery82% · about 1 day left
- DisplayDark theme, font size
import { BatteryMedium, Bluetooth, ChevronRight, Moon, Wifi } from "lucide-react"
import { AndroidFrame } from "@/components/ballmac/android-frame"
const rows = [
{ icon: Wifi, name: "Network & internet", detail: "Wi-Fi, mobile, data usage", tone: "bg-chart-1" },
{ icon: Bluetooth, name: "Connected devices", detail: "Bluetooth, pairing", tone: "bg-chart-4" },
{ icon: BatteryMedium, name: "Battery", detail: "82% · about 1 day left", tone: "bg-chart-2" },
{ icon: Moon, name: "Display", detail: "Dark theme, font size", tone: "bg-chart-5" },
]
function Settings() {
return (
<div className="flex h-full flex-col px-5 pt-5">
<p className="text-[34px] leading-tight font-normal tracking-tight">Settings</p>
<div className="mt-4 rounded-full bg-muted px-5 py-3 text-[15px] text-muted-foreground">Search settings</div>
<ul className="mt-4 flex flex-col gap-0.5">
{rows.map((r) => (
<li key={r.name} className="flex items-center gap-4 rounded-2xl px-2 py-3">
<span className={`flex size-11 shrink-0 items-center justify-center rounded-full text-white ${r.tone}`}>
<r.icon className="size-5" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1 leading-tight">
<span className="block truncate text-[16px] font-medium">{r.name}</span>
<span className="block truncate text-[13px] text-muted-foreground">{r.detail}</span>
</span>
<ChevronRight className="size-4 text-muted-foreground rtl:rotate-180" aria-hidden="true" />
</li>
))}
</ul>
</div>
)
}
export default function AndroidFrameButtons() {
return (
<div className="flex w-full justify-center px-2 py-6">
<AndroidFrame screenWidth={412} variant="sage" navigation="buttons" className="max-w-[300px]">
<Settings />
</AndroidFrame>
</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 porcelain in light mode and obsidian in dark mode. | "auto" | "porcelain" | "obsidian" | "sage" | "auto" |
srcImage shown on the screen, cropped to fill it. | 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 (412 matches a 6.3" 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" |
navigationSystem navigation: the gesture pill, three buttons, or nothing. | "gesture" | "buttons" | "none" | "gesture" |
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.
Accessibility
| Key | Action |
|---|---|
| Static | Purely visual: no focusable elements from the frame |
| Reduced motion | Video on the screen is not autoplayed |
Use with AI
Wrap a mobile screen in <AndroidFrame screenWidth={412}>: children render below a status bar at 412 CSS px wide and are scaled to the frame's width. Set the width with className (e.g. w-64). With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Android Frame (@ballmac/android-frame) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing an Android or responsive mobile app
- Side by side with phone-frame for cross-platform stories
Not for
- iPhone mockups (phone-frame)
- Real device screenshots with a bezel
Registry JSON: https://ui.ballmac.com/r/android-frame.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/mediashadcn/utils
Pairs well with
Phone Frame
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.
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.
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.
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.