Skip to content

Components / UI

Browser Frame

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.

Live demo

New in Pro · 11 GPU effectsOpen source / MIT

Motion components for Astro that keep working after every navigation.

Set up in five minutesEvery component, live

move your cursor · <FluidField curl={24}>

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 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>
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/browser-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.

BrowserFrame.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
urlstring'https://example.com'Shown in the address bar; https shows a lock.
tabsstring[]noneTab labels.
activenumber0Index 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.
srcImageMetadata | stringnoneA screenshot: an imported image goes through Astro's <Image>; a URL is a plain <img>.
altstring''Alt text for the screenshot.
radiusstring'0.75rem'Corner radius.

Reduced motion

Nothing moves; nothing changes.

With ClientRouter

Static markup; nothing to rebind.

Why it is built this way

Replaces: Safari (Magic UI) · BrowserComponent (Cult UI) · BrowserWindow (Eldora UI). See the migration table.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc

A code block with tabs (for example npm, pnpm, bun) and a copy button per panel.

every browserclick

An image that starts narrow and rounded and opens to the full width as it scrolls into view.

Chrome 115 · not Firefox · Safari 26scroll

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.

Chrome 115 · not Firefox · Safari 26scroll

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