Decisions and gotchas for building floating glass panels with WKWebView on macOS, extracted from the MermaidPreview extension.
Use .titled combined with .fullSizeContentView. Do not use .borderless.
window = NSWindow(contentRect: rect,
styleMask: [.titled, .fullSizeContentView],
backing: .buffered, defer: false)
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = trueWhy .titled instead of .borderless:
.borderlessgives a rectangular shadow regardless of content shape. macOS computes the shadow from the window's defined shape, not from what is drawn inside it. With.hudWindowvisual effect blending, borderless windows never get the correct pill-shaped shadow..titled+.fullSizeContentViewlets content fill the entire frame while macOS still treats it as a regular titled window — correct rounded-corner shadow included, no native title bar visible.
Add .resizable later at runtime if the window needs to become resizable after
a mode switch:
window.styleMask.insert(.resizable)
window.contentMinSize = NSSize(width: 500, height: 360)let vfx = NSVisualEffectView(frame: rect)
vfx.autoresizingMask = [.width, .height]
vfx.material = .hudWindow // frosted glass, works in both light and dark mode
vfx.blendingMode = .behindWindow
vfx.state = .active // must be .active, not .followsWindowActiveState
window.contentView = vfxDo not set cornerRadius or masksToBounds on the visual effect view or
on the web view. The native window already clips to its shape; a second clip
boundary causes rendering artefacts (white corners outside the rounded shape).
webView = WKWebView(frame: rect, configuration: config)
webView.autoresizingMask = [.width, .height]
webView.setValue(false, forKey: "drawsBackground")
vfx.addSubview(webView)isOpaqueis read-only on macOS 26 SDK — do not set it.backgroundColor = .clearon the web view is also unreliable on newer SDKs.setValue(false, forKey: "drawsBackground")is the only reliable way to make the WKWebView transparent so the visual effect shows through.- In HTML, set
background: transparenton bothhtmlandbody.
CSS -webkit-app-region: drag shows the correct cursor but does not actually
move the window on macOS. Use a native NSView overlay instead:
class TitleDragView: NSView {
override var mouseDownCanMoveWindow: Bool { true }
}Place it above WKWebView in the view hierarchy, covering only the drag strip:
let dragH = CGFloat(28)
let dragView = TitleDragView(frame: NSRect(x: 0, y: rect.height - dragH,
width: rect.width, height: dragH))
dragView.autoresizingMask = [.width, .minYMargin] // full-width, pinned to top
vfx.addSubview(dragView) // added after webViewwindow.isMovableByWindowBackground = true must also be set — it tells AppKit
to honour mouseDownCanMoveWindow from subviews.
Events outside the drag strip fall through to WKWebView (buttons, textareas,
scroll, etc.) because TitleDragView only covers 28 px. The HTML reserves
matching space with a <div class="drag-strip"> spacer so content is not
obscured.
Register handlers before loading HTML:
let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "bridge")Post from JavaScript:
window.webkit.messageHandlers.bridge.postMessage({ type: 'close' })Receive in Swift (WKScriptMessageHandler):
func userContentController(_ ucc: WKUserContentController,
didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any],
let type = body["type"] as? String else { return }
switch type {
case "close": close()
case "resize": ...
default: break
}
}One named handler with a type field keeps the bridge surface small and easy
to extend. Multiple named handlers work too but fragment the protocol.
WKWebView loads ES modules asynchronously. A WKUserScript injected at
.atDocumentStart runs before the module resolves, so the module's init code
can read a window.__content__ variable that Swift pre-seeded:
// JSON-encode the string so it is safe to inline into a <script> block.
// .fragmentsAllowed is required — without it, JSONSerialization refuses to
// encode a top-level String (only Array/Dictionary are accepted by default).
let jsonData = (try? JSONSerialization.data(withJSONObject: content,
options: .fragmentsAllowed))
?? Data("\"\"".utf8)
let jsonStr = String(data: jsonData, encoding: .utf8) ?? "\"\""
let injection = WKUserScript(source: "window.__content__ = \(jsonStr);",
injectionTime: .atDocumentStart,
forMainFrameOnly: true)
config.userContentController.addUserScript(injection)In the module:
if (typeof window.__content__ === 'string') renderContent(window.__content__);NSEvent.addGlobalMonitorForEvents fires even when the app is not focused,
making it the right tool for a floating panel that should dismiss on any outside
click:
globalMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]
) { [weak self] _ in
guard let self else { return }
if !self.window.frame.contains(NSEvent.mouseLocation) { self.close() }
}Remove the monitor explicitly before NSApp.terminate — leaked monitors keep
firing after the window is gone:
private func close() {
if let m = globalMonitor { NSEvent.removeMonitor(m); globalMonitor = nil }
cleanup()
NSApp.terminate(nil)
}When the window transitions from a light-dismiss panel to an editor that the user interacts with, remove the monitor so outside clicks no longer close it.
app.setActivationPolicy(.accessory) // no Dock icon, no app switcher entry
app.activate(ignoringOtherApps: true).accessory is appropriate for transient floating panels launched by another
app (e.g. a PopClip extension). .regular shows a Dock icon, which is
disruptive for short-lived tool windows.
The activate(ignoringOtherApps: true) call ensures the window becomes key
and receives keyboard events even though it was launched from a background
process.
import mermaid from 'https://esm.sh/mermaid';
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' });Always call mermaid.parse(text) before mermaid.render(...). In v10+,
render writes a bomb-emoji SVG directly into the DOM when the syntax is
invalid, and it cannot be suppressed. parse throws instead, letting you show
a clean error state:
try {
await mermaid.parse(text); // throws on bad syntax
const { svg } = await mermaid.render('unique-id', text);
output.innerHTML = svg;
} catch (e) {
output.innerHTML = ''; // never show the bomb SVG
statusPill.className = 'error';
}Each call to mermaid.render must use a unique id argument — mermaid tracks
previously rendered IDs internally and will error on duplicates. Use a
monotonically incrementing sequence number as a suffix.
Text rendered directly over a blurred background can be illegible depending on
what is behind the window. A heavy layered text-shadow works on any colour:
#hint {
color: rgba(255,255,255,0.55);
text-shadow:
0 1px 3px rgba(0,0,0,1),
0 0 14px rgba(0,0,0,0.95);
}For buttons, a dark semi-transparent background with a subtle border is consistently legible:
button {
background: rgba(20, 20, 20, 0.65);
color: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(255, 255, 255, 0.18);
}Write content to a unique temp file, pass the path to the Swift process, and delete it when the window closes:
private func cleanup() {
try? FileManager.default.removeItem(atPath: sourcePath)
}In the shell script:
TEMP="${TMPDIR:-/tmp/}mermaid_$(/usr/bin/uuidgen).mmd"
echo "$INPUT" > "$TEMP"
/usr/bin/swift script.swift "$TEMP" &Use ${TMPDIR:-/tmp/} — TMPDIR is empty in the PopClip subprocess
environment even though it is set in a normal shell session.