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.
Ship calmer software.
Previews for every branch, rollbacks in one click, and logs that read like a story.
import { ArrowRight, BookOpen, Check, Sparkles, Tag } from "lucide-react"
import { BrowserFrame } from "@/components/ballmac/browser-frame"
function Landing() {
return (
<div className="relative flex size-full flex-col overflow-hidden bg-background">
{/* Glow and grid behind the hero */}
<div
aria-hidden="true"
className="absolute inset-x-0 -top-40 mx-auto h-[520px] w-[820px] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--chart-1)_32%,transparent),color-mix(in_oklch,var(--chart-4)_14%,transparent)_55%,transparent)] blur-2xl"
/>
<div
aria-hidden="true"
className="absolute inset-0 [background-image:linear-gradient(to_right,color-mix(in_oklch,var(--foreground)_6%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklch,var(--foreground)_6%,transparent)_1px,transparent_1px)] [background-size:56px_56px] [mask-image:radial-gradient(ellipse_60%_55%_at_50%_0%,black,transparent)]"
/>
<nav className="relative flex items-center gap-8 px-10 py-5 text-[14px]">
<span className="flex items-center gap-2 font-semibold tracking-tight">
<span className="size-6 rounded-lg bg-[conic-gradient(from_210deg,var(--chart-1),var(--chart-4),var(--chart-2),var(--chart-1))]" />
Acme
</span>
<span className="text-muted-foreground">Product</span>
<span className="text-muted-foreground">Docs</span>
<span className="text-muted-foreground">Pricing</span>
<span className="ms-auto text-muted-foreground">Sign in</span>
<span className="rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground">Get started</span>
</nav>
<div className="relative mt-12 flex flex-col items-center px-10 text-center">
<span className="inline-flex items-center gap-2 rounded-full border bg-background/70 px-3 py-1 text-[13px] text-muted-foreground backdrop-blur">
<Sparkles className="size-3.5 text-chart-4" aria-hidden="true" />
Release 3.0 is out
<ArrowRight className="size-3.5 rtl:rotate-180" aria-hidden="true" />
</span>
<h2 className="mt-6 text-[64px] leading-[1.02] font-semibold tracking-[-0.04em]">
Ship calmer software.
</h2>
<p className="mt-5 max-w-[560px] text-[18px] text-muted-foreground">
Previews for every branch, rollbacks in one click, and logs that read like a story.
</p>
<div className="mt-8 flex gap-3 text-[15px] font-medium">
<span className="rounded-full bg-primary px-6 py-2.5 text-primary-foreground shadow-lg">Start for free</span>
<span className="rounded-full border bg-background px-6 py-2.5">Book a demo</span>
</div>
<div className="mt-6 flex gap-6 text-[13px] text-muted-foreground">
{["No credit card", "SOC 2 Type II", "EU data residency"].map((t) => (
<span key={t} className="flex items-center gap-1.5">
<Check className="size-3.5 text-chart-2" aria-hidden="true" />
{t}
</span>
))}
</div>
</div>
{/* Product shot peeking in from the bottom */}
<div className="relative mx-auto mt-12 w-[860px] flex-1 rounded-t-2xl border border-b-0 bg-card p-4 shadow-[0_-20px_60px_-30px_color-mix(in_oklch,var(--chart-1)_60%,transparent)]">
<div className="flex gap-3">
{["w-40", "w-56", "w-32"].map((w, i) => (
<div key={i} className={`h-20 ${w} rounded-lg border bg-muted/50 p-3`}>
<div className="h-2 w-12 rounded bg-muted-foreground/30" />
<div className="mt-3 h-4 w-20 rounded bg-foreground/80" />
</div>
))}
<div className="h-20 flex-1 rounded-lg border bg-[linear-gradient(180deg,color-mix(in_oklch,var(--chart-1)_18%,transparent),transparent)]" />
</div>
<div className="mt-3 flex h-40 items-end gap-2 rounded-lg border bg-muted/30 p-4">
{[38, 52, 44, 66, 58, 72, 64, 80, 70, 88, 76, 94, 84, 98, 90, 100].map((h, i) => (
<span
key={i}
className="flex-1 rounded-t bg-[linear-gradient(180deg,var(--chart-1),color-mix(in_oklch,var(--chart-1)_25%,transparent))]"
style={{ height: `${h}%`, opacity: 0.45 + i / 30 }}
/>
))}
</div>
</div>
</div>
)
}
export default function BrowserFrameDemo() {
return (
<BrowserFrame
url="acme.com"
tabs={[
{ title: "Acme: Ship calmer software", icon: <Sparkles /> },
{ title: "Docs", icon: <BookOpen /> },
{ title: "Pricing", icon: <Tag /> },
]}
screenWidth={1100}
aspectRatio={16 / 10}
className="max-w-2xl"
>
<Landing />
</BrowserFrame>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/browser-frameInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/mediaCopy the source into your project.
components/ballmac/browser-frame.tsx// Ballmac UI: Browser Frame. https://ui.ballmac.com/components/browser-frame "use client" import * as React from "react" import { ChevronLeft, ChevronRight, Copy, Globe, Lock, PanelLeft, Plus, RotateCw, Share, X } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" import { Media } from "@/components/ballmac/media" type BrowserFrameTab = { /** Tab title. */ title: string /** Optional favicon; defaults to a globe icon. */ icon?: React.ReactNode } type BrowserFrameProps = Omit<React.ComponentProps<"div">, "children"> & { /** Page content. Ignored when `src` is set. */ children?: React.ReactNode /** Address shown in the URL field. Safari shows just the domain, so a short value reads best. */ url?: string /** Show the padlock before the address. */ secure?: boolean /** Tabs shown in a strip under the toolbar. Omit for a single-tab window. */ tabs?: (string | BrowserFrameTab)[] /** Index of the selected tab. */ activeTab?: number /** Screenshot shown as the page, cropped to fill it. */ src?: string /** Alternative text for `src`. */ alt?: string /** * Width in CSS pixels that the page is laid out at; it is then scaled to fit, like a desktop * window shown small. Needs a fixed height, so it implies `aspectRatio` 16/10 unless you set one. */ screenWidth?: number /** Aspect ratio of the page area, e.g. 16 / 10. Omit to let the content set the height. */ aspectRatio?: number /** Class names for the page area. */ screenClassName?: string } /** Lays screen content out at `screenWidth` CSS pixels and scales it to fit, without re-rendering. */ function useScreenScale(screenWidth: number | undefined) { const ref = React.useRef<HTMLDivElement>(null) React.useEffect(() => { const screen = ref.current if (!screen || !screenWidth) return const measure = () => screen.style.setProperty("--screen-scale", String(screen.clientWidth / screenWidth)) measure() const observer = new ResizeObserver(measure) observer.observe(screen) return () => observer.disconnect() }, [screenWidth]) return ref } const toolbarIcon = "size-4 shrink-0 text-muted-foreground" function TrafficLights() { return ( <div aria-hidden="true" data-slot="browser-frame-traffic-lights" className="flex shrink-0 items-center gap-2"> {["bg-(--traffic-close)", "bg-(--traffic-minimize)", "bg-(--traffic-zoom)"].map((color) => ( <span key={color} className={cn("size-3 rounded-full shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.18)]", color)} /> ))} </div> ) } function BrowserFrame({ url = "acme.com", secure = true, tabs, activeTab = 0, src, alt = "", screenWidth, aspectRatio, screenClassName, className, style, children, ...props }: BrowserFrameProps) { const msg = useMessages() const scaled = !src && !!screenWidth const screenRef = useScreenScale(scaled ? screenWidth : undefined) const ratio = aspectRatio ?? (scaled || src ? 16 / 10 : undefined) const tabList = tabs?.map((tab) => (typeof tab === "string" ? { title: tab } : tab)) return ( <div data-slot="browser-frame" className={cn( "@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)]", "[--traffic-close:oklch(0.69_0.2_25)] [--traffic-minimize:oklch(0.83_0.16_82)] [--traffic-zoom:oklch(0.74_0.19_145)]", className )} style={style} {...props} > {/* Toolbar */} <div data-slot="browser-frame-toolbar" className="flex h-11 shrink-0 items-center gap-3 border-b bg-muted/50 px-3.5 @md:gap-4" > <TrafficLights /> <div aria-hidden="true" className="hidden items-center gap-3 @lg:flex"> <PanelLeft className={cn(toolbarIcon, "rtl:-scale-x-100")} /> <ChevronLeft className={cn(toolbarIcon, "rtl:rotate-180")} /> <ChevronRight className={cn(toolbarIcon, "opacity-40 rtl:rotate-180")} /> </div> <div data-slot="browser-frame-address" 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)]" > {secure && <Lock aria-label={msg("browser-frame.secureConnection", "Secure connection")} className="size-3 shrink-0 text-muted-foreground" />} <span className="truncate">{url}</span> <RotateCw aria-hidden="true" className="ms-auto hidden size-3 shrink-0 text-muted-foreground @sm:block" /> </div> <div aria-hidden="true" className="hidden items-center gap-3 @lg:flex"> <Share className={toolbarIcon} /> <Plus className={toolbarIcon} /> <Copy className={toolbarIcon} /> </div> </div> {/* Tab strip */} {tabList && tabList.length > 0 && ( <div data-slot="browser-frame-tabs" role="presentation" className="flex h-9 shrink-0 items-stretch gap-px border-b bg-muted/70 p-1" > {tabList.map((tab, i) => { const active = i === activeTab return ( <div key={i} data-slot="browser-frame-tab" data-state={active ? "active" : "inactive"} className={cn( "relative flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-md px-2 text-[11px]", active ? "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)]" : "text-muted-foreground", // Hide extra tabs on narrow frames, but always keep the selected one. !active && i > 1 && "hidden @md:flex" )} > {active && <X aria-hidden="true" className="absolute start-2 size-3 text-muted-foreground" />} <span aria-hidden="true" className="flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-3.5"> {tab.icon ?? <Globe />} </span> <span className="truncate">{tab.title}</span> </div> ) })} </div> )} {/* Page */} <div ref={screenRef} data-slot="browser-frame-screen" className={cn("relative min-h-0 flex-1 overflow-hidden bg-background text-foreground", screenClassName)} style={ratio ? { aspectRatio: ratio } : undefined} > {src ? ( // eslint-disable-next-line @next/next/no-img-element <Media data-slot="browser-frame-image" media={{ src, alt: alt ?? "", position: "top" }} fill className="absolute inset-0" /> ) : scaled ? ( <div data-slot="browser-frame-viewport" className="absolute top-0 left-0 origin-top-left" // rtl-fixed: scaled from its top-left corner style={{ width: screenWidth, height: "calc(100% / var(--screen-scale, 1))", transform: "scale(var(--screen-scale, 1))", }} > {children} </div> ) : ( children )} </div> </div> ) } export { BrowserFrame, type BrowserFrameProps, type BrowserFrameTab }Update the import paths to match your project setup.
Usage
import { BrowserFrame } from "@/components/ballmac/browser-frame"The full example is in the Code tab above.
Examples
Natural size
Sep 29, 2026
Branch previews, now 2× faster
Builds reuse the dependency cache from main, so most previews are ready before review starts.
import { BrowserFrame } from "@/components/ballmac/browser-frame"
export default function BrowserFrameMinimal() {
return (
<BrowserFrame url="acme.com/changelog" className="max-w-md">
<div className="space-y-4 p-6">
<p className="font-mono text-xs text-muted-foreground">Sep 29, 2026</p>
<h2 className="text-xl font-semibold tracking-tight">Branch previews, now 2× faster</h2>
<p className="text-sm text-muted-foreground">
Builds reuse the dependency cache from main, so most previews are ready before review starts.
</p>
<div className="h-24 rounded-lg border bg-[linear-gradient(135deg,color-mix(in_oklch,var(--chart-2)_22%,transparent),color-mix(in_oklch,var(--chart-1)_22%,transparent))]" />
</div>
</BrowserFrame>
)
}API reference
| Prop | Type | Default |
|---|---|---|
childrenPage content. Ignored when `src` is set. | React.ReactNode | — |
urlAddress shown in the URL field. Safari shows just the domain, so a short value reads best. | string | "acme.com" |
secureShow the padlock before the address. | boolean | true |
tabsTabs shown in a strip under the toolbar. Omit for a single-tab window. | (string | BrowserFrameTab)[] | — |
activeTabIndex of the selected tab. | number | 0 |
srcScreenshot shown as the page, cropped to fill it. | string | — |
altAlternative text for `src`. | string | "" |
screenWidthWidth in CSS pixels that the page is laid out at; it is then scaled to fit, like a desktop window shown small. Needs a fixed height, so it implies `aspectRatio` 16/10 unless you set one. | number | — |
aspectRatioAspect ratio of the page area, e.g. 16 / 10. Omit to let the content set the height. | number | — |
screenClassNameClass names for the page area. | string | — |
Also accepts the standard attributes of its root element.
Use with AI
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. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Browser Frame (@ballmac/browser-frame) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing a website or web app on a landing page or in docs
- Template and theme galleries
- Before/after comparisons of a web page
Not for
- Native desktop apps (use laptop-frame, or a plain window)
- Mobile layouts (use phone-frame)
Registry JSON: https://ui.ballmac.com/r/browser-frame.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Laptop Frame
A notched aluminium laptop drawn in CSS that scales with its container. Renders children, an image or a video as the screen, and can open its lid when it scrolls into view.
Phone Frame
A modern phone drawn in CSS: titanium band, side buttons, Dynamic Island, status bar and home indicator. Children render as the screen, laid out at a phone resolution and scaled to fit.
Android Frame
A modern Android phone drawn in CSS: satin frame in porcelain, obsidian or sage, centered punch-hole camera, Material-style status bar and gesture or three-button navigation. Children render at phone resolution, scaled to fit.
Tablet Frame
A modern tablet drawn in CSS: brushed aluminum frame, thin glass bezel, front camera, status bar and home indicator. Landscape or portrait with the same proportions; children render as the screen at tablet resolution, scaled to fit.