# Browser Frame (Moonarc)

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.

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

## Props

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

## Usage

```astro
---
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>
```

## Reduced motion

Nothing moves; nothing changes.

## With ClientRouter

Static markup; nothing to rebind.

## Craft

- Sized in cqw with clamps: at 320 px wide the bar is a thin strip, at 1200 px it is a real toolbar, and the frame never dictates its own width.
- A pinned theme redefines every base token locally (--ma-panel, --ma-edge, --ma-ink, --ma-glow, --ma-shine, --ma-scrim, --ma-dark) with base.css's values for that theme rather than painting its own colours, so a dark frame on a light page, and the library components inside it, match the library's dark exactly. The theme.css tokens (--background, --card, --primary …) still follow the page: add data-ma-theme="dark" to the frame as well to re-derive them inside it, and set --ma-brand there too if the page sets one.
- The lights keep their three colours in both themes; they are the one thing a browser frame is recognised by.
- Content is a slot, so the frame can hold a live section, a video, or a ScrollScrub with rotateX for the container-scroll look.

## Replaces

- Safari (Magic UI)
- BrowserComponent (Cult UI)
- BrowserWindow (Eldora UI)

## Source

```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>

```
