Visibility layer¶
A small, honest set of show/hide primitives so a toggle doesn't require pulling in a
separate library. Every action is declarative, eval-free, CSP-compliant, and defaults
to click.
One mechanism drives them all: the hidden class. It is synchronous,
Tailwind-compatible and composable — a panel hidden by fx-hide is shown again by
fx-show or fx-toggle. No inline-style fights, no orphan opacity.
Actions¶
<!-- Shows #sidebar on click (removes .hidden) -->
<button fx-show="#sidebar">Open</button>
<!-- Hides #sidebar on click (adds .hidden) -->
<button fx-hide="#sidebar">Close</button>
<!-- Toggles .hidden on click -->
<button fx-toggle="#sidebar">Toggle Menu</button>
<!-- Toggles a class on click (targets self by default) -->
<div fx-class="bg-blue-500">Toggle My Color</div>
<!-- Toggles a class on a specific target -->
<button fx-class="translate-x-full" fx-target=".circle">Toggle Circle</button>
<!-- Removes ITSELF after 3 seconds (value must be a duration) -->
<div fx-remove="3s">Item Saved Successfully!</div>
Dropdowns — fx-dropdown¶
<button fx-dropdown="#menu">Toggle</button>
<ul id="menu" class="hidden">
<li><a href="/profile">Profile</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
One handler coordinates everything: the trigger toggles the menu, clicking outside
the trigger/menu closes it, Esc closes it, and the trigger's aria-expanded
stays in sync. The click that opens the menu can never immediately close it.
Modals & Drawers — canonical path¶
<button fx-open="#edit">Edit</button>
<dialog id="edit" fx-modal>
<form method="dialog">...</form>
<button fx-close>Cancel</button>
</dialog>
<!-- Slide-over drawer panel -->
<button fx-open="#side-drawer">Settings</button>
<dialog id="side-drawer" fx-drawer>
<h2>Settings</h2>
<button fx-close>Close</button>
</dialog>
<dialog> + fx-open / fx-close is the canonical modal and drawer path: focus trap and
restoration are native, and fx-modal / fx-drawer close on backdrop click (available in both
the core and full bundles — the logic lives in the core dialog controller). fx-drawer shares
the same backdrop-dismiss behavior and represents slide-over side panels.
A fx-open target must be a <dialog> or declare the popover attribute; anything
else logs a warning instead of throwing.
Dismissal Control (closedby)¶
Flux embraces the web standard closedby attribute (Chrome 134+, Firefox 137+, Safari 18.2+):
<!-- Non-destructive modal / image preview: backdrop + Escape dismisses -->
<dialog id="preview" fx-modal closedby="any">...</dialog>
<!-- Form with inputs: Escape dismisses, backdrop click does NOT dismiss -->
<dialog id="edit-form" fx-modal closedby="closerequest">...</dialog>
<!-- Destructive action / mandatory choice: must click explicit button -->
<dialog id="delete-confirm" fx-modal closedby="none">...</dialog>
closedby Value |
Escape Key | Backdrop Click | Primary Use Case |
|---|---|---|---|
any |
Dismisses | Dismisses | Image previews, lightweight menus, read-only sheets |
closerequest |
Dismisses | Blocked | Forms with inputs, preventing accidental dismissals on misclicks |
none |
Blocked | Blocked | Critical/destructive confirmations, payments, required choices |
In browsers supporting native closedBy, Flux delegates dismissal to the browser.
In older browsers, fx-modal polyfills closedby="any" light-dismiss while strictly honoring closedby="none" and closedby="closerequest".
Automatic Unsaved Changes Protection¶
If a <dialog fx-modal> contains a dirty form (form[fx-dirty][data-dirty]), Flux intercepts backdrop dismissal (and native cancel events) and prompts before closing.
The prompt message defaults to "Discard unsaved changes?", which can be configured globally via Flux.configure({ messages: { unsavedChanges: '...' } }) or overridden locally with fx-dirty-message="Custom confirmation prompt". If the user cancels the confirmation, the modal remains open and form inputs are preserved.
Explicit closedby=\"any\" bypasses the unsaved changes guard on backdrop clicks
If you explicitly declare closedby="any", the browser's native light-dismiss handles backdrop clicks directly (without firing a cancel event in current browser engines), bypassing the unsaved changes prompt. For modals containing forms, omit closedby and let Flux manage backdrop dismissal with automatic dirty form protection.
Escape Key Handling
Esc dismissal is handled natively by the <dialog> element (which fires the native cancel event).
Use closedby="none" if you need to prevent Escape from closing the dialog.
Avoid width: 100%; height: 100% on dialogs
If a <dialog> is styled to cover 100% of the viewport width and height, backdrop clicks cannot land outside the element's bounding rectangle in fallback environments. Use max-width, max-height, or centered padding instead.
Dismissable overlays (legacy div pattern)¶
<div id="my-modal" class="hidden">
<div class="modal-content" fx-hide-outside="#my-modal" fx-hide-escape="#my-modal">
<h2>Hello Modal</h2>
<button fx-hide="#my-modal">Close</button>
</div>
</div>
fx-hide-outside="#id"— hide the target when a click lands outside the content boxfx-hide-escape="#id"— hide the target on Esc
The two attributes do not conflict with each other or with fx-show/fx-hide on the
same elements — conflict detection only flags genuinely contradictory pairs.
Define the hidden class
Every visibility action drives the hidden class. With Tailwind you already have
it; otherwise load flux.css (which ships .hidden{display:none!important}).
Without a definition, hide actions would silently no-op — Flux warns in the
console the first time it detects that.
Disconnection contract¶
Re-processing an element (htmx swaps, Flux.process()) replaces the previous
listener instead of stacking: signature changes disconnect the old registration. A
fx-toggle keeps toggling correctly no matter how many times its markup is
re-rendered.