Skip to content

Components / UI

Phone Frame

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.

Live demo

Inbox

Deployedmain → production
New signupari@studio.example
Payment$129 · Pro, yearly
Comment"looks great on mobile"
Build passed1 216 checks
Starredsomeone starred the repo
ReviewPR #128 approved
Invoice#2041 paid

island · scroll · hover holds it

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.2 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 105 · Firefox 110 · Safari 16; needs container-units.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import PhoneFrame from '@moonarc/core/PhoneFrame';
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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/phone-frame.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

PhoneFrame.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>

Props

PropTypeDefaultDescription
model'island' | 'notch' | 'punch''island'The cut-out: a pill, a notch at the top edge, or a dot. Generic shapes, no brand.
srcImageMetadata | stringnoneA screenshot: an imported image (optimised by Astro) or a URL. Or fill the slot.
altstring''Alt text for the screenshot.
scrollbooleanfalseDrift 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.
widthstring'18rem'Frame width; the height follows the model's ratio (9:19.5, punch 9:20).

Reduced motion

The content stands at the top; nothing drifts.

With ClientRouter

Static; nothing to bind.

Why it is built this way

Replaces: Iphone15Pro (Magic UI) · DeviceFrame (Cult UI) · Mockup (Tailwind Plus). See the migration table.

A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.

every browseralways

click · four declared sizes, one attribute

A black pill that changes shape with its state.

every browserclick
The scene as a line drawingThe scene in colour

drag · click · ← →

Compares a before and an after image with a line you drag between them.

newly · Chrome 124 · Firefox 121 · Safari 16.5pointerclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown