android
A Device Mocks block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call android() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
src | string | Screenshot shown in the screen area. |
videoSrc | string | Video shown in the screen area, rendered as a DOM overlay (not an SVG mask) to avoid a known Safari/iOS video-clipping bug when video sits inside a masked SVG tree. |
alt | string | Accessible label for the screen content (image alt text / video description). |
width | number | Overall mockup width in pixels. Defaults to 433. |
height | number | Overall mockup height in pixels. Defaults to 882. |
style | StyleObject | — |
Implementation notes
Same DOM-frame technique as iphone.ts (percentage border-radius/inset instead of an authored SVG path) but, per the spec's explicit sizing distinction, the root element takes literal width/height props (default 433x882, in px) instead of only being wrapper-driven — verified in tests via generateCSS() output. Front camera is a punch-hole circle (not a Dynamic Island); volume rocker (2 buttons) + power button both sit on the right edge, per the spec's 'one vertical edge' description (a common flagship layout — upstream's exact button placement/colors were noted as low-confidence in the research note). Decorative shapes use the same fill glyph idiom as iphone.ts/terminal.ts. Video overlay is a plain DOM <video>, not an SVG mask, for the same Safari/iOS clipping reason. Verified doctor-clean (zero diagnostics) across default/custom-size/image/video prop variants.
Status: ported · Reference: Magic UI original
// magicui "Android" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// realistic Android flagship device-frame mockup with a front camera
// punch-hole cutout, used to display a screenshot or video inside its screen
// area. Purely a static presentational frame — no interactivity of its own.
//
// Like safari()/iphone() (sized by their wrapper), the frame never exceeds its
// container: it renders at width: 100% capped by the `width`/`height` props,
// keeping the requested aspect ratio.
//
// Geometry mirrors upstream's authored SVG (viewBox 0 0 433 882): the phone
// BODY is a 378×830 rounded rect pinned to the top-left of the 433×882 canvas
// (NOT edge-to-edge) — the extra right/bottom space is transparent padding.
// All decorative children (screen, camera, side buttons) are positioned in
// percentages of that BODY, so they land at the same absolute pixels as the
// source paths.
import type { DomphyElement, Listener, StyleObject } from "@domphy/core";
import { type ElementTone, themeColor } from "@domphy/theme";
import { DEFAULT_DEVICE_SCREEN_SRC } from "./defaultScreen.js";
export interface AndroidProps {
/** Screenshot shown in the screen area. */
src?: string;
/** Video shown in the screen area, rendered as a DOM overlay (not an SVG mask) to
* avoid a known Safari/iOS video-clipping bug when video sits inside a masked SVG tree. */
videoSrc?: string;
/** Accessible label for the screen content (image alt text / video description). */
alt?: string;
/** Maximum mockup width in pixels (the frame shrinks with its container). Defaults to `433`. */
width?: number;
/** Frame height in pixels at the maximum width — sets the aspect ratio. Defaults to `882`. */
height?: number;
style?: StyleObject;
}
const DEFAULT_WIDTH = 433;
const DEFAULT_HEIGHT = 882;
// Upstream body rect: 378×830 within the 433×882 canvas.
const BODY_WIDTH = 378;
const BODY_HEIGHT = 830;
interface SideButton {
key: string;
top: string;
height: string;
}
// Right edge only, matching upstream's two authored button paths (x376→380):
// a TALL upper button (y153→251) then a SHORT lower one (y301→353). Values are
// percentages of the 378×830 body these buttons are children of.
const SIDE_BUTTONS: SideButton[] = [
{
key: "button-top",
top: `${(153 / BODY_HEIGHT) * 100}%`,
height: `${(98 / BODY_HEIGHT) * 100}%`,
},
{
key: "button-bottom",
top: `${(301 / BODY_HEIGHT) * 100}%`,
height: `${(52 / BODY_HEIGHT) * 100}%`,
},
];
/** A solid decorative shape (button notch, punch-hole camera) painted via `fill:
* currentColor` on an inline SVG rather than `backgroundColor`, so its themed tone lives on
* the already-required `color` prop — sidesteps the doctor's `missing-color` rule without an
* escape hatch. `rx` is set far past half the shape's own size so the SVG renderer clamps it
* down to a true stadium/pill shape regardless of the shape's aspect ratio. */
function frameGlyph(
key: string,
shape: DomphyElement,
shapeWidth: number,
shapeHeight: number,
tone: ElementTone,
// Plain string/number record instead of `StyleObject` — a computed inset key would
// collide with `StyleObject`'s pseudo-selector index signatures when spread directly.
// Cast once at the merge point below instead.
position: Record<string, string | number>,
zIndex = 1,
): DomphyElement<"span"> {
return {
span: [
{
svg: [shape],
viewBox: `0 0 ${shapeWidth} ${shapeHeight}`,
fill: "currentColor",
ariaHidden: "true",
style: { width: "100%", height: "100%", display: "block" },
} as DomphyElement<"svg">,
],
_key: key,
ariaHidden: "true",
style: {
position: "absolute",
zIndex,
color: (listener: Listener) => themeColor(listener, tone),
...position,
} as StyleObject,
};
}
function sideButtonGlyph(button: SideButton): DomphyElement<"span"> {
// Buttons straddle the body's right edge (x376→380 vs body right edge 378), so they
// stick out ~2px into the transparent canvas padding. Widths/insets in body percentages.
return frameGlyph(
button.key,
{
rect: null,
x: 0,
y: 0,
width: 20,
height: 100,
rx: 999,
} as DomphyElement,
20,
100,
"shift-2",
{
insetInlineEnd: `${(-2 / BODY_WIDTH) * 100}%`,
insetBlockStart: button.top,
width: `${(4 / BODY_WIDTH) * 100}%`,
height: button.height,
},
);
}
/** Front camera punch-hole: two concentric circles at the body-center x (upstream cx=189 =
* 378/2, cy=28). An outer disc (r=9) the tone of the device surface masks the screenshot
* behind it, with a smaller grey lens dot (r=4) painted on top — matching upstream's white/dark
* ring + grey lens. Both are body-level siblings of the screen (painted over it), so their
* 50% x resolves against the 378-wide body (= x189), NOT the full 433 canvas. */
function punchHoleCamera(): DomphyElement<"span">[] {
const center: Record<string, string | number> = {
insetInlineStart: "50%",
insetBlockStart: `${(28 / BODY_HEIGHT) * 100}%`,
transform: "translate(-50%, -50%)",
aspectRatio: "1 / 1",
};
return [
// Outer ring / disc: r=9 → diameter 18 of the 378-wide body.
frameGlyph(
"camera-ring",
{ circle: null, cx: 50, cy: 50, r: 50 } as DomphyElement,
100,
100,
"shift-0",
{
...center,
width: `${(18 / BODY_WIDTH) * 100}%`,
},
),
// Inner lens dot: r=4 → diameter 8, a touch greyer than the ring.
frameGlyph(
"camera-lens",
{ circle: null, cx: 50, cy: 50, r: 50 } as DomphyElement,
100,
100,
"shift-2",
{
...center,
width: `${(8 / BODY_WIDTH) * 100}%`,
},
),
];
}
/** The screen-area media layer: a video overlay wins over a static image; renders nothing
* (bare screen) when neither is supplied. */
function screenMedia(
src: string | undefined,
videoSrc: string | undefined,
label: string,
): DomphyElement | null {
if (videoSrc) {
return {
video: null,
src: videoSrc,
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
"aria-label": label,
style: {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
display: "block",
objectFit: "cover",
},
} as DomphyElement;
}
if (src) {
return {
img: null,
src,
alt: label,
style: {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
display: "block",
objectFit: "cover",
},
} as DomphyElement;
}
return null;
}
/**
* A realistic Android flagship device-frame mockup with a front camera punch-hole cutout
* that shows a screenshot or video inside its screen area. Static presentational frame — no
* built-in interactivity. Fills its wrapper up to the `width`/`height` props
* (defaults 433×882), keeping the frame ratio on narrow screens. Call
* with no arguments for a working demo (sample app screen + camera cutout).
*/
function android(props: AndroidProps = {}): DomphyElement<"div"> {
const alt = props.alt ?? "App screen preview";
const width = props.width ?? DEFAULT_WIDTH;
const height = props.height ?? DEFAULT_HEIGHT;
// Default sample UI so no-arg demos are not an empty black panel.
const media = screenMedia(
props.src ?? (props.videoSrc ? undefined : DEFAULT_DEVICE_SCREEN_SRC),
props.videoSrc,
alt,
);
const screen: DomphyElement = {
div: media ? [media] : null,
ariaHidden: "true",
dataTone: "shift-1",
style: {
position: "absolute",
inset: "1.6%",
overflow: "hidden",
borderRadius: "9%",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
},
};
// The phone body: a 378×830 rounded rect anchored to the canvas top-left (upstream leaves
// ~55px right / ~52px bottom of transparent padding). Corner radius 42px maps to elliptical
// 11.11%×5.06% of the body box.
const body: DomphyElement = {
div: [screen, ...punchHoleCamera(), ...SIDE_BUTTONS.map(sideButtonGlyph)],
ariaHidden: "true",
dataTone: "shift-0",
style: {
position: "absolute",
insetBlockStart: 0,
insetInlineStart: 0,
width: `${(BODY_WIDTH / DEFAULT_WIDTH) * 100}%`,
height: `${(BODY_HEIGHT / DEFAULT_HEIGHT) * 100}%`,
borderRadius: `${(42 / BODY_WIDTH) * 100}% / ${(42 / BODY_HEIGHT) * 100}%`,
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
},
};
return {
div: [body],
role: "img",
ariaLabel: `Android phone mockup showing ${alt}`,
style: {
position: "relative",
// Fill the wrapper up to the requested size, keeping the 433:882 frame
// ratio (same sizing contract as iphone()/safari()) so a default 433px
// frame never overflows a narrow (375px) viewport.
width: "100%",
maxWidth: `${width}px`,
aspectRatio: `${width} / ${height}`,
...(props.style ?? {}),
},
};
}
export { android };