Docs
Platform feels
A feel is everything about a product except its colours: sizes, corners, focus, shadows, menus, sidebars, window controls and how it moves. Pick macOS, iOS, Windows or the neutral web feel and every component follows.
Try them side by side in the theme builder. A feel works on top of any colour theme, so Ocean with the Windows feel is a valid combination.
These are web components that feel like each platform, not native controls. Ballmac UI is not affiliated with Apple or Microsoft; the feels are original work informed by open-source design tokens and by measuring the platforms.
The four feels
| Feel | Controls | Corners | Focus ring | Window controls |
|---|---|---|---|---|
| WebNeutral, as it is today | 36 px | 10 px | 3px, 50% | None |
| macOSCompact, soft shadows, frosted menus | 28 px | 6 px | 3px, 50% | Left (traffic lights) |
| iOSBig targets, rounded, dims on press | 44 px | 12 px | 3px, 40% | None |
| WindowsSquare-ish, bordered, crisp 2 px focus | 32 px | 4 px | 2px, 100% | Right (caption buttons) |
- Web: Ballmac UI's own feel and the default: shadcn proportions, a soft focus halo and Tailwind's shadows. Nothing changes until you pick another feel.
@ballmac/feel-web - macOS: Compact 28 px controls, 13 px text, a soft focus halo, hairline-edged shadows and frosted popovers and sidebars. For desktop apps built with Tauri or Electron that should sit comfortably next to native Mac apps.
@ballmac/feel-macos - iOS: 44 px touch targets, 17 px text, large corners, flat grouped surfaces and a springier motion. Controls dim when pressed and nothing reacts to hover. For mobile web apps and webviews.
@ballmac/feel-ios - Windows: 4 px corners, 32 px controls, Fluent-style layered shadows, a 2 px solid focus outline and a fast, no-bounce motion. For desktop apps that should sit comfortably next to native Windows apps.
@ballmac/feel-windows
Pick one feel
For an app that always runs on one platform, install its feel. It changes handling only and leaves your colours alone.
npx shadcn@latest add @ballmac/feel-macosOr let the device pick
A Tauri or Electron app that ships to Mac and Windows, or a web app used on phones and desktops, should handle like the platform it is on. feel-auto includes all four feels and sets the right one before the first paint, so there is no flash.
npx shadcn@latest add @ballmac/feel-autoimport { FeelScript } from "@/components/ballmac/feel-script"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<FeelScript />
</head>
<body>{children}</body>
</html>
)
}Let people override it, and go back to the device's own feel with auto. The choice is remembered in the browser.
import { useFeel } from "@/lib/ballmac/feel"
const { feel, setFeel } = useFeel()
setFeel("windows") // "web" | "macos" | "ios" | "windows"
setFeel("auto") // the device's own feelWhat follows automatically
- Shadows and text sizes. Tailwind's
shadow-xstoshadow-2xlandtext-xs,text-sm,text-baseare rewritten, so every component and your own classes follow. - Radius and density. The usual
--radiusand--spacing, set by the feel. - The focus ring. One rule restyles every component that uses shadcn's standard
focus-visible:ring-[3px], which includes shadcn's own components. A few Ballmac items draw their own ring and keep it. - Controls, menus and sidebars. Button, Input, Select and Tabs heights, menu rows and corners, sidebar rows (including the Windows accent pill), frosted popovers and sidebars, press dimming on iOS, scrollbars.
- Motion. Each feel brings a motion personality that suits it.
Tokens are plain CSS variables, so you can change any of them:
:root {
--bm-control-h: 2rem; /* button and input height */
--bm-focus-width: 2px; /* focus ring */
--bm-focus-alpha: 100%;
--bm-shadow-sm: 0 1px 2px rgb(0 0 0 / .12);
--bm-menu-item-h: 2rem; /* menu rows */
--bm-sidebar-item-h: 2.5rem; /* sidebar rows */
--bm-sidebar-indicator: 3px; /* the accent pill on the selected row */
--bm-material-blur: 30px; /* frosted menus and sidebars */
--bm-press-dim: 0.6; /* opacity of a pressed button */
}Desktop apps
app-window is the window chrome of a real app: a title bar that drags the window, maximizes on double-click, and carries the controls the platform draws. It has no Tauri or Electron dependency; you pass the actions in.
npx shadcn@latest add @ballmac/app-window @ballmac/feel-autoTauri v2
Turn off the native frame, allow the window actions, and wire the buttons:
{
"app": {
"windows": [{ "title": "Notes", "decorations": false }]
}
}{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-set-fullscreen",
"core:window:allow-start-dragging"
]
}import { getCurrentWindow } from "@tauri-apps/api/window"
import { AppWindow, AppWindowContent, AppWindowTitleBar } from "@/components/ballmac/app-window"
const win = getCurrentWindow()
export default function App() {
const [maximized, setMaximized] = useState(false)
useEffect(() => {
win.isMaximized().then(setMaximized)
const unlisten = win.onResized(() => win.isMaximized().then(setMaximized))
return () => void unlisten.then((f) => f())
}, [])
return (
<AppWindow
className="h-screen rounded-none border-0"
maximized={maximized}
onClose={() => win.close()}
onMinimize={() => win.minimize()}
onMaximize={() => win.toggleMaximize()}
onFullscreen={async () => win.setFullscreen(!(await win.isFullscreen()))}
>
<AppWindowTitleBar title="Notes" />
<AppWindowContent>…</AppWindowContent>
</AppWindow>
)
}Electron
const win = new BrowserWindow({ frame: false, webPreferences: { preload: join(__dirname, "preload.js") } })
ipcMain.on("window", (event, action: "close" | "minimize" | "maximize") => {
const w = BrowserWindow.fromWebContents(event.sender)
if (!w) return
if (action === "maximize") w.isMaximized() ? w.unmaximize() : w.maximize()
else w[action]()
})contextBridge.exposeInMainWorld("windowControls", {
send: (action: "close" | "minimize" | "maximize") => ipcRenderer.send("window", action),
})<AppWindow
onClose={() => window.windowControls.send("close")}
onMinimize={() => window.windowControls.send("minimize")}
onMaximize={() => window.windowControls.send("maximize")}
/>The title bar sets both data-tauri-drag-region and -webkit-app-region: drag, and the toolbar inside it is marked no-drag, so buttons stay clickable.
Know the limits
- Windows 11's snap layouts (hovering the maximize button) belong to native caption buttons. If you need them, use Electron's
titleBarOverlayand passcontrols={false}to the title bar. - On macOS, drawn traffic lights do not get the system's window-tiling menu. Keep the native frame (
titleBarStyle: "hidden"in Electron) if you rely on it. - Most of the Desktop collection is Mac-only by nature (Dock, Menu Bar, Spotlight, Launchpad, Dynamic Island, Control Center) and stays that way.
app-window, the sidebar, menus and the form controls are the parts every platform shares.
Links
# open a feel in the builder
https://ui.ballmac.com/themes?fe=windows
# install a custom design with a feel, colours and motion together
npx shadcn@latest add "https://ui.ballmac.com/themes/custom.json?h=262&fe=macos"