{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion-iphone",
  "title": "iPhone",
  "description": "A modern iPhone drawn in SVG and CSS, with a titanium frame, a Dynamic Island and a status bar that follows the screen.",
  "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/iphone.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 IphoneFinish = 'natural' | 'black' | 'white';\n\nexport type IphoneStatusBar = 'auto' | 'light' | 'dark';\n\nexport type IphoneProps = Omit<ComponentProps<'figure'>, 'children'> & {\n  imageSrc?: string;\n  videoSrc?: string;\n  alt?: string;\n  finish?: IphoneFinish;\n  statusBar?: IphoneStatusBar;\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  side: 'left' | 'right';\n  top: number;\n  height: number;\n};\n\n/* Geometry in points of a 6.3 inch iPhone with a Dynamic Island: a 402 by 874 screen. */\nconst DEVICE = { width: 438, height: 904 };\n\nconst FRAME = { x: 3, y: 0, width: 432, height: 904, radius: 72 };\n\nconst GLASS = { x: 10, y: 7, width: 418, height: 890, radius: 65 };\n\nconst SCREEN = { x: 18, y: 15, width: 402, height: 874, radius: 57 };\n\nconst ISLAND = { top: 11, width: 124, height: 36, lens: 11 };\n\nconst SAFE_AREA = { top: 58, bottom: 34 };\n\nconst HOME_INDICATOR = { bottom: 8, width: 138, height: 5 };\n\nconst BUTTON_DEPTH = 4;\n\nconst SIDE_BUTTONS: SideButton[] = [\n  { id: 'action', side: 'left', top: 172, height: 36 },\n  { id: 'volume-up', side: 'left', top: 250, height: 62 },\n  { id: 'volume-down', side: 'left', top: 326, height: 62 },\n  { id: 'side', side: 'right', top: 254, height: 100 },\n];\n\nconst ANTENNA_LINES = [96, 808];\n\nconst UNIT = '--iphone-unit';\n\nconst FINISHES: Record<IphoneFinish, Palette> = {\n  natural: {\n    highlight: 'hsl(40 10% 86%)',\n    base: 'hsl(36 6% 67%)',\n    shade: 'hsl(32 5% 47%)',\n    edge: 'hsl(30 5% 35%)',\n  },\n  black: {\n    highlight: 'hsl(220 4% 46%)',\n    base: 'hsl(220 4% 27%)',\n    shade: 'hsl(220 5% 16%)',\n    edge: 'hsl(220 6% 9%)',\n  },\n  white: {\n    highlight: 'hsl(40 16% 98%)',\n    base: 'hsl(40 9% 89%)',\n    shade: 'hsl(36 5% 73%)',\n    edge: 'hsl(36 4% 60%)',\n  },\n};\n\nconst STATUS_TONE: Record<IphoneStatusBar, 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\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 `iphone-${safeId}-${part}`;\n}\n\nfunction buttonXOf(button: SideButton) {\n  if (button.side === 'left') {\n    return FRAME.x - BUTTON_DEPTH + 1;\n  }\n  return FRAME.x + FRAME.width - 1;\n}\n\nfunction buttonGradientOf(\n  button: SideButton,\n  leftGradientId: string,\n  rightGradientId: string\n) {\n  if (button.side === 'left') {\n    return leftGradientId;\n  }\n  return rightGradientId;\n}\n\nfunction contentStyleOf(safeArea: boolean) {\n  if (safeArea) {\n    return SAFE_AREA_STYLE;\n  }\n  return undefined;\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  top: unit(ISLAND.top),\n  height: unit(ISLAND.height),\n};\n\nconst STATUS_ZONE_WIDTH = (SCREEN.width - ISLAND.width) / 2;\n\nconst ISLAND_STYLE: CSSProperties = {\n  top: unit(ISLAND.top),\n  width: unit(ISLAND.width),\n  height: unit(ISLAND.height),\n};\n\nconst LENS_STYLE: CSSProperties = {\n  right: unit(ISLAND.height / 2 - ISLAND.lens / 2),\n  width: unit(ISLAND.lens),\n  height: unit(ISLAND.lens),\n  boxShadow: `inset 0 0 0 ${unit(1.5)} hsl(230 12% 14%)`,\n};\n\nconst HOME_INDICATOR_STYLE: CSSProperties = {\n  bottom: unit(HOME_INDICATOR.bottom),\n  width: unit(HOME_INDICATOR.width),\n  height: unit(HOME_INDICATOR.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: IphoneFinish }) {\n  const reactId = useId();\n  const rimId = svgIdOf(reactId, 'rim');\n  const leftButtonId = svgIdOf(reactId, 'left-button');\n  const rightButtonId = svgIdOf(reactId, 'right-button');\n  const palette = FINISHES[finish];\n  const viewBox = `0 0 ${DEVICE.width} ${DEVICE.height}`;\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={rimId} x1='0' y1='0' x2='1' y2='1'>\n          <stop offset='0' stopColor={palette.highlight} />\n          <stop offset='0.14' stopColor={palette.base} />\n          <stop offset='0.4' stopColor={palette.shade} />\n          <stop offset='0.55' stopColor={palette.base} />\n          <stop offset='0.78' stopColor={palette.highlight} />\n          <stop offset='1' stopColor={palette.shade} />\n        </linearGradient>\n        <linearGradient id={leftButtonId} x1='0' y1='0' x2='1' y2='0'>\n          <stop offset='0' stopColor={palette.shade} />\n          <stop offset='0.45' stopColor={palette.highlight} />\n          <stop offset='1' stopColor={palette.base} />\n        </linearGradient>\n        <linearGradient id={rightButtonId} x1='1' y1='0' x2='0' y2='0'>\n          <stop offset='0' stopColor={palette.shade} />\n          <stop offset='0.45' stopColor={palette.highlight} />\n          <stop offset='1' stopColor={palette.base} />\n        </linearGradient>\n      </defs>\n      {SIDE_BUTTONS.map((button) => {\n        const gradientId = buttonGradientOf(\n          button,\n          leftButtonId,\n          rightButtonId\n        );\n\n        return (\n          <rect\n            key={button.id}\n            x={buttonXOf(button)}\n            y={button.top}\n            width={BUTTON_DEPTH}\n            height={button.height}\n            rx={1.75}\n            fill={`url(#${gradientId})`}\n            stroke={palette.edge}\n            strokeOpacity={0.45}\n            strokeWidth={0.5}\n          />\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(#${rimId})`}\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 + 2}\n        y={FRAME.y + 2}\n        width={FRAME.width - 4}\n        height={FRAME.height - 4}\n        rx={FRAME.radius - 2}\n        stroke={palette.highlight}\n        strokeOpacity={0.55}\n        strokeWidth={0.75}\n      />\n      {ANTENNA_LINES.map((top) => (\n        <g key={top} fill={palette.shade} fillOpacity={0.55}>\n          <rect x={FRAME.x} y={top} width={GLASS.x - FRAME.x} height={2.5} />\n          <rect\n            x={GLASS.x + GLASS.width}\n            y={top}\n            width={GLASS.x - FRAME.x}\n            height={2.5}\n          />\n        </g>\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.07}\n        strokeWidth={0.75}\n      />\n    </svg>\n  );\n}\n\nfunction CellularGlyph() {\n  return (\n    <svg\n      aria-hidden='true'\n      viewBox='0 0 17 11'\n      fill='currentColor'\n      style={{ width: unit(17), height: unit(11) }}\n    >\n      <rect x='0' y='7' width='3' height='4' rx='0.9' />\n      <rect x='4.6' y='4.7' width='3' height='6.3' rx='0.9' />\n      <rect x='9.2' y='2.4' width='3' height='8.6' rx='0.9' />\n      <rect x='13.8' y='0' width='3' height='11' rx='0.9' />\n    </svg>\n  );\n}\n\nfunction WifiGlyph() {\n  return (\n    <svg\n      aria-hidden='true'\n      viewBox='0 0 15.5 11'\n      fill='currentColor'\n      stroke='currentColor'\n      strokeWidth='0.5'\n      strokeLinejoin='round'\n      style={{ width: unit(15.5), height: unit(11) }}\n    >\n      <path d='M0.962 3.612A9.6 9.6 0 0 1 14.538 3.612L12.629 5.521A6.9 6.9 0 0 0 2.871 5.521Z' />\n      <path d='M3.79 6.44A5.6 5.6 0 0 1 11.71 6.44L10.013 8.137A3.2 3.2 0 0 0 5.487 8.137Z' />\n      <path d='M7.75 10.4L6.406 9.056A1.9 1.9 0 0 1 9.094 9.056Z' />\n    </svg>\n  );\n}\n\nfunction BatteryGlyph() {\n  const levelWidth = 18.5 * BATTERY_LEVEL;\n\n  return (\n    <svg\n      aria-hidden='true'\n      viewBox='0 0 25 12'\n      fill='none'\n      style={{ width: unit(25), height: unit(12) }}\n    >\n      <rect\n        x='0.5'\n        y='0.5'\n        width='21.5'\n        height='11'\n        rx='3.4'\n        stroke='currentColor'\n        strokeOpacity='0.38'\n      />\n      <rect\n        x='2'\n        y='2'\n        width={levelWidth}\n        height='8'\n        rx='2'\n        fill='currentColor'\n      />\n      <path\n        d='M23.2 4.1V7.9C24 7.6 24.5 6.85 24.5 6S24 4.4 23.2 4.1Z'\n        fill='currentColor'\n        fillOpacity='0.4'\n      />\n    </svg>\n  );\n}\n\nfunction StatusBar({ time, toneClass }: { time: string; toneClass: string }) {\n  const zoneStyle = { width: unit(STATUS_ZONE_WIDTH) };\n\n  return (\n    <div\n      aria-hidden='true'\n      className={cn(\n        'pointer-events-none absolute inset-x-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='flex justify-center'\n        style={{ ...zoneStyle, paddingRight: unit(8) }}\n      >\n        <span\n          className='leading-none font-semibold tabular-nums'\n          style={{ fontSize: unit(17) }}\n        >\n          {time}\n        </span>\n      </span>\n      <span\n        className='flex items-center justify-center'\n        style={{ ...zoneStyle, paddingLeft: unit(8), gap: unit(5) }}\n      >\n        <CellularGlyph />\n        <WifiGlyph />\n        <BatteryGlyph />\n      </span>\n    </div>\n  );\n}\n\nfunction DynamicIsland() {\n  return (\n    <div\n      aria-hidden='true'\n      className='pointer-events-none absolute left-1/2 flex -translate-x-1/2 items-center rounded-full bg-black'\n      style={ISLAND_STYLE}\n    >\n      <span\n        className='absolute top-1/2 -translate-y-1/2 rounded-full bg-[hsl(230_18%_8%)]'\n        style={LENS_STYLE}\n      />\n    </div>\n  );\n}\n\nfunction HomeIndicator({ 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-90 transition-colors duration-200',\n        toneClass\n      )}\n      style={HOME_INDICATOR_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 Iphone({\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}: IphoneProps) {\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-[438px]', 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        <DynamicIsland />\n        <HomeIndicator toneClass={toneClass} />\n      </div>\n    </figure>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/core/iphone.tsx"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}