Ballmac UI home

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

What each platform feel sets
FeelControlsCornersFocus ringWindow controls
WebNeutral, as it is today36 px10 px3px, 50%None
macOSCompact, soft shadows, frosted menus28 px6 px3px, 50%Left (traffic lights)
iOSBig targets, rounded, dims on press44 px12 px3px, 40%None
WindowsSquare-ish, bordered, crisp 2 px focus32 px4 px2px, 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-macos

Or 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-auto
app/layout.tsx
import { 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 feel

What follows automatically

  • Shadows and text sizes. Tailwind's shadow-xs to shadow-2xl and text-xs, text-sm, text-base are rewritten, so every component and your own classes follow.
  • Radius and density. The usual --radius and --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-auto

Tauri v2

Turn off the native frame, allow the window actions, and wire the buttons:

src-tauri/tauri.conf.json
{
  "app": {
    "windows": [{ "title": "Notes", "decorations": false }]
  }
}
src-tauri/capabilities/default.json
{
  "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"
  ]
}
src/App.tsx
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

main.ts
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]()
})
preload.ts
contextBridge.exposeInMainWorld("windowControls", {
  send: (action: "close" | "minimize" | "maximize") => ipcRenderer.send("window", action),
})
renderer
<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 titleBarOverlay and pass controls={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.
# 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"