A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.
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
island · scroll · hover holds it
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
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 moonarcpnpm astro add moonarcbunx astro add moonarc---
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.
npx shadcn@latest add https://moonarc.dev/r/phone-frame.jsonThe 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 — 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
| 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). |
Reduced motion
The content stands at the top; nothing drifts.
With ClientRouter
Static; nothing to bind.
Why it is built this way
- 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). See the migration table.
Related
click · four declared sizes, one attribute
drag · click · ← →
Compares a before and an after image with a line you drag between them.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown