Bonsplit
Bonsplit is a custom tab bar and layout split library for macOS apps. Out of the box 120fps animations, drag-and-drop reordering, SwiftUI support & keyboard navigation.
Install / Use
/learn @almonk/BonsplitREADME
Bonsplit
A native macOS tab bar library with split pane support for SwiftUI applications.
Features
- Native macOS look and feel using system colors
- Drag-and-drop tab reordering within and between panes
- Horizontal and vertical split panes with smooth 120fps animations
- Configurable appearance and behavior
- Delegate callbacks for all tab and pane events
- Keyboard navigation between panes
- Optional macOS-like tab state preservation (scroll position, focus, @State)
Requirements
- macOS 14.0+
- Swift 5.9+
- Xcode 15.0+
Installation
Swift Package Manager
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/almonk/bonsplit.git", from: "1.1.1")
]
Or in Xcode: File → Add Package Dependencies → Enter the repository URL.
Quick Start
import SwiftUI
import Bonsplit
struct ContentView: View {
@State private var controller = BonsplitController()
@State private var documents: [TabID: Document] = [:]
var body: some View {
BonsplitView(controller: controller) { tab in
// Content for each tab
if let document = documents[tab.id] {
DocumentEditor(document: document)
}
} emptyPane: { paneId in
// Custom view for empty panes (optional)
VStack {
Text("No Open Files")
Button("New File") {
createDocument(inPane: paneId)
}
}
}
.onAppear {
// Create initial tab
if let tabId = controller.createTab(title: "Untitled", icon: "doc.text") {
documents[tabId] = Document()
}
}
}
}
Note: Splits create empty panes by default, giving you full control. Use the didSplitPane delegate method to auto-create tabs if desired.
API Reference
BonsplitController
The main controller for managing tabs and panes.
Tab Operations
// Create a new tab
let tabId = controller.createTab(
title: "Document.swift",
icon: "swift", // SF Symbol name (optional)
isDirty: false, // Show dirty indicator (optional)
inPane: paneId // Target pane (optional, defaults to focused)
)
// Update tab properties
controller.updateTab(tabId, title: "NewName.swift")
controller.updateTab(tabId, isDirty: true)
controller.updateTab(tabId, icon: "doc.text")
// Close a tab
controller.closeTab(tabId)
// Select a tab
controller.selectTab(tabId)
// Navigate tabs
controller.selectPreviousTab()
controller.selectNextTab()
Split Operations
// Split the focused pane (creates empty pane)
let newPaneId = controller.splitPane(orientation: .horizontal) // Side-by-side
let newPaneId = controller.splitPane(orientation: .vertical) // Stacked
// Split a specific pane
controller.splitPane(paneId, orientation: .horizontal)
// Split with a tab already in the new pane
controller.splitPane(orientation: .horizontal, withTab: Tab(title: "New", icon: "doc"))
// Close a pane
controller.closePane(paneId)
Note: By default, splitPane() creates an empty pane. You have full control over when and how to add tabs. Use the didSplitPane delegate callback to create a tab in the new pane if you want automatic tab creation.
Focus Management
// Get focused pane
let focusedPane = controller.focusedPaneId
// Focus a specific pane
controller.focusPane(paneId)
// Navigate between panes
controller.navigateFocus(direction: .left)
controller.navigateFocus(direction: .right)
controller.navigateFocus(direction: .up)
controller.navigateFocus(direction: .down)
Query Methods
// Get all tabs
let allTabs = controller.allTabIds
// Get all panes
let allPanes = controller.allPaneIds
// Get tab info
if let tab = controller.tab(tabId) {
print(tab.title, tab.icon, tab.isDirty)
}
// Get tabs in a pane
let paneTabs = controller.tabs(inPane: paneId)
// Get selected tab in a pane
let selected = controller.selectedTab(inPane: paneId)
Geometry & Synchronization
Query pane geometry and save/restore layout configurations:
// Get flat list of pane geometries with pixel coordinates
let snapshot = controller.layoutSnapshot()
for pane in snapshot.panes {
print("Pane \(pane.paneId): \(pane.frame.width)x\(pane.frame.height)")
}
// Get full tree structure
let tree = controller.treeSnapshot()
// Set divider position programmatically (0.0-1.0)
controller.setDividerPosition(0.3, forSplit: splitId, fromExternal: true)
// Update container frame when window moves
controller.setContainerFrame(newFrame)
| Method | Description |
|--------|-------------|
| layoutSnapshot() | Get current pane geometry with pixel coordinates |
| treeSnapshot() | Get full tree structure for external consumption |
| findSplit(_:) | Check if a split exists by UUID |
| setDividerPosition(_:forSplit:fromExternal:) | Programmatically set divider position |
| setContainerFrame(_:) | Update container frame |
Tab
Read-only snapshot of tab metadata.
public struct Tab {
public let id: TabID
public let title: String
public let icon: String?
public let isDirty: Bool
}
BonsplitDelegate
Implement this protocol to receive callbacks about tab bar events.
class MyDelegate: BonsplitDelegate {
// Veto tab creation
func splitTabBar(_ controller: BonsplitController,
shouldCreateTab tab: Tab,
inPane pane: PaneID) -> Bool {
return true // Return false to prevent
}
// Veto tab close (e.g., prompt to save)
func splitTabBar(_ controller: BonsplitController,
shouldCloseTab tab: Tab,
inPane pane: PaneID) -> Bool {
if tab.isDirty {
return showSaveConfirmation()
}
return true
}
// React to tab selection
func splitTabBar(_ controller: BonsplitController,
didSelectTab tab: Tab,
inPane pane: PaneID) {
updateWindowTitle(tab.title)
}
// React to splits - new panes are empty by default
func splitTabBar(_ controller: BonsplitController,
didSplitPane originalPane: PaneID,
newPane: PaneID,
orientation: SplitOrientation) {
// Option 1: Auto-create a tab
controller.createTab(title: "Untitled", icon: "doc.text", inPane: newPane)
// Option 2: Leave empty - the emptyPane view will be shown
}
}
All delegate methods have default implementations and are optional.
Available Delegate Methods
| Method | Description |
|--------|-------------|
| shouldCreateTab | Called before creating a tab. Return false to prevent. |
| didCreateTab | Called after a tab is created. |
| shouldCloseTab | Called before closing a tab. Return false to prevent. |
| didCloseTab | Called after a tab is closed. |
| didSelectTab | Called when a tab is selected. |
| didMoveTab | Called when a tab is moved between panes. |
| shouldSplitPane | Called before creating a split. Return false to prevent. |
| didSplitPane | Called after a split is created. Use this to create a tab in the new empty pane. |
| shouldClosePane | Called before closing a pane. Return false to prevent. |
| didClosePane | Called after a pane is closed. |
| didFocusPane | Called when focus changes to a different pane. |
| didChangeGeometry | Called when any pane geometry changes (resize, split, close). |
| shouldNotifyDuringDrag | Return true for real-time notifications during divider drag. |
Geometry Notifications
Receive callbacks when pane geometry changes:
func splitTabBar(_ controller: BonsplitController,
didChangeGeometry snapshot: LayoutSnapshot) {
// Save layout configuration
let encoder = JSONEncoder()
if let data = try? encoder.encode(snapshot) {
UserDefaults.standard.set(data, forKey: "savedLayout")
}
}
// Opt-in to real-time notifications during divider drag
func splitTabBar(_ controller: BonsplitController,
shouldNotifyDuringDrag: Bool) -> Bool {
return true // Enable frame-by-frame updates
}
BonsplitConfiguration
Configure behavior and appearance.
let config = BonsplitConfiguration(
allowSplits: true, // Enable split buttons and drag-to-split
allowCloseTabs: true, // Show close buttons on tabs
allowCloseLastPane: false, // Prevent closing the last pane
allowTabReordering: true, // Enable drag-to-reorder
allowCrossPaneTabMove: true, // Enable moving tabs between panes
autoCloseEmptyPanes: true, // Close panes when last tab is closed
contentViewLifecycle: .recreateOnSwitch, // How tab views are managed
newTabPosition: .current, // Where new tabs are inserted
appearance: .default
)
let controller = BonsplitController(configuration: config)
Content View Lifecycle
Controls how tab content views are managed when switching between tabs:
// Memory efficient (default) - only selected tab is rendered
// Loses scroll position, @State, focus when switching tabs
contentViewLifecycle: .recreateOnSwitch
// macOS-like behavior - all tab views stay in memory
// Preserves scroll position, @State, focus, text selection, etc.
contentViewLifecycle: .keepAllAlive
| Mode | Memory | State Preservation | Use Case |
|------|--------|-------------------|----------|
| .recreateOnSwitch | Low | None | Simple content, external state management |
| .keepAllAlive | Higher | Full | Complex views, scroll positions, form inputs |
New Tab Position
Controls where new tabs are inserted in the tab list:
// Insert after currently focused tab (default)
newTabPosition: .current
// Always insert at the end of the tab list
newTabPosition: .end
| Mode |
Related Skills
openhue
341.2kControl Philips Hue lights and scenes via the OpenHue CLI.
sag
341.2kElevenLabs text-to-speech with mac-style say UX.
weather
341.2kGet current weather and forecasts via wttr.in or Open-Meteo
tweakcc
1.5kCustomize Claude Code's system prompts, create custom toolsets, input pattern highlighters, themes/thinking verbs/spinners, customize input box & user message styling, support AGENTS.md, unlock private/unreleased features, and much more. Supports both native/npm installs on all platforms.
