preact-signal-modal
Modals are where component state goes to rot. You add an isModalOpen
boolean, then a second one for the nested confirmation, then a
pendingDeleteId to remember what the confirmation was about, then an
effect to lock body scroll, then a key listener for Escape, then a ref to give focus
back to the button that opened it. None of that is your feature — and all of it
re-renders the component that owns it, several levels above the thing that actually
changed.
This package moves the whole lot into one signal-backed stack that lives outside your
component tree. Opening a modal re-renders nothing. The answer comes back as a promise,
so the state that only existed to remember the question can go. Scroll lock, Escape,
the focus trap and inert on the rest of the page are solved once, for the
stack. The transitions are Ionic's, ported keyframe for keyframe, and replaceable with
your own through the same builder Ionic uses.
Getting started
Install#
npm i @dmytromykhailiuk/preact-signal-modal
preact >= 10.25 and @preact/signals ^2 are peer
dependencies. There are no runtime dependencies at all — the animation engine, the
focus trap and the gesture handling are part of the package.
Quick start#
Mount the container once, near the root of your app:
import { ModalContainer } from "@dmytromykhailiuk/preact-signal-modal";
const App = () => (
<>
<Routes />
<ModalContainer />
</>
);
Then open a modal from anywhere, and wait for the answer:
import { createModal, useModal } from "@dmytromykhailiuk/preact-signal-modal";
const Confirm = ({ question }: { question: string }) => {
const modal = useModal<boolean>();
return (
<>
<p>{question}</p>
<button type="button" onClick={() => modal.close(true, "confirm")}>Yes</button>
<button type="button" onClick={() => modal.close(false, "cancel")}>No</button>
</>
);
};
const deleteFile = async (id: string) => {
const modal = createModal<boolean>(<Confirm question="Delete this file?" />);
const { data, role } = await modal.afterClose;
if (role === "backdrop" || role === "escape") return; // dismissed, not answered
if (data) await api.delete(id);
};
No isOpen state, no pendingDeleteId, no effect to clean up.
The component that called deleteFile does not re-render when the modal
opens, and it does not re-render when it closes.
Core concepts#
Every open modal — imperative or declarative — lives in a single signal that
<ModalContainer/> renders. That shared list is what gives the
whole app one stacking order, one scroll lock and one focus trap, instead of one
of each per modal you happen to write.
afterClose resolves to { data, role } once the leave
animation has finished and the content has been unmounted. A modal is a question;
the promise is its answer.
Unlike <ion-modal>, a closed modal leaves nothing in the DOM.
Its content unmounts, its effects clean up, and reopening builds it fresh — so a
form never comes back holding last week's draft.
The container mounts once and never re-renders. The stack is drawn with
<For>, visibility with <Show>, and the
whole present/dismiss lifecycle runs in effects against real DOM nodes. Nothing
reads a .value while producing JSX.
ModalContainer#
Modals render where you put the container, not in a portal to <body>.
That is deliberate: it keeps the modal inside your app's stacking and theming context,
and it means a scoped stylesheet or a CSS-variable scope on an ancestor still applies.
<App>
<Routes />
<ModalContainer /> {/* once, anywhere — usually last */}
</App>
Only the first container mounted renders anything, so a stray one in a lazily-loaded route cannot duplicate the stack. Unmounting it hands the role to the next one.
Without a container, createModal() still returns a working handle and
hasModals() still reports it — it simply has nowhere to be drawn.
If a modal seems to do nothing, this is the first thing to check.
Each modal in the stack renders as:
<div class="psm-root" style="--psm-index: 0" data-state="presented">
<div class="psm-backdrop" />
<div class="psm-wrapper" role="dialog" aria-modal="true" tabindex="-1">
<div class="psm-handle" /> {/* sheet modals only */}
<div class="psm-content">
{your content}
</div>
</div>
</div>
data-state is presenting, presented or
dismissing, and --psm-index is the modal's position in the
stack — its z-index is calc(var(--psm-z-index, 1000) + var(--psm-index)).
API
Imperative API#
createModal#
function createModal<T = unknown>(
content: ComponentChildren,
options?: ModalOptions<T>,
): ModalRef<T>;
Opens a modal immediately and returns a handle. content is ordinary JSX —
it is rendered inside the container, with a context that useModal
can read.
The modal handle#
Unique per modal. Pass it to closeModal or to another modal's putAfter.
Closes the modal with a result. Resolves true once it is gone, or
false if canDismiss refused. Calling it
again while a dismissal is in flight joins that one rather than starting another.
Resolves to { data, role } after the leave animation has finished and
the content has been unmounted. It never rejects — a modal that will not close
simply has not resolved yet.
closeModal, closeAllModals#
await closeModal(id, data, role); // false if no such modal, or it refused
await closeAllModals("navigation"); // topmost first; canDismiss still gets its say
closeAllModals() belongs in a route change, where a modal left behind is
always a bug. Modals whose canDismiss refuses stay open; the rest close.
hasModals, getTopModal#
The stack itself is not exported. Handing it over invites a component to read
modals$.value while producing JSX, which subscribes that component to
every open and close — the one thing this package exists to avoid. What is exported is
three narrower things, split so the shape of the name tells you what it does.
Whether anything is open, right now. A plain read — it subscribes nothing, so it is safe anywhere: an event handler, a route guard, a component body.
The modal on top right now — the one Escape would close — or
undefined when nothing is open. It carries id, the
resolved options, a state$ signal and
close(). Also a plain read.
The reactive one. Derive from this, bind this.
// imperative: what is true at this instant
if (hasModals()) return;
await getTopModal()?.close(undefined, "navigation");
// reactive: dim a map, pause a video, suppress a shortcut
const shortcutsEnabled = computed(() => !hasModals$.value);
// in a component: derive, then bind — never unwrap in the body
const label = useComputed(() => (hasModals$.value ? "busy" : "idle"));
return <span>{label}</span>;
A function that quietly registered a dependency would be worse than either half.
computed(() => !hasModals()) shows no signal at the call site, yet
would re-run on every modal — invisible reactivity, and in a package about not
re-rendering, invisible reactivity is the bug. So the functions read through
peek and never track, and anything that should track is spelled with a
$.
The Modal component#
Use createModal() when the modal is the result of something that happened —
a click, a failed request, a route guard. Use <Modal> when it belongs
in the markup.
It renders nothing itself. It is a bridge between an open/closed state and the same
stack createModal() writes to, which is why a declarative modal and an
imperative one share a stacking order, a scroll lock and a focus trap. It takes every
option createModal takes, and then exactly one of
trigger and isOpen — never both, because they are two answers
to the same question: who owns the state. The props are a union, so passing both is a
type error.
trigger#
The component owns the state. Name the id of an element and its click
opens the modal — exactly like <ion-modal trigger="…">. Nothing to
wire up, and nothing to keep in step.
const Settings = () => (
<>
<button type="button" id="settings-button">Settings</button>
<Modal trigger="settings-button" ariaLabel="Settings">
<SettingsForm />
</Modal>
</>
);
The element has to be in the document by the time the modal mounts; if it is not, you
get a console warning rather than a trigger that quietly never fires. Closing works
the usual ways — the backdrop, Escape, useModal().close() — and the next
click on the trigger opens a fresh modal.
isOpen#
You own the state, as a ReadonlySignal<boolean>. Two decisions worth
spelling out:
- A signal, not a boolean. A plain boolean would come from component state, so every open and close would re-render the owning component and everything under it.
-
Read-only. The modal does not write to state it does not own. That
also means the prop takes anything signal-shaped — a
computed, a selector from a store — not just auseSignalit could have written back to.
The other half of that bargain is yours. The modal still closes on the backdrop, on
Escape and on a swipe; onDidDismiss is where you set your signal back:
const isOpen = useSignal(false);
<Modal isOpen={isOpen} onDidDismiss={() => (isOpen.value = false)} ariaLabel="Settings">
<SettingsForm />
</Modal>
// or drive it from somewhere else entirely
const isOpen = computed(() => route.value === "/settings");
A signal left at true for a modal that has already been dismissed does
not spring it open again — the modal only opens when the signal goes from
false to true. But it will read true for a
modal that is not there, and the next true will not be a change at all,
so it will not reopen either. Set it back in onDidDismiss.
When canDismiss refuses, nothing closes and nothing needs syncing —
onDidDismiss never fires.
A <Modal> that unmounts takes its modal with it, whichever way it
was driven. Leaving an orphan on screen with no way to reach it would be worse.
useModal#
Inside modal content, useModal() is how you close with a result. It throws
outside a modal, because the alternative is a close() that silently does
nothing.
The id of the modal this component is rendered in.
Same as the handle's — this is the one to reach for in a button's onClick.
Where a sheet currently sits, or undefined for a
regular modal. Bind it, or derive from it — do not unwrap it during render.
Animates a sheet to one of its breakpoints. 0 dismisses it.
Dismissal and roles#
Every dismissal carries a role saying how it happened. Without one you
cannot tell "the user chose No" from "the user tapped outside", and those usually mean
very different things.
| Role | Set when |
|---|---|
backdrop | the backdrop itself was clicked (never a click that bubbled up from the content) |
escape | Escape was pressed — only the topmost modal ever sees it |
gesture | a sheet was swiped down past its lowest breakpoint |
handler | code asked: close(), closeModal(), or isOpen going false |
"…" | anything you pass yourself — close(value, "confirm") — travels through untouched |
const { data, role } = await modal.afterClose;
switch (role) {
case "confirm": return save(data);
case "backdrop":
case "escape": return; // the user walked away — do nothing
}
The lifecycle hooks fire in this order, and both dismiss hooks receive the same object:
onWillPresent() // before the enter animation
onDidPresent() // after it
onWillDismiss({ data, role }) // after canDismiss agreed, before the leave animation
onDidDismiss({ data, role }) // after it, once the content is unmounted
canDismiss#
A form with unsaved changes should not vanish because someone missed the modal by ten
pixels. canDismiss vetoes a dismissal — from any source, including your own
close():
const draft = signal("");
const modal = createModal(<EditNote draft={draft} />, {
canDismiss: ({ role }) => role === "save" || draft.peek().length === 0,
});
It may be a boolean or a function, and the function may be async — the modal stays open
until it resolves, and stays open for good if it resolves false. Ask the
user first, if that is what the situation calls for:
canDismiss: async ({ role }) => {
if (role === "save" || !isDirty.peek()) return true;
const confirm = createModal<boolean>(<Confirm question="Discard changes?" />);
const { data } = await confirm.afterClose;
return data === true;
},
A refused dismissal leaves the modal exactly as it was — still presented, still
closable. close() resolves false so the caller can tell the
difference, and neither dismiss hook fires.
Reference
Options#
Every option is accepted both by createModal(content, options) and as a
prop on <Modal>. Anything left out falls back to
configureModal, and then to the built-in default shown here.
Which preset to animate with. Defaults to the platform: ios on iOS and iPadOS, md everywhere else.
Set false to appear and disappear instantly. Already false for anyone whose OS asks for reduced motion.
Replace the preset for this modal. See Your own animation.
With false the backdrop is not rendered and the page behind stays clickable — the modal layer itself never swallows pointer events.
Clicking the backdrop closes with role backdrop.
Escape closes with role escape. The topmost modal swallows the key either way, so it never falls through to one underneath.
Locks body scroll while the modal is open, reference counted across the stack, with scrollbar-width compensation so the layout does not jump.
Autofocus, Tab containment, focus restore and inert on the rest of the page. See Accessibility.
Vetoes a dismissal. See canDismiss.
Added to .psm-wrapper and .psm-backdrop.
Inline styles for the same two elements. Custom properties are passed through as written: { "--psm-width": "40rem" }.
Slot this modal directly above the one with that id, instead of on top of everything. That is how a modal opened from another can still sit below a third one that was already there.
Put on the dialog element. Give every modal one of the first two.
See Sheet modals.
See Dismissal and roles.
Motion
Animations#
The presets#
The stock transitions are ports of Ionic's own, keyframe for keyframe. A modal from this
package moves exactly like an <ion-modal>:
| Duration | Easing | Movement | |
|---|---|---|---|
ios enter | 500ms | cubic-bezier(0.32,0.72,0,1) | translateY(100vh) → 0, backdrop 0.01 → full |
ios leave | 500ms | cubic-bezier(0.32,0.72,0,1) | the same, reversed |
md enter | 280ms | cubic-bezier(0.36,0.66,0.04,1) | opacity 0.01 → 1, translateY(40px) → 0 |
md leave | 200ms | cubic-bezier(0.47,0,0.745,0.715) | opacity 0.99 → 0, translateY(0) → 40px |
They are exported as iosEnterAnimation, iosLeaveAnimation,
mdEnterAnimation and mdLeaveAnimation, so you can build on one
rather than starting over.
A user whose OS asks for less motion gets none of ours: animated resolves
to false and both animations land on their final frame at once. Nothing
to configure.
Your own animation#
An AnimationBuilder is handed the modal's root element and the current
options, and returns an animation. Reach into it for
.psm-backdrop, .psm-wrapper and .psm-content:
import { createAnimation } from "@dmytromykhailiuk/preact-signal-modal";
import type { AnimationBuilder } from "@dmytromykhailiuk/preact-signal-modal";
const zoomEnter: AnimationBuilder = (baseEl) =>
createAnimation()
.addElement(baseEl)
.duration(300)
.easing("cubic-bezier(0.32,0.72,0,1)")
.addAnimation([
createAnimation()
.addElement(baseEl.querySelector(".psm-backdrop"))
.fromTo("opacity", 0, "var(--psm-backdrop-opacity, 0.32)"),
createAnimation()
.addElement(baseEl.querySelector(".psm-wrapper"))
.fromTo("transform", "scale(0.8)", "scale(1)")
.fromTo("opacity", 0, 1),
]);
createModal(<Confirm />, { enterAnimation: zoomEnter, leaveAnimation: zoomLeave });
Timing set on the parent is inherited by children that do not define their own — that is
how one duration(300) drives the backdrop and the modal together. The
second argument tells you the mode, and for a sheet, the
currentBreakpoint and backdropBreakpoint it is moving between.
createAnimation#
A port of Ionic's animation builder on the Web Animations API. The methods you are likely to need:
addElement takes an element or a NodeList and ignores null, so a querySelector that finds nothing is harmless. addAnimation nests one animation or an array of them.
Set on a parent, inherited by children that leave them unset.
fromTo repeated for several properties merges into the same two frames. keyframes takes the full array when you need offsets in between.
Applied to the animation's elements before the first frame and after the last. There are beforeAddRead / beforeAddWrite pairs too, for measuring without thrashing layout.
play() resolves when the animation finishes or is stopped. onFinish receives 1 when it ran to the end and 0 when it was reversed.
Scrub an animation by hand, then hand control back. This is what makes a finger-driven transition possible.
Where Element.prototype.animate is missing — jsdom, server rendering —
an animation still runs its hooks and finishes synchronously. The lifecycle completes
and only the motion is lost, so tests and SSR need no special casing.
Appearance
Styling#
The stylesheet is injected into <head> the first time a modal opens,
once per document. Everything visual is a custom property with an inline fallback — set
one on :root, on any ancestor, or on the modal itself:
--psm-background #fff
--psm-color inherit
--psm-width auto
--psm-min-width 300px
--psm-max-width calc(100vw - 2rem)
--psm-height auto
--psm-max-height calc(100vh - 2rem)
--psm-border-radius 12px
--psm-box-shadow 0 10px 40px -8px rgba(0, 0, 0, 0.35)
--psm-padding 1.25rem
--psm-backdrop-color #000
--psm-backdrop-opacity 0.32
--psm-z-index 1000
--psm-sheet-border-radius 16px
--psm-sheet-expanded-border-radius 0
--psm-sheet-padding 0 1.25rem 1.25rem
--psm-handle-color rgba(0, 0, 0, 0.24)
:root {
--psm-border-radius: 14px;
--psm-max-width: 32rem;
--psm-backdrop-opacity: 0.45;
}
createModal(<Gallery />, {
modalStyle: { "--psm-width": "min(60rem, 90vw)", "--psm-padding": "0" },
});
Because the injected sheet is appended at runtime, it would outrank an app's own
:root declarations if it defined the tokens itself. It does not — the
defaults live inline in each var(), so your values always win.
Injecting a <style> needs style-src 'unsafe-inline',
and there is no <head> to inject into on the server. Turn injection
off and import the identical bytes as a file:
configureModal({ injectStyles: false });
import "@dmytromykhailiuk/preact-signal-modal/styles.css";
ensureModalStyles() injects it on demand, which is worth knowing about
when rendering into a second document such as a popup window. Everything else about
the stylesheet — its text, the id of the element it lands in — is an implementation
detail and is not exported.
Sheet modals#
Give a modal breakpoints and it becomes a sheet you can drag, flick and swipe away:
createModal(<Filters />, {
breakpoints: [0, 0.25, 0.5, 1],
initialBreakpoint: 0.25,
handleBehavior: "cycle",
});
Breakpoints#
Fractions of the viewport height the sheet may rest at, 0 to 1. Include 0 to let it be swiped away. Sorted and de-duplicated for you.
Where it opens. It must be one of breakpoints, and you get a thrown
error if it is not — a sheet resting at a position it can never return to is far
harder to notice later than an error at the call site.
Below this point the backdrop is fully transparent and lets clicks through, so a peeking sheet does not block the page behind it. Above it, opacity ramps linearly to full.
The grab bar at the top.
cycle makes a tap on the handle advance to the next breakpoint, wrapping past the top. The handle becomes a real <button> only in that case, so it is never a pointless tab stop.
Ionic's default: content only scrolls at the tallest breakpoint. Set false and the content's max-height grows with the sheet, so it scrolls at every breakpoint.
A sheet is rounded across the top — until it reaches breakpoint 1, where
it covers the viewport and those corners would have nothing but backdrop behind
them. At that point the root carries data-fullscreen="true" and the
radius goes to --psm-sheet-expanded-border-radius, which is
0. Set it back to var(--psm-sheet-border-radius) to keep
the curve, and style anything else off the same attribute:
.psm-root--sheet[data-fullscreen="true"] .psm-handle {
opacity: 0;
}
Gestures#
A slow drag snaps to the nearest breakpoint. A flick carries on to the next one in the
direction of travel. Landing on 0 dismisses with role
gesture.
The sheet can be dragged from anywhere, not just the handle — except when the content
is scrolled down, where the scroll wins, because otherwise you could never scroll back
up. From inside the sheet, useModal() gives you
breakpoint$ and setBreakpoint():
const Filters = () => {
const modal = useModal();
const label = useComputed(() => (modal.breakpoint$.value === 1 ? "Collapse" : "Expand"));
return (
<button type="button" onClick={() => modal.setBreakpoint(modal.breakpoint$.peek() === 1 ? 0.5 : 1)}>
{label}
</button>
);
};
Behaviour
Accessibility#
The dialog is role="dialog" with aria-modal="true". It is not
a native <dialog>: that can only be opened through
showModal(), which brings its own top-layer stacking, backdrop and
dismissal — all of which this package already owns and animates.
The first focusable element inside the modal is focused, or the dialog itself when there is nothing to focus.
Tab and Shift+Tab wrap inside the modal, and focus that drifts outside is pulled back. Only the topmost modal traps.
Focus returns to whatever had it before — unless the user has since clicked elsewhere, in which case it is left alone.
Worked out by walking up from the modal element to <body> and
hiding, at each step, every child that is not on the path back down. The usual
recipe — hide every sibling of <body> — assumes the modal was
portalled there, and this one deliberately is not.
Because each modal records the state it found, stacked modals nest correctly on their own: the second sees the page already hidden and leaves it that way when it closes, while the first modal's layer is handed back.
Nothing can infer what a dialog is for. Pass ariaLabel, or
ariaLabelledBy pointing at your heading, on every modal.
Configuration#
configureModal() sets the defaults for every modal created from then on.
Call it once at startup; modals already on screen keep the settings they were created
with.
configureModal({
mode: "ios",
baseZIndex: 4000,
backdropDismiss: false,
enterAnimation: zoomEnter,
leaveAnimation: zoomLeave,
});
It takes mode, animated, showBackdrop,
backdropDismiss, keyboardClose, scrollLock,
focusTrap, expandToScroll, handleBehavior,
enterAnimation, leaveAnimation, plus two that only exist
here:
The z-index of the first modal; each one above it gets +1. Equivalent to setting --psm-z-index in CSS.
Whether to put the stylesheet in <head> on first use. See Styling.
getModalConfig() returns the current values, if you need to read them back.
TypeScript#
createModal<T>() types both ends: close(data) only
accepts a T, and afterClose resolves to
ModalDismissal<T>. useModal<T>() inside the
content agrees with it.
const modal = createModal<{ id: string }>(<Picker />);
const { data, role } = await modal.afterClose;
// ^? { id: string } | undefined
The compiler catches
- Closing with the wrong shape of result.
- Passing a plain boolean to
<Modal isOpen>instead of a signal. - An
AnimationBuilderthat forgets to return an animation. - Reading
datawithout checking it is there — it is optional, because a modal can always be dismissed without answering. That is the type system telling you to look atrole.
Runtime only
- An
initialBreakpointthat is not inbreakpoints, or a breakpoint outside 0…1 — both throw atcreateModal. useModal()outside a modal — it throws rather than returning a handle that does nothing.- A
triggerid that matches no element — a console warning.