{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "browser-frame",
  "title": "Browser Frame",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A Safari-style window with traffic lights, a padlocked address field and optional tabs. Children render as the page, either at natural size or laid out at desktop width and scaled to fit.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/media.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/browser-frame.tsx",
      "content": "// Ballmac UI: Browser Frame. https://ui.ballmac.com/components/browser-frame\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight, Copy, Globe, Lock, PanelLeft, Plus, RotateCw, Share, X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\nimport { Media } from \"@/components/ballmac/media\"\n\ntype BrowserFrameTab = {\n  /** Tab title. */\n  title: string\n  /** Optional favicon; defaults to a globe icon. */\n  icon?: React.ReactNode\n}\n\ntype BrowserFrameProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Page content. Ignored when `src` is set. */\n  children?: React.ReactNode\n  /** Address shown in the URL field. Safari shows just the domain, so a short value reads best. */\n  url?: string\n  /** Show the padlock before the address. */\n  secure?: boolean\n  /** Tabs shown in a strip under the toolbar. Omit for a single-tab window. */\n  tabs?: (string | BrowserFrameTab)[]\n  /** Index of the selected tab. */\n  activeTab?: number\n  /** Screenshot shown as the page, cropped to fill it. */\n  src?: string\n  /** Alternative text for `src`. */\n  alt?: string\n  /**\n   * Width in CSS pixels that the page is laid out at; it is then scaled to fit, like a desktop\n   * window shown small. Needs a fixed height, so it implies `aspectRatio` 16/10 unless you set one.\n   */\n  screenWidth?: number\n  /** Aspect ratio of the page area, e.g. 16 / 10. Omit to let the content set the height. */\n  aspectRatio?: number\n  /** Class names for the page area. */\n  screenClassName?: string\n}\n\n/** Lays screen content out at `screenWidth` CSS pixels and scales it to fit, without re-rendering. */\nfunction useScreenScale(screenWidth: number | undefined) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  React.useEffect(() => {\n    const screen = ref.current\n    if (!screen || !screenWidth) return\n    const measure = () => screen.style.setProperty(\"--screen-scale\", String(screen.clientWidth / screenWidth))\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(screen)\n    return () => observer.disconnect()\n  }, [screenWidth])\n  return ref\n}\n\nconst toolbarIcon = \"size-4 shrink-0 text-muted-foreground\"\n\nfunction TrafficLights() {\n  return (\n    <div aria-hidden=\"true\" data-slot=\"browser-frame-traffic-lights\" className=\"flex shrink-0 items-center gap-2\">\n      {[\"bg-(--traffic-close)\", \"bg-(--traffic-minimize)\", \"bg-(--traffic-zoom)\"].map((color) => (\n        <span key={color} className={cn(\"size-3 rounded-full shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.18)]\", color)} />\n      ))}\n    </div>\n  )\n}\n\nfunction BrowserFrame({\n  url = \"acme.com\",\n  secure = true,\n  tabs,\n  activeTab = 0,\n  src,\n  alt = \"\",\n  screenWidth,\n  aspectRatio,\n  screenClassName,\n  className,\n  style,\n  children,\n  ...props\n}: BrowserFrameProps) {\n  const msg = useMessages()\n  const scaled = !src && !!screenWidth\n  const screenRef = useScreenScale(scaled ? screenWidth : undefined)\n  const ratio = aspectRatio ?? (scaled || src ? 16 / 10 : undefined)\n  const tabList = tabs?.map((tab) => (typeof tab === \"string\" ? { title: tab } : tab))\n\n  return (\n    <div\n      data-slot=\"browser-frame\"\n      className={cn(\n        \"@container relative flex w-full min-w-0 flex-col overflow-hidden rounded-xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.06),0_24px_60px_-24px_rgb(0_0_0/0.35)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.04),0_24px_60px_-24px_rgb(0_0_0/0.8)]\",\n        \"[--traffic-close:oklch(0.69_0.2_25)] [--traffic-minimize:oklch(0.83_0.16_82)] [--traffic-zoom:oklch(0.74_0.19_145)]\",\n        className\n      )}\n      style={style}\n      {...props}\n    >\n      {/* Toolbar */}\n      <div\n        data-slot=\"browser-frame-toolbar\"\n        className=\"flex h-11 shrink-0 items-center gap-3 border-b bg-muted/50 px-3.5 @md:gap-4\"\n      >\n        <TrafficLights />\n        <div aria-hidden=\"true\" className=\"hidden items-center gap-3 @lg:flex\">\n          <PanelLeft className={cn(toolbarIcon, \"rtl:-scale-x-100\")} />\n          <ChevronLeft className={cn(toolbarIcon, \"rtl:rotate-180\")} />\n          <ChevronRight className={cn(toolbarIcon, \"opacity-40 rtl:rotate-180\")} />\n        </div>\n        <div\n          data-slot=\"browser-frame-address\"\n          className=\"mx-auto flex h-7 w-full max-w-sm min-w-0 items-center justify-center gap-1.5 rounded-md bg-background/80 px-2.5 text-xs text-foreground shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--foreground)_8%,transparent)]\"\n        >\n          {secure && <Lock aria-label={msg(\"browser-frame.secureConnection\", \"Secure connection\")} className=\"size-3 shrink-0 text-muted-foreground\" />}\n          <span className=\"truncate\">{url}</span>\n          <RotateCw aria-hidden=\"true\" className=\"ms-auto hidden size-3 shrink-0 text-muted-foreground @sm:block\" />\n        </div>\n        <div aria-hidden=\"true\" className=\"hidden items-center gap-3 @lg:flex\">\n          <Share className={toolbarIcon} />\n          <Plus className={toolbarIcon} />\n          <Copy className={toolbarIcon} />\n        </div>\n      </div>\n\n      {/* Tab strip */}\n      {tabList && tabList.length > 0 && (\n        <div\n          data-slot=\"browser-frame-tabs\"\n          role=\"presentation\"\n          className=\"flex h-9 shrink-0 items-stretch gap-px border-b bg-muted/70 p-1\"\n        >\n          {tabList.map((tab, i) => {\n            const active = i === activeTab\n            return (\n              <div\n                key={i}\n                data-slot=\"browser-frame-tab\"\n                data-state={active ? \"active\" : \"inactive\"}\n                className={cn(\n                  \"relative flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-md px-2 text-[11px]\",\n                  active\n                    ? \"bg-background font-medium text-foreground shadow-[0_0_0_1px_color-mix(in_oklch,var(--foreground)_7%,transparent),0_1px_2px_rgb(0_0_0/0.06)]\"\n                    : \"text-muted-foreground\",\n                  // Hide extra tabs on narrow frames, but always keep the selected one.\n                  !active && i > 1 && \"hidden @md:flex\"\n                )}\n              >\n                {active && <X aria-hidden=\"true\" className=\"absolute start-2 size-3 text-muted-foreground\" />}\n                <span aria-hidden=\"true\" className=\"flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-3.5\">\n                  {tab.icon ?? <Globe />}\n                </span>\n                <span className=\"truncate\">{tab.title}</span>\n              </div>\n            )\n          })}\n        </div>\n      )}\n\n      {/* Page */}\n      <div\n        ref={screenRef}\n        data-slot=\"browser-frame-screen\"\n        className={cn(\"relative min-h-0 flex-1 overflow-hidden bg-background text-foreground\", screenClassName)}\n        style={ratio ? { aspectRatio: ratio } : undefined}\n      >\n        {src ? (\n          // eslint-disable-next-line @next/next/no-img-element\n          <Media data-slot=\"browser-frame-image\" media={{ src, alt: alt ?? \"\", position: \"top\" }} fill className=\"absolute inset-0\" />\n        ) : scaled ? (\n          <div\n            data-slot=\"browser-frame-viewport\"\n            className=\"absolute top-0 left-0 origin-top-left\" // rtl-fixed: scaled from its top-left corner\n            style={{\n              width: screenWidth,\n              height: \"calc(100% / var(--screen-scale, 1))\",\n              transform: \"scale(var(--screen-scale, 1))\",\n            }}\n          >\n            {children}\n          </div>\n        ) : (\n          children\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport { BrowserFrame, type BrowserFrameProps, type BrowserFrameTab }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/browser-frame.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "mockup",
      "browser",
      "safari",
      "window",
      "screenshot",
      "website",
      "hero"
    ],
    "version": "1.0.0",
    "updated": "2026-09-29",
    "ai": {
      "summary": "Wrap web content in <BrowserFrame url=\"acme.com\">. Pass screenWidth={1280} (plus aspectRatio) to render a full desktop layout scaled down, tabs={[\"Home\", \"Docs\"]} for a tab strip, or src for a screenshot. Chrome colors follow the theme tokens.",
      "whenToUse": [
        "Showing a website or web app on a landing page or in docs",
        "Template and theme galleries",
        "Before/after comparisons of a web page"
      ],
      "whenNotToUse": [
        "Native desktop apps (use laptop-frame, or a plain window)",
        "Mobile layouts (use phone-frame)"
      ],
      "composesWith": [
        "laptop-frame",
        "phone-frame"
      ],
      "a11y": [],
      "customization": [
        "url, secure (padlock), tabs (strings or { title, icon }), activeTab",
        "screenWidth + aspectRatio for a scaled desktop layout; omit both for natural size",
        "Traffic-light colors: override --traffic-close, --traffic-minimize, --traffic-zoom",
        "Parts: [data-slot=browser-frame-toolbar], -address, -tabs, -screen"
      ]
    },
    "examples": [
      "browser-frame-demo",
      "browser-frame-minimal"
    ],
    "url": "https://ui.ballmac.com/components/browser-frame"
  },
  "categories": [
    "devices"
  ],
  "type": "registry:ui"
}