npx astro add moonarcpnpm astro add moonarcbunx astro add moonarcA code block with tabs (for example npm, pnpm, bun) and a copy button per panel.
Components / UI
A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active. Static, zero JavaScript. The chrome scales with the frame's own width through container query units, and every colour is a theme token, so theme="dark" or "light" pins one look and "auto" follows the page.
Motion components for Astro that keep working after every navigation.
move your cursor · <FluidField curl={24}>
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
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.
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 BrowserFrame from '@moonarc/core/BrowserFrame';
import shot from '../assets/home.png';
---
<BrowserFrame url="https://moonarc.dev" tabs={['Home', 'Components']} src={shot} alt="The home page" />
<BrowserFrame url="https://example.com" theme="dark">
<MyHero />
</BrowserFrame>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/browser-frame.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* BrowserFrame — a browser window around a screenshot or any content: the
* three lights, an address bar, optional tabs with one active. Static and
* zero JS; the chrome scales with the frame's own width through container
* query units, and every colour is a theme token, so `theme="dark"` or
* "light" pins one look and "auto" follows the page. Pass `src` (an
* imported image or a URL) for a screenshot, or put anything in the slot.
*/
import type { HTMLAttributes } from 'astro/types';
import type { ImageMetadata } from 'astro';
import { Image } from 'astro:assets';
interface Props extends HTMLAttributes<'div'> {
/** Shown in the address bar. */
url?: string;
/** Tab labels; `active` is the index that is selected. */
tabs?: string[];
active?: number;
theme?: 'light' | 'dark' | 'auto';
/** A screenshot: an imported image (optimised by Astro) or a plain URL. */
src?: ImageMetadata | string;
alt?: string;
/** Corner radius, any CSS length. */
radius?: string;
}
const { url = 'https://example.com', tabs, active = 0, theme = 'auto', src, alt = '', radius = '0.75rem', class: className, style, ...rest } = Astro.props;
const host = url.replace(/^https?:\/\//, '');
const secure = /^https:\/\//.test(url) || !/^https?:\/\//.test(url);
const vars = [`--ma-browser-r:${radius}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-browser', className]} data-theme={theme === 'auto' ? undefined : theme} style={vars} {...rest}>
<div class="ma-browser__bar" aria-hidden="true">
<span class="ma-browser__lights"><i></i><i></i><i></i></span>
{tabs && tabs.length > 0 && (
<span class="ma-browser__tabs">
{tabs.map((t, i) => <span class="ma-browser__tab" data-active={i === active ? '' : undefined}>{t}</span>)}
</span>
)}
<span class="ma-browser__url">
<svg viewBox="0 0 16 16" class="ma-browser__lock">{secure ? <path d="M4.5 7V5.5a3.5 3.5 0 0 1 7 0V7M3.5 7h9v6h-9z" /> : <path d="M8 3v6M8 11.5v1" />}</svg>
<span>{host}</span>
</span>
</div>
<div class="ma-browser__body">
{src && typeof src === 'string' && <img src={src} alt={alt} loading="lazy" decoding="async" />}
{src && typeof src !== 'string' && <Image src={src} alt={alt} />}
<slot />
</div>
</div>
<style is:global>
@layer components {
:where(.ma-browser) {
container-type: inline-size;
display: grid;
/* one column the frame's own width: an auto column grew to the bar's unbroken line (tabs and address are nowrap)
and ran the frame out of a phone; now the tabs and the address clip inside it */
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
overflow: clip;
border: 1px solid var(--ma-edge);
border-radius: var(--ma-browser-r, 0.75rem);
background: var(--ma-panel);
color: var(--ma-ink);
box-shadow: 0 1px 2px var(--ma-glow);
}
/* pinned themes: base.css's whole token set of that theme, locally (theme.css's tokens follow data-ma-theme) */
:where(.ma-browser[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-browser[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-browser__bar) {
display: flex;
align-items: center;
gap: clamp(0.4rem, 1.5cqw, 0.75rem);
padding: clamp(0.35rem, 1.4cqw, 0.6rem) clamp(0.5rem, 2cqw, 0.9rem);
border-bottom: 1px solid var(--ma-edge);
font-size: clamp(0.55rem, 1.9cqw, 0.8rem);
line-height: 1;
white-space: nowrap;
}
:where(.ma-browser__lights) {
display: inline-flex;
gap: 0.5em;
flex: none;
}
:where(.ma-browser__lights > i) {
width: 1em;
height: 1em;
border-radius: 50%;
background: var(--ma-edge);
}
:where(.ma-browser__lights > i:nth-child(1)) { background: #ff5f57; }
:where(.ma-browser__lights > i:nth-child(2)) { background: #febc2e; }
:where(.ma-browser__lights > i:nth-child(3)) { background: #28c840; }
:where(.ma-browser__tabs) {
display: inline-flex;
gap: 0.25em;
min-width: 0;
overflow: clip;
}
:where(.ma-browser__tab) {
padding: 0.5em 0.9em;
border-radius: 0.5em;
opacity: var(--ma-dim, 0.7);
overflow: clip;
text-overflow: ellipsis;
}
:where(.ma-browser__tab[data-active]) {
opacity: 1;
background: color-mix(in srgb, var(--ma-ink) 8%, transparent);
}
:where(.ma-browser__url) {
display: inline-flex;
align-items: center;
gap: 0.5em;
flex: 1 1 auto;
min-width: 0;
padding: 0.5em 0.9em;
border-radius: 0.5em;
background: color-mix(in srgb, var(--ma-ink) 5%, transparent);
opacity: 0.8;
overflow: clip;
text-overflow: ellipsis;
}
:where(.ma-browser__lock) {
width: 1em;
height: 1em;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
}
:where(.ma-browser__body) {
position: relative;
min-height: 0;
overflow: clip;
}
:where(.ma-browser__body > img) {
display: block;
width: 100%;
height: auto;
}
/* static: nothing moves, so nothing to reduce */
@media (prefers-reduced-motion: reduce) {
:where(.ma-browser) {
animation: none;
}
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
url | string | 'https://example.com' | Shown in the address bar; https shows a lock. |
tabs | string[] | none | Tab labels. |
active | number | 0 | Index of the selected tab. |
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. |
src | ImageMetadata | string | none | A screenshot: an imported image goes through Astro's <Image>; a URL is a plain <img>. |
alt | string | '' | Alt text for the screenshot. |
radius | string | '0.75rem' | Corner radius. |
Nothing moves; nothing changes.
ClientRouterStatic markup; nothing to rebind.
Replaces: Safari (Magic UI) · BrowserComponent (Cult UI) · BrowserWindow (Eldora UI). See the migration table.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarcA code block with tabs (for example npm, pnpm, bun) and a copy button per panel.
An image that starts narrow and rounded and opens to the full width as it scrolls into view.
Bind translate, scale, rotate and opacity to the scroll position: the element goes from the start values you give to neutral across a range of its own passage through the viewport, exactly in step.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown