{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "android-frame",
  "title": "Android Frame",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "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.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/media.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/android-frame.tsx",
      "content": "// Ballmac UI: Android Frame. https://ui.ballmac.com/components/android-frame\n\"use client\"\n\nimport * as React from \"react\"\nimport { useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Media } from \"@/components/ballmac/media\"\n\ntype AndroidFrameProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Screen content, laid out below the status bar. Ignored when `src` or `videoSrc` is set. */\n  children?: React.ReactNode\n  /** Finish of the frame. \"auto\" is porcelain in light mode and obsidian in dark mode. */\n  variant?: \"auto\" | \"porcelain\" | \"obsidian\" | \"sage\"\n  /** Image shown on the screen, cropped to fill it. */\n  src?: string\n  /** Alternative text for `src`. */\n  alt?: string\n  /** Video shown on the screen: muted, looping, and paused under reduced motion. */\n  videoSrc?: string\n  /** Poster image for `videoSrc`. */\n  poster?: string\n  /**\n   * Width in CSS pixels that the screen content is laid out at (412 matches a 6.3\" phone);\n   * it is then scaled to fit. Omit to lay content out at the frame's actual size.\n   */\n  screenWidth?: number\n  /** Show the status bar (time, signal, Wi-Fi, battery). */\n  statusBar?: boolean\n  /** Time shown in the status bar. */\n  time?: string\n  /** System navigation: the gesture pill, three buttons, or nothing. */\n  navigation?: \"gesture\" | \"buttons\" | \"none\"\n  /** Class names for the screen element (inside the bezel). Use \"dark\" to give the screen its own theme. */\n  screenClassName?: string\n}\n\nconst PORCELAIN =\n  \"[--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)]\"\nconst OBSIDIAN =\n  \"[--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)]\"\nconst OBSIDIAN_DARK =\n  \"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)]\"\nconst SAGE =\n  \"[--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)]\"\n\nfunction useScreenScale(screenWidth: number | undefined) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  React.useEffect(() => {\n    const screen = ref.current\n    if (!screen || !screenWidth) return\n    const measure = () => screen.style.setProperty(\"--screen-scale\", String(screen.clientWidth / screenWidth))\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(screen)\n    return () => observer.disconnect()\n  }, [screenWidth])\n  return ref\n}\n\n/**\n * A modern Android phone drawn in CSS: flat satin frame, thin even bezels, centered punch-hole camera, Material-style\n * status bar and a gesture or button navigation bar. Children render as the screen, laid out at a phone resolution and scaled to fit.\n */\nfunction AndroidFrame({\n  variant = \"auto\",\n  src,\n  alt = \"\",\n  videoSrc,\n  poster,\n  screenWidth,\n  statusBar = true,\n  time = \"9:41\",\n  navigation = \"gesture\",\n  screenClassName,\n  className,\n  children,\n  ...props\n}: AndroidFrameProps) {\n  const media = !!(src || videoSrc)\n  const viewportRef = useScreenScale(media ? undefined : screenWidth)\n  const reduceMotion = !!useReducedMotion()\n\n  return (\n    <div\n      data-slot=\"android-frame\"\n      data-variant={variant}\n      className={cn(\n        \"@container relative aspect-[72/152.8] w-full max-w-[320px] select-none\",\n        variant === \"porcelain\" && PORCELAIN,\n        variant === \"obsidian\" && OBSIDIAN,\n        variant === \"sage\" && SAGE,\n        variant === \"auto\" && cn(PORCELAIN, OBSIDIAN_DARK),\n        className\n      )}\n      {...props}\n    >\n      {/* Power and volume on the right edge. */}\n      {[\"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) => (\n        <span\n          key={position}\n          aria-hidden=\"true\"\n          className={cn(\"absolute w-[1.4cqw] bg-[linear-gradient(90deg,var(--af-lo),var(--af-hi)_55%,var(--af-lo))]\", position)}\n        />\n      ))}\n\n      <div\n        data-slot=\"android-frame-body\"\n        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)]\"\n      >\n        <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)]\">\n          <div\n            data-slot=\"android-frame-screen\"\n            className={cn(\n              \"relative isolate flex size-full flex-col overflow-hidden rounded-[10.3cqw] bg-background text-foreground\",\n              screenClassName\n            )}\n          >\n            {media ? (\n              videoSrc ? (\n                <video\n                  data-slot=\"android-frame-video\"\n                  className=\"absolute inset-0 size-full object-cover\"\n                  src={videoSrc}\n                  poster={poster}\n                  autoPlay={!reduceMotion}\n                  muted\n                  loop\n                  playsInline\n                />\n              ) : (\n                // eslint-disable-next-line @next/next/no-img-element\n                <Media data-slot=\"android-frame-image\" media={src} alt={alt} fill className=\"absolute inset-0\" />\n              )\n            ) : null}\n            {statusBar && (\n              <div\n                data-slot=\"android-frame-status-bar\"\n                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\"\n              >\n                <span className=\"tabular-nums\">{time}</span>\n                <span aria-hidden=\"true\" className=\"flex items-center gap-[1.5cqw]\">\n                  <svg viewBox=\"0 0 16 16\" className=\"h-[3.4cqw]\" fill=\"currentColor\">\n                    <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\" />\n                  </svg>\n                  <svg viewBox=\"0 0 16 16\" className=\"h-[3.4cqw]\" fill=\"currentColor\">\n                    <path d=\"M16 16V0L0 16h16Z\" />\n                  </svg>\n                  <svg viewBox=\"0 0 10 16\" className=\"h-[3.6cqw]\" fill=\"currentColor\">\n                    <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\" />\n                    <path d=\"M1 7h8v8.2c0 .4-.3.8-.8.8H1.8a.8.8 0 0 1-.8-.8V7Z\" />\n                  </svg>\n                </span>\n              </div>\n            )}\n            {!media && (\n              <div ref={viewportRef} data-slot=\"android-frame-content\" className=\"relative min-h-0 flex-1\">\n                {screenWidth ? (\n                  <div\n                    className=\"absolute top-0 left-0 origin-top-left\"\n                    style={{ width: screenWidth, height: \"calc(100% / var(--screen-scale, 1))\", transform: \"scale(var(--screen-scale, 1))\" }}\n                  >\n                    {children}\n                  </div>\n                ) : (\n                  children\n                )}\n              </div>\n            )}\n            {/* Punch-hole camera */}\n            <span\n              aria-hidden=\"true\"\n              data-slot=\"android-frame-camera\"\n              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\"\n            >\n              <span className=\"absolute inset-[0.8cqw] rounded-full bg-[radial-gradient(circle_at_35%_35%,rgb(90_110_200/0.5),transparent_70%)]\" />\n            </span>\n            {navigation === \"gesture\" && (\n              <span\n                aria-hidden=\"true\"\n                data-slot=\"android-frame-gesture\"\n                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\"\n              />\n            )}\n            {navigation === \"buttons\" && (\n              <span\n                aria-hidden=\"true\"\n                data-slot=\"android-frame-buttons\"\n                className=\"pointer-events-none relative z-30 flex h-[11cqw] shrink-0 items-center justify-center gap-[17cqw] bg-background/80 text-foreground\"\n              >\n                <svg viewBox=\"0 0 12 12\" className=\"h-[3.4cqw]\" fill=\"currentColor\"><path d=\"M10 .5v11L1.5 6 10 .5Z\" /></svg>\n                <span className=\"size-[3.6cqw] rounded-full border-[0.5cqw] border-current\" />\n                <span className=\"size-[3.4cqw] rounded-[0.9cqw] border-[0.5cqw] border-current\" />\n              </span>\n            )}\n            <span\n              aria-hidden=\"true\"\n              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%)]\"\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport { AndroidFrame, type AndroidFrameProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/android-frame.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "mockup",
      "device",
      "phone",
      "android",
      "pixel",
      "mobile",
      "hero"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "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).",
      "whenToUse": [
        "Showing an Android or responsive mobile app",
        "Side by side with phone-frame for cross-platform stories"
      ],
      "whenNotToUse": [
        "iPhone mockups (phone-frame)",
        "Real device screenshots with a bezel"
      ],
      "composesWith": [
        "phone-frame",
        "tablet-frame",
        "laptop-frame"
      ],
      "a11y": [
        {
          "keys": "Static",
          "action": "Purely visual: no focusable elements from the frame"
        },
        {
          "keys": "Reduced motion",
          "action": "Video on the screen is not autoplayed"
        }
      ],
      "customization": [
        "variant: auto | porcelain | obsidian | sage",
        "navigation: gesture | buttons | none",
        "screenWidth: virtual width in CSS px",
        "statusBar, time",
        "src/alt or videoSrc/poster instead of children"
      ]
    },
    "examples": [
      "android-frame-demo",
      "android-frame-buttons"
    ],
    "url": "https://ui.ballmac.com/components/android-frame"
  },
  "categories": [
    "devices"
  ],
  "type": "registry:ui"
}