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.


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.


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.


Switching layouts retiles the space. Floating windows are unaffected — they keep their position regardless of which layout is active.

Floating state is per-window, not per-space. If a window appears on multiple macOS Spaces, it stays floating on all of them.

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).

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.


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.


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

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.


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().


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


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.