Style the native macOS DocumentGroup document tabs. Bar background, per-tab
fill, active-tab outline, label colors, the + button - and dynamic per-tab
colors at runtime - without leaving DocumentGroup and without building your own
tab bar.
The common wisdom is that you can't restyle the native window/document tab bar
- it's private, end of story. You mostly can. Tabberwocky is the small, current (macOS 26 "liquid glass"–aware) library that does it.
See it in action — themes, per-tab colors, right-click recolor, and collapsible tab groups:
Tabberwocky_Demo.mp4
Warning
Tabberwocky reaches into the private AppKit tab-bar view tree
(NSTabBar / NSTabButton). It is not App Store-safe - review can reject
binaries that reference private class names. Gate it behind a non-App-Store build
flag (Developer ID / Setapp / direct distribution). See Caveats.
A SwiftUI document app gets multi-document tabs for free:
DocumentGroup(newDocument: MyDocument()) { file in
EditorView(document: file.$document)
}…but those tabs are drawn by a private NSTabBar, and Apple ships no public API
to style them. So you're stuck with stock gray tabs, or you throw away
DocumentGroup and rebuild tabs (and Save / Versions / restoration / ⌘1–9) from
scratch. Tabberwocky is the third option: keep all the native machinery, restyle
the chrome.
| Element | |
|---|---|
| Bar background | matches your titlebar |
| Per-tab fill | any color/alpha - uniform, by index, by tag, or a user pick |
| Active outline | accent border on the selected tab |
| Label colors | active / inactive, plus per-tab via textForTab (sets attributedTitle) |
+ button |
tint the glyph |
The same tab bar, restyled live. Tabberwocky takes any colors you give it.
Swift Package Manager
.package(url: "https://github.com/uncSoft/Tabberwocky", from: "1.0.0")…then import Tabberwocky.
Single file
Or just drag Sources/Tabberwocky/Tabberwocky.swift
into your target. No dependencies. Two optional add-on files:
TabberwockyColorStore.swift for
persistence, and
TabberwockyGroups.swift for
tab groups.
Call once at launch (e.g. in your AppDelegate), behind your distribution flag:
#if DIRECT_DISTRIBUTION || SETAPP_DISTRIBUTION
Tabberwocky.shared.style = {
TabberwockyStyle(
barBackground: .black,
activeFill: NSColor.white.withAlphaComponent(0.10),
inactiveFill: NSColor.white.withAlphaComponent(0.04),
activeText: .systemBlue,
inactiveText: NSColor.white.withAlphaComponent(0.55),
activeOutline: .systemBlue,
newButtonTint: .systemBlue
)
}
Tabberwocky.shared.start() // re-applies on key/update/resize
#endifYou also need native window tabbing on (standard for document apps):
NSWindow.allowsAutomaticWindowTabbing = true
UserDefaults.standard.set("always", forKey: "AppleWindowTabbingMode")When your theme changes, call Tabberwocky.shared.refresh() - or pass your own
notification to start(reapplyOn:):
Tabberwocky.shared.start(reapplyOn: [.myAppearanceChanged])Return a color per tab from fillForTab (return nil to use style). The tab's
documentURL lets you key off the file - color by tag, by a user pick, anything.
Indexed colors (a "rainbow") work too:
Tabberwocky.shared.fillForTab = { index, documentURL, active in
if let chosen = myColorStore[documentURL] { return chosen } // user pick
return rainbow[index % rainbow.count] // or by index
}Labels are colored from style.activeText / style.inactiveText by default. For
per-tab control there's a textForTab closure, symmetric with fillForTab - return
a color to override a single tab's label, or nil to fall back to the style.
The example wires this to a right-click "Label Color" menu, separate from the tab fill.
The library doesn't force any label color on you. The common case is keeping labels legible on saturated fills, which is one line - give any tab that has a custom fill a white label:
Tabberwocky.shared.textForTab = { index, documentURL, active in
guard Tabberwocky.shared.fillForTab?(index, documentURL, active) != nil else {
return nil // use style.activeText/inactiveText
}
return NSColor.white.withAlphaComponent(active ? 1.0 : 0.85)
}You can just as easily color labels by tag, dim background tabs harder, or anything else - it's the same per-tab signature as the fill.
fillForTab / textForTab are stateless, so where colors live is up to you. If
you don't want to build that yourself, add the optional
TabberwockyColorStore.swift. It
saves per-document fill and label colors to UserDefaults, keyed by file URL, so a
tab keeps its color across launches.
The whole thing, persisted, in four lines:
let colors = TabberwockyColorStore()
colors.attach() // wires fillForTab + textForTab
Tabberwocky.shared.style = { /* your theme */ }
Tabberwocky.shared.start(reapplyOn: [TabberwockyColorStore.colorsChanged])Then set a color when the user picks one, and it sticks:
colors.setColor(.systemTeal, for: document.fileURL, .fill)
colors.setColor(.systemYellow, for: document.fileURL, .label)
colors.setColor(nil, for: document.fileURL, .fill) // clearIf you already compose your own fillForTab (rainbow, tag colors, whatever), skip
attach() and just read colors.color(for:_:) inside your closures - the store is
only the storage, it doesn't take over. Pass a custom UserDefaults /
storageKey to the initializer if you need to namespace or use an app group.
Optional add-on: TabberwockyGroups.swift.
Safari-style document groups for a DocumentGroup app: a color-coded sidebar of
groups you can collapse to hide their tabs and expand to bring them back — in
order, with no flicker.
How the hiding works (the shadow window). A native tab group shows all its member windows, and there's no supported way to hide just one. The naive fix — ordering windows out and re-merging — is unstable (tabs pop into their own windows, flicker, go unresponsive). Instead, Tabberwocky parks a collapsed group's windows inside an off-screen, transparent "shadow" window's tab group, so they leave the visible bar while staying validly tabbed (no homeless windows → no stray singlets, the trick real apps use). Expanding moves them back, and the bar is rebuilt in canonical model order with minimal moves (already-visible tabs never shift, so they never flash "active" as others animate in). It's window-tabbing only — no private API.
Hand each document window to the engine as you open it, then drive it from your UI:
let groups = TabberwockyGroups() // one shared instance
// as each document window opens:
groups.register(window, url: doc.fileURL!, group: "Notes")
// color tabs by group (compose with your own fillForTab, or use attachColors()):
Tabberwocky.shared.fillForTab = { _, url, _ in groups.color(forURL: url) }
Tabberwocky.shared.start(reapplyOn: [TabberwockyGroups.didChange])
// from your sidebar / menus:
groups.assign(url, to: id) // move a doc to a group (recolors its tab)
groups.addGroup("Drafts") // new group
groups.toggle(group.id) // collapse → hide the group's tabs; expand → restore
groups.select(url) // jump to a doc's tabgroups is an ObservableObject (@Published groups), so a SwiftUI sidebar reacts
live. The example's GroupSidebar is a ready reference. Collapsing the only expanded
group re-expands the rest, so a header click is never a dead no-op.
Set TabberwockyGroups.debugLogging = true to log the full window / tab-group
topology to the console (Console.app) while debugging collapse/expand.
Examples/DocumentTabsShowcase is a real,
minimal DocumentGroup app that consumes Tabberwocky as a Swift package
(via a local path while developing) and demonstrates everything:
- 6 themes incl. Rainbow (per-tab colors)
- Right-click a tab → preset / custom color / "Color from #tag" / clear - with live recolor as you edit the tag
- Tab groups: a sidebar of color-coded groups you can
collapse to hide their tabs (and expand to restore them, in order), plus
create-group and right-click "Move to Group", driven by the library's
TabberwockyGroups.GroupSidebaris the reference SwiftUI for it. - It opens its own source (and this library) as tabs, so it's self-documenting
cd Examples/DocumentTabsShowcase && ./build.sh && open DocumentTabsShowcase.appShort version: walk down from window.contentView?.superview to find the private
NSTabBar, then set layer colors / borders and KVC attributedTitle on the tab
views, re-applying on the notifications AppKit fires when it repaints. Active tab
is found via the public NSWindowTabGroup (the tab views aren't NSButtons, so
they have no .state), and each tab resolves to its document by tab-group window
order — window.tabGroup.windows[i] → windowController.document.fileURL — not by
matching the title string (which breaks on duplicate filenames or hidden extensions).
The full write-up - verified view hierarchy, every piece enumerated, and the
dead-ends - is in
docs/custom-document-group-tab-styling.md.
- Not App Store-safe. Private AppKit class names → gate to direct/Setapp builds.
- Private hierarchy. Class names/structure can shift between macOS releases; Tabberwocky fails soft (does nothing) if it can't find the views. Verified on macOS 26.5.
- The Tahoe glass floor. On macOS 26 each tab's label/icon live inside an
NSGlassEffectView, so you can tint the glass but can't fully remove it - a perfectly matte tab isn't possible without losing the label. - Re-applies on notifications, not a timer. AppKit repaints the bar on its own; Tabberwocky reasserts the style (coalesced) rather than polling.
- Main-thread only. Everything is
@MainActor(it touchesNSApp/NSView); call it from the main thread. Clean under Swift 6 strict concurrency. - You own contrast / accessibility. Tabberwocky doesn't consult Reduce Transparency or Increase Contrast — if you use saturated fills, pick label colors that stay legible (and consider solid fills under Reduce Transparency).
- OS support. Builds back to macOS 13 (it fails soft on older private layouts); the styling is visually verified on macOS 26.5. The private hierarchy on 13/14 is not separately verified — test on your floor.
The whole private-API blast radius is one table — TabberwockyPrivateNames
(the class names like NSTabBar/NSTabButton/NSGlassEffectView and the KVC keys
title/attributedTitle/tintColor). When a macOS beta moves something, there's
one place to patch, and anyone auditing before they ship can read exactly what's
private.
Paired with it is a canary: Tabberwocky.probe() walks a live tabbed window and
reports which of those names/keys are still present.
let result = Tabberwocky.probe() // run in a DocumentGroup window with ≥2 tabs
print(result) // ✓/✗ per class + key
if !result.allFound { /* a beta moved something — investigate before shipping */ }Run it against each beta and it goes red the moment Apple renames something, telling
you which knob broke instead of leaving you eyeballing gray tabs. Adopters can even
gate their own start() on probe().allFound. It doesn't make this App-Store-safe
(nothing does) — it just makes the betas boring.
Tabberwocky is pre-2.0 and the API may still move; pin to a version. Planned:
- Extensibility - more hooks: per-tab icons, custom fonts, a
willStyleTabcallback, and a pluggable tab→document resolver.
Contributions and ideas welcome.
Using Tabberwocky only requires keeping the MIT license notice. If you'd like to credit it in your app (an About / acknowledgements screen, a README, a tweet), that's appreciated but optional. Copy-paste:
Tab styling by Tabberwocky (MIT)
Plain text:
Tabberwocky by uncSoft - https://github.com/uncSoft/Tabberwocky
Or pull it from code (TabberwockyInfo):
Text(TabberwockyInfo.attribution) // "Tab styling by Tabberwocky (MIT) · …"
Link("Tabberwocky", destination: TabberwockyInfo.url)MIT - see LICENSE.






