Browser frame
A browser window around any content, with tabs and an address field.
npx shadcn@latest add https://ui.goodtabs.co/r/browser-frame.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/browser-frame
ui.goodtabs.co/components
Components
Every piece reads shadcn theme tokens only, so it matches your project the moment it lands.
Full chrome around a small page with a heading, text and a button.
With tabs
Mobbingoodtabsshadcn/ui
goodtabs.co
Inspiration
The tabs worth keeping open.
Three tabs across the top, the active one raised.
Tab variant
goodtabs UIDocs
ui.goodtabs.co
Only the tab strip and address, framing a 16:9 placeholder.
Usage
import { BrowserFrame } from "@/components/ui/browser-frame"
<BrowserFrame url="ui.goodtabs.co" tabs={[{ label: "goodtabs", active: true }, { label: "Docs" }]}>
<img src="/screenshot.png" alt="The components page" />
</BrowserFrame>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| url | string | Shown in the address field. Omit to hide the field. | |
| tabs | { label: string; active?: boolean; icon?: ReactNode }[] | Tabs across the top. The active one is raised. | |
| variant | "window" | "tab" | "window" | window draws the full chrome; tab draws only tabs and address. |
| secure | boolean | true | Show a lock before the url. |
| shadow | boolean | false | Lift the frame with shadow-md. |
| maxWidth | string | Constrain the frame, for example "640px". | |
| onSelect | (index: number) => void | Makes the tabs buttons and reports the clicked index. |
Source
browser-frame/browser-frame.tsx
import * as React from "react";
import { LockIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export type BrowserTab = {
label: string;
active?: boolean;
icon?: React.ReactNode;
};
type BrowserFrameProps = React.ComponentProps<"div"> & {
/** Shown in the address field. */
url?: string;
/** Tabs across the top. The active one is raised. */
tabs?: BrowserTab[];
/** "window" draws the full chrome; "tab" draws only the tab strip and address. */
variant?: "window" | "tab";
/** Show a lock before the url. */
secure?: boolean;
/** Lift the frame off the page. */
shadow?: boolean;
/** Constrain the frame, for example "640px". */
maxWidth?: string;
/** Makes the tabs buttons. Called with the tab index. */
onSelect?: (index: number) => void;
};
/**
* A browser window around any content. The goodtabs motif.
* Presentational by default: tabs become buttons only when onSelect is passed.
*/
export function BrowserFrame({
url,
tabs,
variant = "window",
secure = true,
shadow = false,
maxWidth,
onSelect,
className,
style,
children,
...props
}: BrowserFrameProps) {
const hasTabs = tabs && tabs.length > 0;
return (
<div
className={cn("w-full overflow-hidden rounded-lg border bg-background", shadow && "shadow-md", className)}
style={{ maxWidth, ...style }}
{...props}
>
<div className="flex min-h-9 flex-col bg-muted">
{(variant === "window" || hasTabs) && (
<div className="flex h-9 items-end gap-1 px-2">
{variant === "window" && (
<div className="mr-2 flex items-center gap-1.5 self-center" aria-hidden="true">
<span className="size-2.5 rounded-full bg-muted-foreground/30" />
<span className="size-2.5 rounded-full bg-muted-foreground/30" />
<span className="size-2.5 rounded-full bg-muted-foreground/30" />
</div>
)}
{hasTabs && (
<div role={onSelect ? "tablist" : undefined} className="flex min-w-0 items-end gap-0.5 overflow-hidden">
{tabs.map((tab, i) => {
const classes = cn(
"flex h-7 max-w-40 items-center gap-1.5 truncate rounded-t-md px-3 text-xs",
tab.active ? "bg-background font-medium text-foreground" : "text-foreground/70",
onSelect && !tab.active && "hover:bg-background/60 hover:text-foreground",
);
return onSelect ? (
<button
key={i}
type="button"
role="tab"
aria-selected={!!tab.active}
onClick={() => onSelect(i)}
className={cn(classes, "transition-colors duration-100 ease-out")}
>
{tab.icon && <span className="shrink-0 [&_svg]:size-3.5">{tab.icon}</span>}
<span className="truncate">{tab.label}</span>
</button>
) : (
<span key={i} className={classes}>
{tab.icon && <span className="shrink-0 [&_svg]:size-3.5">{tab.icon}</span>}
<span className="truncate">{tab.label}</span>
</span>
);
})}
</div>
)}
</div>
)}
{url !== undefined && (
<div className={cn("flex items-center px-2 py-1.5", (variant === "window" || hasTabs) && "bg-background")}>
<div className="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md bg-muted px-2.5 font-mono text-xs text-foreground">
{secure && <LockIcon className="size-3 shrink-0" aria-hidden="true" />}
<span className="truncate">{url}</span>
</div>
</div>
)}
</div>
<div className="border-t bg-background">{children}</div>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/browser-frame/prompt.md, or the Copy AI prompt button above.
When to use
- Around a screenshot or a live demo on a landing page
- In a hero as the visual below the title
- To show a site in context in docs or a changelog
When not to use
- As real navigation; the tabs are decorative unless onSelect is passed
- Around app UI that is not a web page, such as a mobile screen
Agent notes
- Server component by default; pass onSelect from a client component to make the tabs interactive
- The body has no padding, so children control their own; images fill edge to edge
- Window dots use bg-muted-foreground/30, never traffic light colours, so the frame stays monochrome
- Pair with the hero block: put the frame in the hero's children