Skip to content

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:

  1. Manual toggle — the user presses toggle_float.
  2. 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:

  1. The current floating state is read from state.windows[windowID].
  2. If the window was floating, its current frame is saved so it can be restored later.
  3. The floating flag is flipped.
  4. The window cache is invalidated so the next tiling pass sees the change.
  5. The space is retiled immediately.
  6. If the window just became floating and has a saved frame from a previous float, that frame is restored (clamped to the current screen).
  7. The float_toggled hook 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:

  1. Tiled → floating: the window leaves the layout and stays at whatever position it had. No saved frame is applied.
  2. Floating → tiled: the current floating position is saved in state.windows[windowID].floatingFrame. The window then joins the tiled layout.
  3. 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_floattoggle_zoom
EffectExcludes from tilingExcludes from tiling and maximizes to screen
SizeKeeps current sizeFills the screen canvas
UndoRejoins tiled layoutRestores previous state (tiled or floating)
Use casePosition a window freelyTemporarily 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).

PropertyValue
ArgumentswindowID (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))
end

state: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.