# Phone Frame (Moonarc)

A generic phone frame around a screenshot or any content, with three cut-outs (island, notch, punch), a home bar, and no logo, buttons or brand. The chrome scales with the frame's width through container query units and shares BrowserFrame's tokens; scroll drifts content taller than the screen up and back on a translate keyframe, paused under the pointer. Zero JavaScript.

- Import: `import PhoneFrame from '@moonarc/core/PhoneFrame'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/phone-frame.json`
- Tier A · category ui · trigger always
- Readout: `<PhoneFrame model="island" src={shot} scroll>`
- Browser support: widely (Chrome 105 · Firefox 110 · Safari 16)
- Measured cost: 0 B JS (CSS 6.2 kB raw)
- Page: https://moonarc.dev/components/phone-frame/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `model` | `'island' | 'notch' | 'punch'` | `'island'` | The cut-out: a pill, a notch at the top edge, or a dot. Generic shapes, no brand. |
| `src` | `ImageMetadata | string` | none | A screenshot: an imported image (optimised by Astro) or a URL. Or fill the slot. |
| `alt` | `string` | `''` | Alt text for the screenshot. |
| `scroll` | `boolean` | `false` | Drift content taller than the screen up and back (six ambient durations per leg), paused on hover and focus. |
| `theme` | `'light' | 'dark' | 'auto'` | `'auto'` | Pin the base tokens to one theme, or follow the page. The theme.css tokens follow the page; add data-ma-theme="dark" as well to re-derive them inside the frame. |
| `width` | `string` | `'18rem'` | Frame width; the height follows the model's ratio (9:19.5, punch 9:20). |

## Usage

```astro
---
import shot from '../assets/app.png';
---
<PhoneFrame model="island" src={shot} alt="The app's home screen" scroll />

<!-- any content, pinned dark -->
<PhoneFrame model="punch" theme="dark" width="14rem">
  <div class="p-6">…</div>
</PhoneFrame>
```

## Reduced motion

The content stands at the top; nothing drifts.

## With ClientRouter

Static; nothing to bind.

## Craft

- Every length is in cqw of the frame (bezel, radius, cut-out, home bar), so a 14 rem phone and a 24 rem phone are the same drawing, the BrowserFrame technique.
- The drift is one translate keyframe to min(0, 100cqh − 100%): 100cqh is the screen (a size container), 100% the content's own height, so content shorter than the screen never moves and longer content ends exactly at its bottom. Nothing is measured.
- The frame is generic on purpose: three neutral cut-out shapes, no logo, no button silhouettes. It draws a phone, not an imitation of one product.
- The drift pauses under the pointer and while anything inside has focus, so a reader can look at a screen, or tab through real content in the slot, without chasing it.

## Replaces

- Iphone15Pro (Magic UI)
- DeviceFrame (Cult UI)
- Mockup (Tailwind Plus)

## Source

