Floating windows
Floating windows are excluded from tiling. They stay where the user places them and do not affect the positions of tiled windows. This page covers the mechanics of floating: how state is tracked, what triggers it, how it interacts with zoom, and whether it persists across restarts.
What floating means
A floating window is one that Shoji skips during tiling. When a space is tiled, floating windows are invisible to the layout algorithm — they keep their current size and position while tiled windows arrange around them.
A window can become floating in two ways:
- Manual toggle — the user presses
toggle_float. - Filter-based — the window’s app matches the configured filter (blocklist or allowlist), so Shoji treats it as floating automatically.
Both paths produce the same result: the window is excluded from
isTilingCandidate checks and ignored by the layout.
Toggling floating
Bind the toggle_float action to a hotkey:
spoon.Shoji:bindHotkeys({ toggle_float = { { "ctrl", "alt", "shift" }, "t" },})Or call it directly from Lua:
spoon.Shoji.actions.toggleFloat()When toggled:
- The current floating state is read from
state.windows[windowID]. - If the window was floating, its current frame is saved so it can be restored later.
- The floating flag is flipped.
- The window cache is invalidated so the next tiling pass sees the change.
- The space is retiled immediately.
- If the window just became floating and has a saved frame from a previous float, that frame is restored (clamped to the current screen).
- The
float_toggledhook fires.
An on-screen alert confirms the new state (“Window floating” or
“Window tiled”) unless show_layout_alerts is disabled.
Floating window lifecycle
Layout changes
Switching layouts retiles the space. Floating windows are unaffected — they keep their position regardless of which layout is active.
Space switches
Floating state is per-window, not per-space. If a window appears on multiple macOS Spaces, it stays floating on all of them.
Window close and reopen
Floating state is tied to the window ID. When a window is closed, its state
is cleaned up by State:removeWindow or State:cleanupStaleWindows. If
the same app opens a new window, it gets a new window ID and starts as
tiled (unless the app is in the filter list).
Retiling
Floating windows survive retiles. The toggle_float action sets a flag in
state.windows[windowID].floating, and this flag is checked on every
tiling pass via Windows.isFloating. Manual retiles (retile_space,
reset_space) do not clear floating state.
Frame restoration
Shoji remembers the last position of a floating window so it can restore it when the window is floated again later. The flow:
- Tiled → floating: the window leaves the layout and stays at whatever position it had. No saved frame is applied.
- Floating → tiled: the current floating position is saved in
state.windows[windowID].floatingFrame. The window then joins the tiled layout. - Tiled → floating again: the saved frame from step 2 is restored, clamped to the current screen bounds so the window does not end up off-screen.
If there is no saved frame (first time floating), the window simply stays where it is.
Zoom vs float
Both toggle_zoom and toggle_float remove a window from tiling, but
they serve different purposes.
toggle_float | toggle_zoom | |
|---|---|---|
| Effect | Excludes from tiling | Excludes from tiling and maximizes to screen |
| Size | Keeps current size | Fills the screen canvas |
| Undo | Rejoins tiled layout | Restores previous state (tiled or floating) |
| Use case | Position a window freely | Temporarily focus on one window |
Interaction between zoom and float
Zooming a window sets it to floating internally and records whether it was
already floating beforehand (wasFloatingBeforeZoom). When unzoomed:
- If the window was not floating before zoom, it returns to tiling.
- If the window was floating before zoom, it returns to floating at its saved frame position (not tiling).
This prevents zoom from accidentally changing a deliberate floating state.
Zooming an already-floating window saves its current frame first, so unzooming restores the floating position rather than snapping to a tiled slot.
State persistence
Floating state survives Hammerspoon restarts. The persistence module
(persistence.lua) includes per-window floating flags in the state file:
{ "windows": { "12345": { "floating": true }, "67890": { "floating": true } }}On load, Persistence.apply restores floating flags for windows that
still exist. Windows that no longer exist (closed between restarts) are
skipped.
The state file location defaults to
$XDG_STATE_HOME/shoji/state.json (typically
~/.local/state/shoji/state.json). Configure it with
persistence_path in configure().
Per-app floating via filter
Instead of toggling individual windows, you can configure entire apps to
always float using filter_apps:
spoon.Shoji:configure({ filter_mode = "blocklist", filter_apps = { "com.apple.finder", "com.apple.systempreferences", "com.1password.1password", },})In blocklist mode, listed apps are excluded from tiling. In allowlist mode, only listed apps are tiled — everything else floats. See Window filtering for details.
Filter-based floating differs from manual floating in one way: there is no
toggle_float flag in state, so the window cannot be toggled into tiling
with the hotkey. The filter decision is re-evaluated from the app’s bundle
ID on every tiling pass.
The float_toggled hook
The float_toggled hook fires whenever a window’s floating state changes
via toggle_float. It does not fire for filter-based floating (which
is a static property of the app, not a state change).
| Property | Value |
|---|---|
| Arguments | windowID (WindowID), floating (boolean) |
spoon.Shoji:configure({ hooks = { float_toggled = function(windowID, isFloating) local state = isFloating and "floating" or "tiled" print("Window " .. windowID .. " is now " .. state) end, },})The hook runs after the floating flag is set, the space is retiled, and frame restoration (if applicable) completes.
Inspecting floating state
Check whether a window is floating from the Hammerspoon console:
local win = hs.window.focusedWindow()if win then -- Manual floating flag print(spoon.Shoji.state:isWindowFloating(win:id())) -- Combined check (manual + filter-based) print(spoon.Shoji.windows.isFloating(win))endstate:isWindowFloating checks only the manual toggle flag.
windows.isFloating checks the manual flag, filter configuration, and
special cases (Firefox PiP, nil bundle ID). Use windows.isFloating for
the complete picture.