What It Does
Hold Option and tap Tab — a panel appears showing every open window across all your apps, on the screen where your mouse is. Cycle through them with Tab / Shift-Tab or the arrow keys, then release Option to jump straight to the selected window. Click any thumbnail to switch instantly.
| Shortcut | Action |
|---|---|
Option-Tab |
Open switcher / next window |
Tab |
Cycle forward |
Shift-Tab |
Cycle backward |
← / → |
Navigate left / right |
Escape |
Cancel |
Enter |
Confirm and switch |
| Click | Select and switch immediately |
The switcher includes minimized windows (they unminimize when selected), windows of Cmd-H-hidden apps, and windows sitting on other Spaces. No Dock icon, no clutter — just a clean menu bar icon with a small preferences menu.
What's New in 1.3.3
-
Glass Strength. Liquid Glass now has four levels — Light / Medium / High / Max — in the status menu (macOS 26+).
NSGlassEffectViewexposes no intensity control, so the levels are emulated: High is the original regular glass; Light and Medium lay an appearance-adaptive translucent plate over it, calming the effect toward the Solid look; Max switches to Apple's clear glass style, the most see-through. Takes effect on the nextOption-Tab. -
Homebrew.
brew install --cask sergio-farfan/tap/alttab— and the release pipeline now bumps the cask itself, sobrew upgrade --cask alttabtracks every release. - First-launch guidance for the unsigned build follows Apple's current flow (System Settings → Privacy & Security → Open Anyway).
What's New in 1.3.2
1.3.1 fixed which slot the first Tab lands on; 1.3.2 makes sure the list itself is never stale — and makes the slow paths fast:
-
The window list stays warm. The list used to be re-gathered only while the switcher was open, so the first
Option-Tabafter a long idle opened from a snapshot frozen at your previous session. Now every focus change, app activation, launch, and termination schedules a debounced background refresh — rate-limited, so sustained window-hopping costs one sweep per 8 seconds, not one per switch. - A busy app can't corrupt the order. A single 0.25s Accessibility timeout from a wedged or App-Napped app used to silently drop its minimized and other-Space windows from the list and permanently demote their MRU ranks. Windows of apps that fail to answer are now carried over from the previous gather, ranks intact.
-
Option-Shift-Tabopens the switcher backward. Just like Windows: the initial reverse invoke anchors on the least-recently-used window and keeps cycling backward from there. - Faster everywhere it was slow. The per-app Accessibility pass now runs concurrently instead of stacking per-app timeouts sequentially; raising or unminimizing a window of a wedged app is bounded by per-window timeouts (previously up to ~6s per call); the focused-window probe moved off the main thread so a slow app can't stall the event tap; app icons resolve off the main thread too.
- The pure-logic core is now five unit-tested types (
SwitcherSelection,MRUOrder,SwitcherStateMachine,GatherMerge,Debouncer) — 83 tests.
What's New in 1.3.1
This release is entirely about the first Option-Tab. The bug report that started it: after the switcher had sat idle for a while, a single Tab would sometimes land two or three windows back instead of on the window you had just left. An adversarially verified code review traced it to five root causes. All five are fixed, and the selection logic that decides which slot the first Tab lands on is now a pure, unit-tested type.
- The first Tab lands on the previous window — every time. The panel opens from a cached window list re-sorted by live MRU order, so a window you had opened (or closed) since the last gather was missing from (or lingering in) the list, shifting every slot by one. The initial highlight is now anchored against the window that actually has focus, and the mid-session reconcile re-anchors against the fresh list unless you have already cycled or clicked.
-
Background apps can no longer scramble the order.
kAXFocusedWindowChangedfires for non-frontmost apps too — Electron/Chromium window churn, a Terminal window closing when its job finishes — and each event used to silently jump to the front of the MRU while you worked elsewhere. Only the frontmost app's focus changes count now. - No more phantom switches. Confirming a window that had been closed since the last gather used to fall through to "raise the app's first window" — an arbitrary one. Confirm now verifies liveness with one batched WindowServer query and falls through to the next real window; the arbitrary-raise fallback is gone.
-
Intra-app tracking survives updates. Every release re-prompts for Accessibility (ad-hoc signing), and the per-app AX observers were registered before the grant — the failures were stored as if they had succeeded, so
Cmd-`switches went untracked for the whole session. Observers now register only on success, reinstall the moment the grant arrives, retry for just-launched apps, and self-heal on an app's first activation. - Rapid toggling is stable. Option-Tab-ing straight back after a switch treats the in-flight activation as focus ground truth, so a quick flick can't anchor on the window you just switched to and turn into a no-op.
What's New in 1.3
-
Liquid Glass. On macOS 26 (Tahoe), the switcher can render on Apple's native glass material via
NSGlassEffectView— the real thing, not a blur imitation. Status menu → Background → Liquid Glass. -
Selectable backgrounds. Three styles, applied on the next
Option-Tabwith no relaunch: Solid (default — opaque, theme-adaptive), Transparent (the classic translucent HUD with vibrancy), and Liquid Glass. - Appearance override. The panel follows the OS Light/Dark theme by default (including scheduled Auto switching), and you can force Light or Dark from the menu regardless of the system setting.
- WCAG-tested readability. The text under each window is no longer hardcoded white on a translucent panel. Labels use semantic system colors, and on the default Solid background the label/background pair meets WCAG 2.x AA (contrast ≥ 4.5:1) — not as an aspiration, but verified by unit tests that resolve the live system colors under both Light and Dark appearances and compute the actual contrast ratio. If Apple ever changes the palette in a way that breaks AA, the test suite fails.
What's New in 1.2
Instant-open switcher (cached window list, reconciled off the main thread), a 0.25s messaging timeout on every Accessibility call so one wedged app can't freeze the hotkey, opt-in live window previews via ScreenCaptureKit (macOS 14+, no Screen Recording prompt unless you enable it), click-to-switch fixed, multi-monitor support, and a unit-tested core.
Installation
Requirements: macOS 13 Ventura or later (Liquid Glass needs macOS 26)
Download (recommended)
Grab the prebuilt universal DMG (Apple Silicon + Intel) from the releases page, drag AltTab to Applications, and launch it. The build is not notarized yet, so macOS blocks the first launch: open System Settings → Privacy & Security, click Open Anyway, then launch again (or clear the quarantine flag):
xattr -dr com.apple.quarantine /Applications/AltTab.app
Homebrew
brew install --cask sergio-farfan/tap/alttab
The fully qualified name trusts just this cask (Homebrew 6+ requires third-party taps to be trusted). For short names, brew trust sergio-farfan/tap && brew tap sergio-farfan/tap first. Updates: brew upgrade --cask alttab. The same first-launch Open Anyway step applies.
Build from source
git clone https://github.com/sergio-farfan/alttab-macos.git && cd alttab-macos && ./build.sh install && open ~/Applications/AltTab.app
./build.sh build # Build Release binary only
./build.sh install # Build and install to ~/Applications
./build.sh run # Build and launch immediately
./build.sh uninstall # Remove the installed app
swift test # Run the unit tests (90)
Note: building the Liquid Glass code requires Xcode 26+ (macOS 26 SDK); the app still runs on macOS 13+.
Permissions
-
Accessibility (required) — intercepts the
Option-Tabhotkey, reads window titles, raises windows - Screen Recording (never prompted by default) — only requested if you explicitly enable "Show Window Previews" in the menu
How It Works
Global hotkey interception — A CGEvent tap installed at the session level intercepts Option-Tab system-wide before it reaches any app, without stealing keyboard focus. The tap plumbing only decodes events; the session logic lives in a pure SwitcherStateMachine struct, unit-testable without synthesizing CGEvents.
Complete window discovery — On-screen windows come from CGWindowList. Everything else (titles, minimized windows, hidden apps, other Spaces) comes from a single AXUIElement pass per application, with a messaging timeout on every element. Fun fact: AXUIElementSetMessagingTimeout is per-element — setting it on the app element does nothing for the window elements it returns.
Selectable backgrounds — The panel builds one of three root views per style: an opaque NSBox (Solid), a .hudWindow NSVisualEffectView (Transparent — labels become effect-view descendants, so macOS renders them with vibrancy), or an NSGlassEffectView (Liquid Glass) with the content embedded through its contentView property, which is the only placement the SDK header guarantees. The persistent scroll view is re-parented between roots only when the preference actually changes.
MRU ordering — Windows are sorted most-recently-used first via NSWorkspace notifications plus per-app AXObserver callbacks that catch intra-app switches (like Cmd-`) — accepted only from the frontmost app, because background apps fire focus-changed notifications too. Explicit activations are pinned so the asynchronous window-raise can't demote the window you just picked, and the pin is superseded the moment a different app or window takes focus.
Focus-anchored selection — The list you see is served from a cache and can be one gather stale, so the switcher never assumes slot 0 is the window you are on. At open it determines which window really has focus (a fresh in-flight activation if there is one; otherwise the frontmost app's topmost window via a single WindowServer query, escalating to one bounded Accessibility call only when that window is unknown to the cache) and highlights the first window that isn't it. Confirm walks the same order past any window that no longer exists. The whole policy — anchor, cycle, reconcile, confirmation order — lives in a pure SwitcherSelection type with a test table for every stale-cache shape.
Non-activating overlay — The switcher is an NSPanel with .nonactivatingPanel, so it appears without stealing focus. Releasing Option activates the target window, not AltTab.
Two More macOS Gotchas
The 1.3 work surfaced two API behaviors worth knowing about.
withAlphaComponent() freezes dynamic colors. macOS semantic colors like labelColor are dynamic — they resolve differently per appearance, at draw time. But call withAlphaComponent(0.78) on one and you get back a static color, resolved against whatever appearance happens to be current at that call site. My unit test caught this before it shipped: the derived color measured 1.21:1 contrast in Dark mode — because it had frozen the Light variant (black text) and composited it onto a dark background. The fix is NSColor(name:dynamicProvider:), which re-derives the color inside each appearance resolution. If you take one thing from this post: contrast assertions in unit tests that resolve live system colors are cheap, and they catch exactly this class of bug.
#available gates runtime, not compile time. The Liquid Glass code is properly wrapped in #available(macOS 26.0, *) — and the release build still failed in CI with cannot find 'NSGlassEffectView' in scope. Locally everything compiled fine. The difference: my Mac has the macOS 26 SDK; the CI runner had Xcode 16 with the macOS 15 SDK, where the symbol doesn't exist at all. Availability checks assume the symbol is in the SDK you compile against — they only guard which OS executes it. Bumping the CI image to one with Xcode 26 fixed it. If you adopt Tahoe APIs behind #available, check your CI's SDK before you tag the release.
Three More, from 1.3.1
[CGWindowID] as CFArray silently matches nothing. CGWindowListCreateDescriptionFromArray takes a CFArray of window IDs — and the natural Swift bridge, ids as CFArray, compiles, runs, and returns an empty array for every live window. The function wants the IDs stored directly as pointer-sized values with NULL callbacks, not as CFNumbers. My first liveness check did exactly the wrong thing, which would have turned every confirm into a no-op. A ten-line probe against two real window IDs plus one bogus one caught it before it shipped (and four independent review passes flagged it too). The working form:
var values: [UnsafeRawPointer?] = ids.map { UnsafeRawPointer(bitPattern: UInt($0)) }
let array = CFArrayCreate(kCFAllocatorDefault, &values, values.count, nil)
kAXFocusedWindowChanged does not mean "the user focused a window". It means "this app's focused window changed" — and it fires for apps in the background. Chromium- and Electron-based apps churn windows while idle; a Terminal window closing when its job exits moves focus to a sibling. Each event, taken at face value, is a counterfeit "most recently used" entry. Check the element's pid against NSWorkspace.shared.frontmostApplication before you believe it (AXUIElementGetPid reads the pid out of the element token — no IPC).
AXObserverAddNotification returns an error you must read. Before Accessibility is granted it fails with kAXErrorAPIDisabled; right after an app launches it can fail because the app's AX server isn't accepting registrations yet. If you store the observer anyway — and gate "already installed" on that dictionary — the app stays silently unobserved until you relaunch. Since ad-hoc signing re-prompts for Accessibility on every release, that made the first session after every update the broken one. Store on .success only, reinstall when the grant arrives, and let an app's first activation heal anything that slipped through.
When the Accessibility Toggle Lies
(From the 1.2.1 investigation, still the best bug of this project.) After updating, Option-Tab went dead while System Settings showed the Accessibility toggle confidently ON. macOS pins every TCC permission grant to a code signing requirement — for unsigned apps, essentially the cdhash of one exact build. My DMG script built with CODE_SIGNING_ALLOWED=NO, which ships an unsealed bundle, and macOS fabricates unpredictable code identities for those — so the recorded grant never matched the running binary. The toggle renders "an allowed entry exists"; enforcement asks "does this binary match the requirement". Different questions.
Diagnosing it meant reading the csreq blob out of TCC.db with sqlite3, decompiling it with csreq -r -t, comparing cdhashes with codesign -dvvv, and confirming with CGGetEventTapList that no event tap existed. The fix: always apply an ad-hoc seal (codesign --force --deep -s -) when no signing identity is available. If you ever hit a stuck grant on an unsigned app:
tccutil reset Accessibility <bundle-id>
then relaunch and grant again.
Project Stats
-
~2,800 lines of Swift across 17 source files, plus a unit-tested core (90 tests,
swift test) - Zero external dependencies — pure Swift + AppKit + ScreenCaptureKit
- MIT licensed — fork it, modify it, ship it
- macOS 13+ (Ventura through Tahoe; Liquid Glass on 26+)
Try It Out
The code is on GitHub: github.com/sergio-farfan/alttab-macos
If you have been frustrated by macOS window switching, give it a try. And if you are curious about the low-level macOS APIs — CGEvent taps, AXUIElement, ScreenCaptureKit, NSGlassEffectView, or how TCC really decides whether your app is trusted — the codebase is small enough to read in an afternoon.
Feedback, issues, and PRs are welcome!



Top comments (0)