Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
urlstringShown 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.
securebooleantrueShow a lock before the url.
shadowbooleanfalseLift the frame with shadow-md.
maxWidthstringConstrain the frame, for example "640px".
onSelect(index: number) => voidMakes 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