{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion-android",
  "title": "Android",
  "description": "An unbranded Android phone drawn in SVG and CSS, with a punch-hole camera, thin even bezels and a gesture bar.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "components/core/android.tsx",
      "content": "'use client';\nimport { cn } from '@/lib/utils';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { AnimatePresence, motion } from 'motion/react';\nimport {\n  useEffect,\n  useId,\n  useRef,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactNode,\n} from 'react';\n\nexport type AndroidFinish = 'natural' | 'black' | 'white';\n\nexport type AndroidStatusBar = 'auto' | 'light' | 'dark';\n\nexport type AndroidProps = Omit<ComponentProps<'figure'>, 'children'> & {\n  imageSrc?: string;\n  videoSrc?: string;\n  alt?: string;\n  finish?: AndroidFinish;\n  statusBar?: AndroidStatusBar;\n  time?: string;\n  safeArea?: boolean;\n  label?: string;\n  children?: ReactNode;\n};\n\ntype Palette = {\n  highlight: string;\n  base: string;\n  shade: string;\n  edge: string;\n};\n\ntype SideButton = {\n  id: string;\n  top: number;\n  height: number;\n};\n\n/* Geometry in density-independent pixels of a 6.3 inch phone with a 412 by 915 screen and even bezels. */\nconst DEVICE = { width: 450, height: 947 };\n\nconst FRAME = { x: 3, y: 0, width: 444, height: 947, radius: 62 };\n\nconst GLASS = { x: 9, y: 6, width: 432, height: 935, radius: 56 };\n\nconst SCREEN = { x: 19, y: 16, width: 412, height: 915, radius: 46 };\n\nconst CAMERA = { centerY: 22, diameter: 22, lens: 10 };\n\nconst STATUS_BAR = { height: 44, inset: 26 };\n\nconst SAFE_AREA = { top: 44, bottom: 24 };\n\nconst GESTURE_BAR = { bottom: 9, width: 108, height: 4.5 };\n\nconst BUTTON_DEPTH = 3.5;\n\nconst SIDE_BUTTONS: SideButton[] = [\n  { id: 'power', top: 206, height: 58 },\n  { id: 'volume', top: 296, height: 112 },\n];\n\nconst UNIT = '--android-unit';\n\nconst FINISHES: Record<AndroidFinish, Palette> = {\n  natural: {\n    highlight: 'hsl(210 8% 88%)',\n    base: 'hsl(210 5% 74%)',\n    shade: 'hsl(210 4% 58%)',\n    edge: 'hsl(210 4% 44%)',\n  },\n  black: {\n    highlight: 'hsl(220 5% 34%)',\n    base: 'hsl(220 5% 21%)',\n    shade: 'hsl(220 6% 13%)',\n    edge: 'hsl(220 8% 7%)',\n  },\n  white: {\n    highlight: 'hsl(45 30% 98%)',\n    base: 'hsl(42 18% 90%)',\n    shade: 'hsl(40 9% 77%)',\n    edge: 'hsl(40 6% 63%)',\n  },\n};\n\nconst STATUS_TONE: Record<AndroidStatusBar, string> = {\n  auto: 'text-foreground',\n  light: 'text-white',\n  dark: 'text-black',\n};\n\nconst GLASS_FILL = 'hsl(240 8% 3%)';\n\nconst MEDIA_FADE_SECONDS = 0.24;\n\nconst MEDIA_ENTER = {\n  duration: MEDIA_FADE_SECONDS,\n  ease: [0.23, 1, 0.32, 1],\n} as const;\n\nconst MEDIA_EXIT = { duration: 0, delay: MEDIA_FADE_SECONDS } as const;\n\nconst BATTERY_LEVEL = 0.8;\n\nconst BATTERY_WIDTH = 23;\n\nfunction unit(value: number) {\n  return `calc(var(${UNIT}) * ${value})`;\n}\n\nfunction svgIdOf(reactId: string, part: string) {\n  const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '');\n  return `android-${safeId}-${part}`;\n}\n\nfunction contentStyleOf(safeArea: boolean) {\n  if (safeArea) {\n    return SAFE_AREA_STYLE;\n  }\n  return undefined;\n}\n\nfunction batteryLevelPathOf(level: number) {\n  const right = BATTERY_WIDTH * level;\n  return `M3.5 0H${right}V12H3.5A3.5 3.5 0 0 1 0 8.5V3.5A3.5 3.5 0 0 1 3.5 0Z`;\n}\n\nfunction ignoreRefusedPlayback() {\n  return undefined;\n}\n\nconst ROOT_STYLE = {\n  [UNIT]: `calc(100cqw / ${DEVICE.width})`,\n  aspectRatio: `${DEVICE.width} / ${DEVICE.height}`,\n} as CSSProperties;\n\nconst SHADOW_STYLE: CSSProperties = {\n  left: unit(FRAME.x),\n  top: unit(FRAME.y),\n  width: unit(FRAME.width),\n  height: unit(FRAME.height),\n  borderRadius: unit(FRAME.radius),\n  boxShadow: `0 ${unit(1)} ${unit(2)} rgb(0 0 0 / 0.12), 0 ${unit(28)} ${unit(56)} ${unit(-20)} rgb(0 0 0 / 0.3)`,\n};\n\nconst SCREEN_STYLE: CSSProperties = {\n  left: unit(SCREEN.x),\n  top: unit(SCREEN.y),\n  width: unit(SCREEN.width),\n  height: unit(SCREEN.height),\n  borderRadius: unit(SCREEN.radius),\n  clipPath: `inset(0 round ${unit(SCREEN.radius)})`,\n};\n\nconst STATUS_BAR_STYLE: CSSProperties = {\n  height: unit(STATUS_BAR.height),\n  paddingLeft: unit(STATUS_BAR.inset),\n  paddingRight: unit(STATUS_BAR.inset),\n};\n\nconst CAMERA_STYLE: CSSProperties = {\n  top: unit(CAMERA.centerY - CAMERA.diameter / 2),\n  width: unit(CAMERA.diameter),\n  height: unit(CAMERA.diameter),\n  boxShadow: `0 0 0 ${unit(1)} hsl(230 6% 12%)`,\n};\n\nconst LENS_STYLE: CSSProperties = {\n  width: unit(CAMERA.lens),\n  height: unit(CAMERA.lens),\n  boxShadow: `inset 0 0 0 ${unit(1.5)} hsl(230 12% 15%)`,\n};\n\nconst GESTURE_BAR_STYLE: CSSProperties = {\n  bottom: unit(GESTURE_BAR.bottom),\n  width: unit(GESTURE_BAR.width),\n  height: unit(GESTURE_BAR.height),\n};\n\nconst SAFE_AREA_STYLE: CSSProperties = {\n  paddingTop: unit(SAFE_AREA.top),\n  paddingBottom: unit(SAFE_AREA.bottom),\n};\n\nfunction Hardware({ finish }: { finish: AndroidFinish }) {\n  const reactId = useId();\n  const railId = svgIdOf(reactId, 'rail');\n  const buttonId = svgIdOf(reactId, 'button');\n  const palette = FINISHES[finish];\n  const viewBox = `0 0 ${DEVICE.width} ${DEVICE.height}`;\n  const buttonX = FRAME.x + FRAME.width - 1;\n\n  return (\n    <svg\n      aria-hidden='true'\n      viewBox={viewBox}\n      fill='none'\n      className='absolute inset-0 size-full'\n    >\n      <defs>\n        <linearGradient id={railId} x1='0' y1='0' x2='0.35' y2='1'>\n          <stop offset='0' stopColor={palette.highlight} />\n          <stop offset='0.12' stopColor={palette.base} />\n          <stop offset='0.86' stopColor={palette.base} />\n          <stop offset='1' stopColor={palette.shade} />\n        </linearGradient>\n        <linearGradient id={buttonId} x1='1' y1='0' x2='0' y2='0'>\n          <stop offset='0' stopColor={palette.shade} />\n          <stop offset='0.5' stopColor={palette.highlight} />\n          <stop offset='1' stopColor={palette.base} />\n        </linearGradient>\n      </defs>\n      {SIDE_BUTTONS.map((button) => (\n        <rect\n          key={button.id}\n          x={buttonX}\n          y={button.top}\n          width={BUTTON_DEPTH}\n          height={button.height}\n          rx={1.5}\n          fill={`url(#${buttonId})`}\n          stroke={palette.edge}\n          strokeOpacity={0.45}\n          strokeWidth={0.5}\n        />\n      ))}\n      <rect\n        x={FRAME.x}\n        y={FRAME.y}\n        width={FRAME.width}\n        height={FRAME.height}\n        rx={FRAME.radius}\n        fill={`url(#${railId})`}\n      />\n      <rect\n        x={FRAME.x + 0.25}\n        y={FRAME.y + 0.25}\n        width={FRAME.width - 0.5}\n        height={FRAME.height - 0.5}\n        rx={FRAME.radius - 0.25}\n        stroke={palette.edge}\n        strokeOpacity={0.7}\n        strokeWidth={0.5}\n      />\n      <rect\n        x={FRAME.x + 1.75}\n        y={FRAME.y + 1.75}\n        width={FRAME.width - 3.5}\n        height={FRAME.height - 3.5}\n        rx={FRAME.radius - 1.75}\n        stroke={palette.highlight}\n        strokeOpacity={0.35}\n        strokeWidth={0.75}\n      />\n      <rect\n        x={GLASS.x}\n        y={GLASS.y}\n        width={GLASS.width}\n        height={GLASS.height}\n        rx={GLASS.radius}\n        fill={GLASS_FILL}\n        stroke='black'\n        strokeOpacity={0.6}\n        strokeWidth={0.75}\n      />\n      <rect\n        x={GLASS.x + 1}\n        y={GLASS.y + 1}\n        width={GLASS.width - 2}\n        height={GLASS.height - 2}\n        rx={GLASS.radius - 1}\n        stroke='white'\n        strokeOpacity={0.06}\n        strokeWidth={0.75}\n      />\n    </svg>\n  );\n}\n\nfunction WifiGlyph() {\n  return (\n    <svg\n      aria-hidden='true'\n      viewBox='-0.5 0 17 12.6'\n      fill='currentColor'\n      stroke='currentColor'\n      strokeWidth='1'\n      strokeLinejoin='round'\n      style={{ width: unit(15), height: unit(11.1) }}\n    >\n      <path d='M8 12L0.01 4.01A11.3 11.3 0 0 1 15.99 4.01Z' />\n    </svg>\n  );\n}\n\nfunction CellularGlyph() {\n  return (\n    <svg\n      aria-hidden='true'\n      viewBox='0 0 16 13'\n      fill='currentColor'\n      stroke='currentColor'\n      strokeWidth='1'\n      strokeLinejoin='round'\n      style={{ width: unit(13.5), height: unit(11) }}\n    >\n      <path d='M1 12H15V1Z' />\n    </svg>\n  );\n}\n\nfunction BatteryGlyph() {\n  const levelPath = batteryLevelPathOf(BATTERY_LEVEL);\n\n  return (\n    <svg\n      aria-hidden='true'\n      viewBox='0 0 26 12'\n      fill='currentColor'\n      style={{ width: unit(22), height: unit(10.2) }}\n    >\n      <rect\n        x='0'\n        y='0'\n        width={BATTERY_WIDTH}\n        height='12'\n        rx='3.5'\n        fillOpacity='0.3'\n      />\n      <path d={levelPath} />\n      <rect x='24' y='4' width='2' height='4' rx='1' fillOpacity='0.3' />\n    </svg>\n  );\n}\n\nfunction StatusBar({ time, toneClass }: { time: string; toneClass: string }) {\n  return (\n    <div\n      aria-hidden='true'\n      className={cn(\n        'pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between transition-colors duration-200 select-none',\n        toneClass\n      )}\n      style={STATUS_BAR_STYLE}\n    >\n      <span\n        className='leading-none font-medium tabular-nums'\n        style={{ fontSize: unit(14) }}\n      >\n        {time}\n      </span>\n      <span className='flex items-center' style={{ gap: unit(6) }}>\n        <WifiGlyph />\n        <CellularGlyph />\n        <BatteryGlyph />\n      </span>\n    </div>\n  );\n}\n\nfunction PunchHoleCamera() {\n  return (\n    <div\n      aria-hidden='true'\n      className='pointer-events-none absolute left-1/2 flex -translate-x-1/2 items-center justify-center rounded-full bg-black'\n      style={CAMERA_STYLE}\n    >\n      <span className='rounded-full bg-[hsl(230_18%_8%)]' style={LENS_STYLE} />\n    </div>\n  );\n}\n\nfunction GestureBar({ toneClass }: { toneClass: string }) {\n  return (\n    <div\n      aria-hidden='true'\n      className={cn(\n        'pointer-events-none absolute left-1/2 -translate-x-1/2 rounded-full bg-current opacity-80 transition-colors duration-200',\n        toneClass\n      )}\n      style={GESTURE_BAR_STYLE}\n    />\n  );\n}\n\nfunction ScreenVideo({ src, alt }: { src: string; alt: string }) {\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const isDecorative = alt === '';\n  const showsControls = Boolean(shouldReduceMotion);\n  const isHidden = isDecorative && !showsControls;\n\n  useEffect(() => {\n    const video = videoRef.current;\n    if (!video) {\n      return;\n    }\n    if (shouldReduceMotion) {\n      video.pause();\n      return;\n    }\n    const playback = video.play();\n    playback.catch(ignoreRefusedPlayback);\n  }, [shouldReduceMotion]);\n\n  return (\n    <video\n      ref={videoRef}\n      src={src}\n      muted\n      loop\n      playsInline\n      preload='metadata'\n      controls={showsControls}\n      aria-label={alt || undefined}\n      aria-hidden={isHidden || undefined}\n      className='size-full object-cover'\n    />\n  );\n}\n\nfunction ScreenMedia({\n  imageSrc,\n  videoSrc,\n  alt,\n}: {\n  imageSrc?: string;\n  videoSrc?: string;\n  alt: string;\n}) {\n  const mediaKey = videoSrc ?? imageSrc;\n  const hasVideo = videoSrc !== undefined;\n  const hasImage = imageSrc !== undefined;\n  const showsImage = hasImage && !hasVideo;\n\n  return (\n    <AnimatePresence initial={false}>\n      <motion.div\n        key={mediaKey}\n        className='absolute inset-0'\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1, transition: MEDIA_ENTER }}\n        exit={{ opacity: 0, transition: MEDIA_EXIT }}\n      >\n        {hasVideo && <ScreenVideo src={videoSrc} alt={alt} />}\n        {showsImage && (\n          // eslint-disable-next-line @next/next/no-img-element -- registry items land in any React app, where next/image may not exist\n          <img\n            src={imageSrc}\n            alt={alt}\n            draggable={false}\n            className='size-full object-cover'\n          />\n        )}\n      </motion.div>\n    </AnimatePresence>\n  );\n}\n\nexport function Android({\n  imageSrc,\n  videoSrc,\n  alt = '',\n  finish = 'black',\n  statusBar = 'auto',\n  time = '9:41',\n  safeArea = true,\n  label = 'Phone screen',\n  className,\n  style,\n  children,\n  ...props\n}: AndroidProps) {\n  const hasMedia = Boolean(imageSrc || videoSrc);\n  const hasChildren = children !== undefined && children !== null;\n  const toneClass = STATUS_TONE[statusBar];\n  const contentStyle = contentStyleOf(safeArea);\n\n  return (\n    <figure\n      aria-label={label}\n      className={cn('@container relative w-full max-w-[450px]', className)}\n      style={{ ...ROOT_STYLE, ...style }}\n      {...props}\n    >\n      <div aria-hidden='true' className='absolute' style={SHADOW_STYLE} />\n      <Hardware finish={finish} />\n      <div\n        className='bg-background text-foreground absolute overflow-hidden'\n        style={SCREEN_STYLE}\n      >\n        {hasMedia && (\n          <ScreenMedia imageSrc={imageSrc} videoSrc={videoSrc} alt={alt} />\n        )}\n        {hasChildren && (\n          <div\n            className='absolute inset-0 flex [scrollbar-width:none] flex-col overflow-y-auto overscroll-contain [&::-webkit-scrollbar]:hidden'\n            style={contentStyle}\n          >\n            {children}\n          </div>\n        )}\n        <StatusBar time={time} toneClass={toneClass} />\n        <PunchHoleCamera />\n        <GestureBar toneClass={toneClass} />\n      </div>\n    </figure>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/core/android.tsx"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}