preact-signal-modal
preact-signal-modal 1.0.1

Signal-driven modals for Preact. Ionic motion, sheet gestures, zero re-renders.

Contents

Contents

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.

zero re-renders awaitable result imperative + declarative Ionic-identical motion Web Animations API custom animations sheet modals swipe to close focus trap inert scroll lock canDismiss guard CSS variables fully typed no dependencies

Getting started

Install#

sh
npm i @dmytromykhailiuk/preact-signal-modal
Dependencies

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:

tsapp.tsx
import { ModalContainer } from "@dmytromykhailiuk/preact-signal-modal";

const App = () => (
  <>
    <Routes />
    <ModalContainer />
  </>
);

Then open a modal from anywhere, and wait for the answer:

tsa confirmation you can await
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#

One stacksignal

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.

A result, not a flagPromise

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.

Destroyed on closenot hidden

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.

Zero re-rendersby construction

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.

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

Required

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:

tsthe element structure your animations and CSS target
<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#

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

idstring

Unique per modal. Pass it to closeModal or to another modal's putAfter.

close(data?, role?) => Promise<boolean>

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.

afterClosePromise<ModalDismissal<T>>

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#

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

hasModals() => boolean

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.

getTopModal() => OpenModal | undefined

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.

hasModals$ReadonlySignal<boolean>

The reactive one. Derive from this, bind this.

tsa read in a handler, a signal in a derivation
// 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>;
Why the split

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.

tsno signal anywhere
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 a useSignal it 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:

ts
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");
If you skip the sync

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.

Unmounting

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.

idstring

The id of the modal this component is rendered in.

close(data?, role?) => Promise<boolean>

Same as the handle's — this is the one to reach for in a button's onClick.

breakpoint$ReadonlySignal<number | undefined>

Where a sheet currently sits, or undefined for a regular modal. Bind it, or derive from it — do not unwrap it during render.

setBreakpoint(breakpoint: number) => Promise<void>

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.

RoleSet when
backdropthe backdrop itself was clicked (never a click that bubbled up from the content)
escapeEscape was pressed — only the topmost modal ever sees it
gesturea sheet was swiped down past its lowest breakpoint
handlercode asked: close(), closeModal(), or isOpen going false
"…"anything you pass yourself — close(value, "confirm") — travels through untouched
ts
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:

ts
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():

tsthe draft lives outside, so the guard and the form read one signal
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:

tsa modal guarding a modal
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;
},
After a refusal

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.

mode"ios" | "md"

Which preset to animate with. Defaults to the platform: ios on iOS and iPadOS, md everywhere else.

animatedboolean — true

Set false to appear and disappear instantly. Already false for anyone whose OS asks for reduced motion.

enterAnimation, leaveAnimationAnimationBuilder

Replace the preset for this modal. See Your own animation.

showBackdropboolean — true

With false the backdrop is not rendered and the page behind stays clickable — the modal layer itself never swallows pointer events.

backdropDismissboolean — true

Clicking the backdrop closes with role backdrop.

keyboardCloseboolean — true

Escape closes with role escape. The topmost modal swallows the key either way, so it never falls through to one underneath.

scrollLockboolean — true

Locks body scroll while the modal is open, reference counted across the stack, with scrollbar-width compensation so the layout does not jump.

focusTrapboolean — true

Autofocus, Tab containment, focus restore and inert on the rest of the page. See Accessibility.

canDismissboolean | (d) => boolean | Promise<boolean>

Vetoes a dismissal. See canDismiss.

modalClass, backdropClassstring

Added to .psm-wrapper and .psm-backdrop.

modalStyle, backdropStylestring | Record<string, string | number>

Inline styles for the same two elements. Custom properties are passed through as written: { "--psm-width": "40rem" }.

putAfterstring

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.

ariaLabel, ariaLabelledBy, ariaDescribedBystring

Put on the dialog element. Give every modal one of the first two.

breakpoints, initialBreakpoint, backdropBreakpoint, handle, handleBehavior, expandToScrollsheet
onWillPresent, onDidPresent, onWillDismiss, onDidDismisscallbacks

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

DurationEasingMovement
ios enter500mscubic-bezier(0.32,0.72,0,1)translateY(100vh) → 0, backdrop 0.01 → full
ios leave500mscubic-bezier(0.32,0.72,0,1)the same, reversed
md enter280mscubic-bezier(0.36,0.66,0.04,1)opacity 0.01 → 1, translateY(40px) → 0
md leave200mscubic-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.

Reduced motion

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:

tsa zoom instead of a slide
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, addAnimationstructure

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.

duration, easing, delay, fill, direction, iterationstiming

Set on a parent, inherited by children that leave them unset.

from, to, fromTo, keyframeswhat moves

fromTo repeated for several properties merges into the same two frames. keyframes takes the full array when you need offsets in between.

beforeStyles, beforeAddClass, afterClearStyles, afterAddClass, …hooks

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, pause, stop, destroy, onFinishplayback

play() resolves when the animation finishes or is stopped. onFinish receives 1 when it ran to the end and 0 when it was reversed.

progressStart, progressStep, progressEndgestures

Scrub an animation by hand, then hand control back. This is what makes a finger-driven transition possible.

No Web Animations, no problem

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:

shevery token, with its default
--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)
shglobally, or per modal
:root {
  --psm-border-radius: 14px;
  --psm-max-width: 32rem;
  --psm-backdrop-opacity: 0.45;
}
ts
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.

CSP and SSR

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:

ts
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:

ts
createModal(<Filters />, {
  breakpoints: [0, 0.25, 0.5, 1],
  initialBreakpoint: 0.25,
  handleBehavior: "cycle",
});

Breakpoints#

breakpointsnumber[]

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.

initialBreakpointnumber — required

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.

backdropBreakpointnumber — 0

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.

handleboolean — true for sheets

The grab bar at the top.

handleBehavior"none" | "cycle" — "none"

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.

expandToScrollboolean — true

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.

Corners at full screen

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:

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

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

Focus inon present

The first focusable element inside the modal is focused, or the dialog itself when there is nothing to focus.

Focus containedwhile open

Tab and Shift+Tab wrap inside the modal, and focus that drifts outside is pulled back. Only the topmost modal traps.

Focus outon dismiss

Focus returns to whatever had it before — unless the user has since clicked elsewhere, in which case it is left alone.

The rest of the pageinert + aria-hidden

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.

Give it a name

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.

ts
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:

baseZIndexnumber — 1000

The z-index of the first modal; each one above it gets +1. Equivalent to setting --psm-z-index in CSS.

injectStylesboolean — true

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.

ts
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 AnimationBuilder that forgets to return an animation.
  • Reading data without 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 at role.

Runtime only

  • An initialBreakpoint that is not in breakpoints, or a breakpoint outside 0…1 — both throw at createModal.
  • useModal() outside a modal — it throws rather than returning a handle that does nothing.
  • A trigger id that matches no element — a console warning.