```astro
---
/**
 * PhoneFrame — a generic phone around a screenshot or any content, zero
 * JS. No brand: three cut-outs (`island`, `notch`, `punch`), a home bar,
 * no logo and no buttons. The chrome scales with the frame's width through
 * container query units, like BrowserFrame, and uses the same tokens for
 * the bezel, the screen and the shadow. `scroll` drifts content taller
 * than the screen up and back on a translate keyframe — one ambient
 * duration times six, paused under the pointer and while anything inside
 * has focus. Pass `src` for a screenshot or fill the slot.
 */
import type { HTMLAttributes } from 'astro/types';
import type { ImageMetadata } from 'astro';
import { Image } from 'astro:assets';

interface Props extends HTMLAttributes<'div'> {
  model?: 'island' | 'notch' | 'punch';
  /** A screenshot: an imported image (optimised by Astro) or a plain URL. */
  src?: ImageMetadata | string;
  alt?: string;
  /** Drift content taller than the screen up and back. */
  scroll?: boolean;
  theme?: 'light' | 'dark' | 'auto';
  /** Frame width, any CSS length; the height follows the model's ratio. */
  width?: string;
}

const { model = 'island', src, alt = '', scroll = false, theme = 'auto', width = '18rem', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-phone-w:${width}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-phone', className]} data-model={model} data-scroll={scroll ? '' : undefined} data-theme={theme === 'auto' ? undefined : theme} style={vars} {...rest}>
  <div class="ma-phone__body">
  <div class="ma-phone__screen">
    <div class="ma-phone__content">
      {src && typeof src === 'string' && <img src={src} alt={alt} loading="lazy" decoding="async" />}
      {src && typeof src !== 'string' && <Image src={src} alt={alt} />}
      <slot />
    </div>
    <span class="ma-phone__cutout" aria-hidden="true"></span>
    <span class="ma-phone__home" aria-hidden="true"></span>
  </div>
  </div>
</div>

<style is:global>
  @keyframes ma-phone-drift {
    to {
      translate: 0 min(0px, calc(100cqh - 100%));
    }
  }
  @layer components {
    :where(.ma-phone) {
      /* the container: its own cqw units belong to its descendants, so the bezel is a child (the body), not this box */
      container-type: inline-size;
      display: grid;
      inline-size: var(--ma-phone-w, 18rem);
      max-inline-size: 100%;
      aspect-ratio: 9 / 19.5;
      /* an aspect-ratio box has a content-based automatic minimum height: WebKit let the screen's content stretch the phone past its ratio */
      min-block-size: 0;
    }
    :where(.ma-phone__body) {
      /* the same tokens BrowserFrame uses for its chrome: edge, panel, glow; every length in cqw of the phone */
      box-sizing: border-box;
      display: grid;
      min-block-size: 0;
      padding: 3cqw;
      border: 1px solid var(--ma-edge);
      border-radius: 15cqw;
      background: color-mix(in srgb, var(--ma-ink) 92%, var(--ma-panel));
      box-shadow:
        inset 0 0 0 1px color-mix(in srgb, var(--ma-panel) 25%, transparent),
        0 1px 2px var(--ma-glow),
        0 24px 48px -24px rgb(0 0 0 / 0.5);
    }
    :where(.ma-phone[data-model='punch']) {
      aspect-ratio: 9 / 20;
    }
    :where(.ma-phone[data-theme='dark']) {
      color-scheme: dark;
      --ma-shine: rgb(255 255 255 / 0.32);
      --ma-scrim: rgb(10 10 12 / 0.72);
      --ma-edge: rgb(255 255 255 / 0.1);
      --ma-ink: rgb(255 255 255 / 0.92);
      --ma-panel: rgb(24 24 27 / 0.94);
      --ma-glow: rgb(255 255 255 / 0.14);
      --ma-dark: 1;
    }
    :where(.ma-phone[data-theme='light']) {
      color-scheme: light;
      --ma-shine: rgb(255 255 255 / 0.85);
      --ma-scrim: rgb(255 255 255 / 0.72);
      --ma-edge: rgb(0 0 0 / 0.08);
      --ma-ink: rgb(0 0 0 / 0.9);
      --ma-panel: rgb(255 255 255 / 0.92);
      --ma-glow: rgb(0 0 0 / 0.1);
      --ma-dark: 0;
    }
    :where(.ma-phone__screen) {
      position: relative;
      container-type: size;
      min-block-size: 0;
      border-radius: 12cqw;
      background: var(--ma-panel);
      color: var(--ma-ink);
      overflow: clip;
    }
    :where(.ma-phone__content) {
      min-block-size: 100%;
    }
    :where(.ma-phone__content > img) {
      display: block;
      inline-size: 100%;
      block-size: auto;
    }
    :where(.ma-phone__cutout) {
      position: absolute;
      inset-block-start: 4cqw;
      inset-inline-start: 50%;
      inline-size: 30cqw;
      block-size: 8cqw;
      translate: -50% 0;
      border-radius: 999px;
      background: #000;
    }
    :where(.ma-phone[data-model='notch'] .ma-phone__cutout) {
      inset-block-start: 0;
      inline-size: 52cqw;
      block-size: 8.5cqw;
      border-radius: 0 0 6cqw 6cqw;
    }
    :where(.ma-phone[data-model='punch'] .ma-phone__cutout) {
      inline-size: 5.5cqw;
      block-size: 5.5cqw;
      border-radius: 50%;
    }
    :where(.ma-phone__home) {
      position: absolute;
      inset-block-end: 2.5cqw;
      inset-inline-start: 50%;
      inline-size: 36cqw;
      block-size: 1.3cqw;
      translate: -50% 0;
      border-radius: 999px;
      background: color-mix(in srgb, var(--ma-ink) 65%, transparent);
    }
    /* scroll: content taller than the screen drifts up and back; the pointer, or focus inside, holds it */
    :where(.ma-phone[data-scroll] .ma-phone__content) {
      animation: ma-phone-drift calc(var(--ma-dur-ambient) * 6) var(--ma-ease-in-out) infinite alternate;
    }
    :where(.ma-phone[data-scroll]:is(:hover, :focus-within) .ma-phone__content) {
      animation-play-state: paused;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-phone[data-scroll] .ma-phone__content) {
        animation: none;
      }
    }
  }
</style>

```
