The window chrome of a real desktop app (Tauri or Electron): a title bar that drags the window, double-click to maximize, and the controls the platform draws: traffic lights, Windows caption buttons, or none.
v1.0.1Updated Oct 11, 2026Ships with a testUpdate without losing your edits
What changed
- 1.0.1 Oct 11, 2026macOS traffic lights are 12 px apart so every target meets the 24 px target-size rule (WCAG 2.5.8).
- 1.0.0 Oct 12, 2026First release.
Launch
Ship the beta on Friday, then write the changelog and invite the first ten teams.
"use client"
import * as React from "react"
import { FileText, Folder, PanelLeft, Plus, Search, Settings, Star } from "lucide-react"
import { AppWindow, AppWindowContent, AppWindowTitleBar } from "@/components/ballmac/app-window"
import { Button } from "@/components/ballmac/button"
import { Input } from "@/components/ballmac/input"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { Switch } from "@/components/ballmac/switch"
import type { FeelId } from "@/lib/ballmac/feel"
import { cn } from "@/lib/utils"
const folders = [
{ name: "All notes", icon: FileText, count: 24 },
{ name: "Starred", icon: Star, count: 6 },
{ name: "Launch", icon: Folder, count: 8 },
{ name: "Recipes", icon: Folder, count: 4 },
]
/** A small desktop app, so the feel shows in the chrome, the sidebar, the controls and the focus ring all at once. */
export default function AppWindowDemo() {
const [feel, setFeel] = React.useState<FeelId>("macos")
const [folder, setFolder] = React.useState("Launch")
const [maximized, setMaximized] = React.useState(false)
const [open, setOpen] = React.useState(true)
const [sync, setSync] = React.useState(true)
return (
<div className="flex w-full max-w-[760px] flex-col items-center gap-5">
<SegmentedControl aria-label="Platform feel" value={feel} onValueChange={(v) => setFeel(v as FeelId)}>
<SegmentedControlItem value="macos">macOS</SegmentedControlItem>
<SegmentedControlItem value="windows">Windows</SegmentedControlItem>
<SegmentedControlItem value="ios">iOS</SegmentedControlItem>
</SegmentedControl>
{/* data-feel scopes the whole feel to this block, so the page around it is unchanged */}
<div data-feel={feel} className="bg-muted/40 flex min-h-[26rem] w-full items-center justify-center rounded-2xl border p-4 sm:p-8">
{open ? (
<AppWindow
feel={feel}
active
maximized={maximized}
onClose={() => setOpen(false)}
onMinimize={() => setOpen(false)}
onMaximize={() => setMaximized((m) => !m)}
className={cn("h-[22rem] w-full shadow-[var(--bm-shadow-popover,0_12px_36px_-10px_rgb(0_0_0/0.25))]", maximized && "h-[26rem] max-w-none")}
>
<AppWindowTitleBar
title="Notes"
icon={<FileText />}
leading={feel === "windows" ? undefined : <PanelLeft className="text-muted-foreground size-4 rtl:-scale-x-100" aria-hidden="true" />}
>
<Button variant="ghost" size="icon-sm" aria-label="New note">
<Plus />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Settings">
<Settings />
</Button>
</AppWindowTitleBar>
<AppWindowContent className="flex">
<nav aria-label="Folders" className="bg-muted/40 hidden w-44 shrink-0 flex-col gap-0.5 border-e p-2 sm:flex">
{folders.map(({ name, icon: Icon, count }) => (
<button
key={name}
type="button"
aria-current={folder === name ? "page" : undefined}
onClick={() => setFolder(name)}
className={cn(
"relative flex h-[var(--bm-sidebar-item-h,calc(var(--spacing)*9))] w-full items-center gap-2 rounded-[var(--bm-sidebar-item-radius,var(--radius-lg))] px-2 text-start text-sm outline-none transition-colors duration-150",
"hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-[current=page]:bg-accent aria-[current=page]:font-medium",
"before:absolute before:inset-y-[22%] before:start-0 before:w-[var(--bm-sidebar-indicator,0px)] before:rounded-full before:bg-primary before:opacity-0 before:content-[''] aria-[current=page]:before:opacity-100"
)}
>
<Icon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{name}</span>
<span className="text-muted-foreground text-xs tabular-nums">{count}</span>
</button>
))}
</nav>
<div className="flex min-w-0 flex-1 flex-col gap-4 p-4">
<div className="relative">
<Search className="text-muted-foreground pointer-events-none absolute start-2.5 top-1/2 size-4 -translate-y-1/2" aria-hidden="true" />
<Input aria-label="Search notes" placeholder="Search notes" className="ps-8" />
</div>
<div className="grid gap-1">
<h3 className="text-base font-semibold tracking-tight">{folder}</h3>
<p className="text-muted-foreground text-sm">Ship the beta on Friday, then write the changelog and invite the first ten teams.</p>
</div>
<label className="flex items-center justify-between gap-3 text-sm">
Sync across devices
<Switch checked={sync} onCheckedChange={setSync} />
</label>
<div className="mt-auto flex gap-2">
<Button>Save</Button>
<Button variant="outline">Cancel</Button>
</div>
</div>
</AppWindowContent>
</AppWindow>
) : (
<div className="text-muted-foreground grid justify-items-center gap-3 text-sm">
The window is closed.
<Button variant="outline" onClick={() => setOpen(true)}>
Reopen
</Button>
</div>
)}
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/app-windowAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/feel-presetsCopy the source into your project.
components/ballmac/app-window.tsx// Ballmac UI: App Window. https://ui.ballmac.com/components/app-window // Based on tauri-controls (MIT, Copyright (c) 2023 agmmnn): window control layout and metrics, rewritten without any Tauri dependency. "use client" import * as React from "react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" import { useFeel, type FeelId } from "@/lib/ballmac/feel" type AppWindowContextValue = { feel: FeelId active: boolean maximized: boolean onClose?: () => void onMinimize?: () => void onMaximize?: () => void onFullscreen?: () => void } const AppWindowContext = React.createContext<AppWindowContextValue>({ feel: "web", active: true, maximized: false }) // Tauri reads `data-tauri-drag-region`; Electron reads the CSS property. Both are set, so the same bar drags in either. const drag = { WebkitAppRegion: "drag" } as React.CSSProperties const noDrag = { WebkitAppRegion: "no-drag" } as React.CSSProperties const subscribeFocus = (cb: () => void) => { window.addEventListener("focus", cb) window.addEventListener("blur", cb) return () => { window.removeEventListener("focus", cb) window.removeEventListener("blur", cb) } } /** Whether this window has focus, so a window in the background dims as it does on the desktop. */ function useWindowFocus() { return React.useSyncExternalStore(subscribeFocus, () => document.hasFocus(), () => true) } type AppWindowProps = React.ComponentProps<"div"> & { /** Which platform's chrome to draw. Defaults to the page's feel (`data-feel`), which Feel Auto sets from the device. */ feel?: FeelId /** Whether the window is the focused one. Defaults to following the real window focus. */ active?: boolean /** The window fills the screen: corners square off and the Windows button shows "restore". */ maximized?: boolean /** Called by the close button. Without a handler the button is drawn but not focusable. */ onClose?: () => void /** Called by the minimize button. */ onMinimize?: () => void /** Called by the maximize (Windows) or zoom (macOS) button, and when the title bar is double-clicked. */ onMaximize?: () => void /** Called by the macOS green button; hold Option to call onMaximize instead. Falls back to onMaximize. */ onFullscreen?: () => void } /** * The frame of a desktop app window: a title bar that drags the window and carries the controls the platform draws (traffic * lights on the left for macOS, minimize, maximize and close on the right for Windows, none for iOS and web), and the content. * It draws chrome only; you wire the callbacks to Tauri's `getCurrentWindow()` or Electron's IPC. */ function AppWindow({ feel: feelProp, active, maximized = false, onClose, onMinimize, onMaximize, onFullscreen, className, ...props }: AppWindowProps) { const page = useFeel().feel const focused = useWindowFocus() const feel = feelProp ?? page const isActive = active ?? focused const value = React.useMemo( () => ({ feel, active: isActive, maximized, onClose, onMinimize, onMaximize, onFullscreen }), [feel, isActive, maximized, onClose, onMinimize, onMaximize, onFullscreen] ) return ( <AppWindowContext.Provider value={value}> <div data-slot="app-window" data-feel={feel} data-state={isActive ? "active" : "inactive"} data-maximized={maximized || undefined} className={cn( "group/app-window relative isolate flex min-h-0 min-w-0 flex-col overflow-hidden bg-card text-card-foreground", maximized ? "rounded-none" : "rounded-xl border border-border", className )} {...props} /> </AppWindowContext.Provider> ) } const lights = { close: <path d="M3.5 3.5l5 5m0-5l-5 5" strokeWidth="1.3" strokeLinecap="round" />, minimize: <path d="M3 6h6" strokeWidth="1.4" strokeLinecap="round" />, zoom: <path d="M3.4 8.6V5l3.6 3.6zM8.6 3.4V7L5 3.4z" stroke="none" fill="currentColor" />, plus: <path d="M6 3v6M3 6h6" strokeWidth="1.3" strokeLinecap="round" />, } as const function TrafficLight({ kind, glyph, label, onPress }: { kind: "close" | "minimize" | "zoom"; glyph: keyof typeof lights; label: string; onPress?: () => void }) { const shared = cn( "relative flex size-[12px] shrink-0 items-center justify-center rounded-full text-black/60 outline-none", "bg-(--light) shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.18)] dark:shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.12)]", "group-data-[state=inactive]/app-window:bg-foreground/15 group-data-[state=inactive]/app-window:group-hover/lights:bg-(--light)" ) const style = { "--light": `var(--mac-${kind}, ${kind === "close" ? "oklch(0.7 0.19 25)" : kind === "minimize" ? "oklch(0.84 0.16 84)" : "oklch(0.76 0.19 145)"})`, ...noDrag } as React.CSSProperties const icon = ( <svg viewBox="0 0 12 12" aria-hidden="true" stroke="currentColor" fill="none" className="size-3 opacity-0 transition-opacity duration-100 group-hover/lights:opacity-100 group-has-[:focus-visible]/lights:opacity-100"> {lights[glyph]} </svg> ) if (!onPress) return <span data-slot={`app-window-${kind}`} className={shared} style={style} aria-hidden="true">{icon}</span> return ( <button type="button" data-slot={`app-window-${kind}`} aria-label={label} onClick={onPress} className={cn(shared, "focus-visible:ring-[3px] focus-visible:ring-ring/50")} style={style}> {icon} </button> ) } function MacControls() { const { onClose, onMinimize, onMaximize, onFullscreen } = React.useContext(AppWindowContext) const msg = useMessages() // Holding Option turns the green button from full screen into a plain zoom, as it does on macOS. const [alt, setAlt] = React.useState(false) React.useEffect(() => { const set = (e: KeyboardEvent) => setAlt(e.altKey) window.addEventListener("keydown", set) window.addEventListener("keyup", set) return () => { window.removeEventListener("keydown", set) window.removeEventListener("keyup", set) } }, []) const zoom = alt && onFullscreen ? onMaximize : (onFullscreen ?? onMaximize) // 12 px lights 12 px apart keep every target 24 px from its neighbours, which is what WCAG 2.5.8 asks of small targets return ( <div data-slot="app-window-controls" dir="ltr" className="group/lights flex shrink-0 items-center gap-[12px]" style={noDrag}> <TrafficLight kind="close" glyph="close" label={msg("app-window.close", "Close window")} onPress={onClose} /> <TrafficLight kind="minimize" glyph="minimize" label={msg("app-window.minimize", "Minimize window")} onPress={onMinimize} /> <TrafficLight kind="zoom" glyph={alt ? "plus" : "zoom"} label={alt ? msg("app-window.zoom", "Zoom window") : msg("app-window.fullscreen", "Enter full screen")} onPress={zoom} /> </div> ) } const caption = { minimize: <path d="M0 5h10" />, maximize: <rect x="0.5" y="0.5" width="9" height="9" rx="1" />, restore: ( <> <path d="M2.5 2.5V1.5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-1" /> <rect x="0.5" y="2.5" width="7" height="7" rx="1" /> </> ), close: <path d="M0.5 0.5l9 9m0-9l-9 9" />, } as const function CaptionButton({ kind, label, onPress }: { kind: keyof typeof caption; label: string; onPress?: () => void }) { return ( <button type="button" data-slot={`app-window-${kind}`} aria-label={label} aria-disabled={onPress ? undefined : true} tabIndex={onPress ? undefined : -1} onClick={onPress} style={noDrag} className={cn( "flex h-full w-[46px] cursor-default items-center justify-center rounded-none text-foreground outline-none transition-colors duration-100 focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50", "group-data-[state=inactive]/app-window:text-muted-foreground", kind === "close" ? "hover:bg-destructive hover:text-white active:bg-destructive/90" : "hover:bg-foreground/[0.06] active:bg-foreground/[0.04]" )} > <svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1"> {caption[kind]} </svg> </button> ) } function WindowsControls() { const { maximized, onClose, onMinimize, onMaximize } = React.useContext(AppWindowContext) const msg = useMessages() return ( <div data-slot="app-window-controls" dir="ltr" className="flex h-full shrink-0 items-stretch"> <CaptionButton kind="minimize" label={msg("app-window.minimize", "Minimize window")} onPress={onMinimize} /> <CaptionButton kind={maximized ? "restore" : "maximize"} label={maximized ? msg("app-window.restore", "Restore window") : msg("app-window.maximize", "Maximize window")} onPress={onMaximize} /> <CaptionButton kind="close" label={msg("app-window.close", "Close window")} onPress={onClose} /> </div> ) } type AppWindowControlsProps = { feel?: FeelId } /** Just the window controls the platform draws, for a custom title bar. Draws nothing for iOS and web. */ function AppWindowControls({ feel: feelProp }: AppWindowControlsProps) { const { feel: ctx } = React.useContext(AppWindowContext) const feel = feelProp ?? ctx if (feel === "macos") return <MacControls /> if (feel === "windows") return <WindowsControls /> return null } type AppWindowTitleBarProps = Omit<React.ComponentProps<"div">, "title"> & { /** The window title. Centered on macOS and iOS, at the start beside the icon on Windows. */ title?: React.ReactNode /** A small icon before the title (Windows). */ icon?: React.ReactNode /** Items after the controls and title, at the start (a back button, a sidebar toggle). */ leading?: React.ReactNode /** Draw the window controls. Turn off when something else draws them. */ controls?: boolean } /** * The title bar. It drags the window (Tauri and Electron), maximizes on double-click, and sizes itself from the feel * (`--bm-titlebar-h`). Children become the trailing toolbar; buttons inside it stay clickable. */ function AppWindowTitleBar({ title, icon, leading, controls = true, className, children, onDoubleClick, ...props }: AppWindowTitleBarProps) { const { feel, onMaximize } = React.useContext(AppWindowContext) const mac = feel === "macos" const win = feel === "windows" return ( <div data-slot="app-window-titlebar" data-tauri-drag-region="" style={drag} onDoubleClick={(e) => { onDoubleClick?.(e) // only the bar itself and its title, never a button or field inside it if (!e.defaultPrevented && onMaximize && (e.target === e.currentTarget || (e.target as Element).closest("[data-slot=app-window-title]"))) onMaximize() }} className={cn( "relative flex h-[var(--bm-titlebar-h,2.5rem)] shrink-0 items-center gap-2 border-b border-border bg-card select-none", win ? "ps-3" : "px-3", className )} {...props} > {controls && mac ? <AppWindowControls /> : null} {win && icon ? <span data-slot="app-window-icon" className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4" style={noDrag}>{icon}</span> : null} {title ? ( <div data-slot="app-window-title" className={cn( "truncate font-semibold text-foreground/85 group-data-[state=inactive]/app-window:text-muted-foreground", win ? "min-w-0 text-xs font-normal" : "pointer-events-none absolute inset-x-24 text-center text-sm" )} > {title} </div> ) : null} {leading ? <div data-slot="app-window-leading" className="relative z-10 flex items-center gap-1" style={noDrag}>{leading}</div> : null} {children ? <div data-slot="app-window-toolbar" className="relative z-10 ms-auto flex min-w-0 items-center gap-1 text-foreground/70 group-data-[state=inactive]/app-window:text-foreground/40" style={noDrag}>{children}</div> : null} {controls && win ? <div className={cn("h-full", children ? "" : "ms-auto")}><AppWindowControls /></div> : null} </div> ) } type AppWindowContentProps = React.ComponentProps<"div"> /** The scrollable area under the title bar. */ function AppWindowContent({ className, ...props }: AppWindowContentProps) { return <div data-slot="app-window-content" className={cn("min-h-0 flex-1 overflow-auto", className)} {...props} /> } export { AppWindow, AppWindowTitleBar, AppWindowControls, AppWindowContent } export type { AppWindowProps, AppWindowTitleBarProps, AppWindowControlsProps, AppWindowContentProps }Update the import paths to match your project setup.
Usage
import { AppWindow, AppWindowTitleBar, AppWindowControls, AppWindowContent } from "@/components/ballmac/app-window"The full example is in the Code tab above.
Examples
One window, three feels
Hiring, launch dates and the budget review.
Hiring, launch dates and the budget review.
Hiring, launch dates and the budget review.
"use client"
import { ChevronLeft, FileText, Share } from "lucide-react"
import { AppWindow, AppWindowContent, AppWindowTitleBar } from "@/components/ballmac/app-window"
import { Button } from "@/components/ballmac/button"
import type { FeelId } from "@/lib/ballmac/feel"
const feels: { feel: FeelId; label: string }[] = [
{ feel: "macos", label: "macOS" },
{ feel: "windows", label: "Windows" },
{ feel: "ios", label: "iOS" },
]
/** The same window in three feels: only the chrome and the tokens differ, the markup is identical. */
export default function AppWindowFeels() {
return (
<div className="grid w-full max-w-[960px] gap-6 md:grid-cols-3">
{feels.map(({ feel, label }) => (
<figure key={feel} data-feel={feel} className="grid gap-2">
<AppWindow feel={feel} active onClose={() => {}} onMinimize={() => {}} onMaximize={() => {}} className="h-56 shadow-[var(--bm-shadow-popover,0_12px_36px_-10px_rgb(0_0_0/0.25))]">
<AppWindowTitleBar
title="Quarterly plan"
icon={<FileText />}
leading={feel === "ios" ? <ChevronLeft className="text-primary size-5 rtl:rotate-180" aria-hidden="true" /> : undefined}
>
{feel === "ios" ? <Share className="text-primary size-5" aria-hidden="true" /> : null}
</AppWindowTitleBar>
<AppWindowContent className="grid content-start gap-3 p-4">
<p className="text-sm">Hiring, launch dates and the budget review.</p>
<div className="flex gap-2">
<Button size="sm">Share</Button>
<Button size="sm" variant="outline">
Export
</Button>
</div>
</AppWindowContent>
</AppWindow>
<figcaption className="text-muted-foreground text-center text-xs">{label}</figcaption>
</figure>
))}
</div>
)
}API reference
<AppWindow>
| Prop | Type | Default |
|---|---|---|
feelWhich platform's chrome to draw. Defaults to the page's feel (`data-feel`), which Feel Auto sets from the device. | FeelId | — |
activeWhether the window is the focused one. Defaults to following the real window focus. | boolean | — |
maximizedThe window fills the screen: corners square off and the Windows button shows "restore". | boolean | false |
onCloseCalled by the close button. Without a handler the button is drawn but not focusable. | () => void | — |
onMinimizeCalled by the minimize button. | () => void | — |
onMaximizeCalled by the maximize (Windows) or zoom (macOS) button, and when the title bar is double-clicked. | () => void | — |
onFullscreenCalled by the macOS green button; hold Option to call onMaximize instead. Falls back to onMaximize. | () => void | — |
<AppWindowControls>
| Prop | Type | Default |
|---|---|---|
feel | FeelId | — |
<AppWindowTitleBar>
| Prop | Type | Default |
|---|---|---|
titleThe window title. Centered on macOS and iOS, at the start beside the icon on Windows. | React.ReactNode | — |
iconA small icon before the title (Windows). | React.ReactNode | — |
leadingItems after the controls and title, at the start (a back button, a sidebar toggle). | React.ReactNode | — |
controlsDraw the window controls. Turn off when something else draws them. | boolean | true |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Reaches the window controls when their handlers are set, then the toolbar |
| Enter / Space | Presses the focused window control |
| Double-click the bar | Maximizes or restores (calls onMaximize) |
Use with AI
Real window chrome for a Tauri or Electron app. <AppWindow onClose onMinimize onMaximize><AppWindowTitleBar title="…">toolbar</AppWindowTitleBar><AppWindowContent/></AppWindow>. It draws the platform's controls (macOS traffic lights on the left, Windows caption buttons on the right, none on iOS or web) from the page's feel, drags the window, and maximizes on double-click. You wire the callbacks; there is no Tauri or Electron dependency. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI App Window (@ballmac/app-window) to this project with the shadcn MCP, then use it where it fits.
Use it for
- The window of a Tauri or Electron app with a hidden native title bar (decorations: false, or titleBarStyle hidden)
- A desktop-style web app that should match the platform's window chrome
Not for
- Landing-page mockups of a Mac app (use mac-window)
- Modal dialogs inside a web app (use dialog)
Registry JSON: https://ui.ballmac.com/r/app-window.json
Credits
Based on tauri-controls, adapted by Ballmac. Free to use in personal and commercial projects.
- npm
- None
Pairs well with
Feel Auto
Picks the platform feel for you: macOS, iOS, Windows or web, from the device, before the first paint. All four feels are included and a user's override is remembered.
Sidebar
An app sidebar with icon-rail collapse, floating and inset variants, grouped menus with badges and sub-items, a mobile sheet, tooltips when collapsed and a Cmd/Ctrl+B shortcut.
Toolbar
A frosted macOS toolbar with a title block, icon and labeled buttons, segmented view switches, an expanding search field, separators and spacers, built on Radix Toolbar so the whole bar is one tab stop.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.