Popup Forms & Triggers
Show a form in a modal at the moment a visitor is most likely to fill it in: as they move to leave, after a delay, once they scroll past a point, on a button click, or when a section comes into view.
Turn on popup mode
Open a form in the dashboard, go to its Display settings, and set the display mode to Popup. Save the form, then embed it the same way you embed an inline form:
<script src="https://www.kirokuforms.com/embed.js"
data-kiroku-form-id="YOUR_FORM_ID"
async></script> A popup form arms its own trigger from the saved Display settings (an exit-intent or a delay), so this single script tag is enough to make it appear. The rest of this guide shows how to pick a different trigger and how to open the modal from your own code.
One modal, shared behavior
Escape to close, and reduced-motion handling apply to all of
them without extra work on your side.
Set it in the form JSON / API
Popup display is part of the form definition, under
settings.popup, so you can set it programmatically instead
of in the dashboard. The create and update endpoints (POST /api/forms and PUT /api/forms/:id) accept it, and it round-trips
through template import and export. Enabling popup mode is a paid-plan
feature; the API force-disables it for free-tier accounts.
{
"settings": {
"popup": {
"enabled": true,
"trigger": "exit",
"delayMs": 8000,
"scrollPercent": 50,
"intentLevel": "warm",
"selector": "#book-a-demo",
"and": [
{ "trigger": "scroll", "scrollPercent": 50 },
{ "trigger": "inactivity", "delayMs": 8000 }
],
"exitSensitivityPx": 20,
"mouseLeaveDelayMs": 1000,
"devices": "all",
"pages": { "include": ["/pricing", "/blog/*"], "exclude": ["/checkout"] },
"frequencyDays": 7,
"maxShows": 3,
"decline": { "enabled": true, "text": "No thanks" },
"trackingConsent": { "cookie": "visitor_consent", "category": "analytics" }
}
}
}
The trigger values are the eight documented below, and each
one reads a single parameter: delayMs is the delay for
delay or the idle window for inactivity,
scrollPercent is the depth for scroll,
intentLevel is warm or hot for
intent, selector is the element for
click and visible, and and holds the
two conditions for all. The example sets all of them to
show the shape; a saved form keeps the ones it is not using, so
switching trigger and switching back returns to what you had.
Two more fields tune how exit reads a departing cursor, and
both have a working default, so set them only if the popup is opening too
readily or not readily enough. exitSensitivityPx is how far
down the window a cursor can be when it crosses the boundary and still count
as leaving through the top, 20 by default; a cursor thrown at the tab strip
is moving fast enough to be sampled a few pixels inside the window, which
is why the band is not the top pixel alone.
mouseLeaveDelayMs is how long the engine waits before believing
the departure, 1000 by default, and a cursor back inside the window cancels
it. Both are in the Display settings as well. Neither touches the four cursor-free
signals: a phone reads the same either way.
Three fields apply whichever trigger you pick. devices is
all, desktop or mobile and is checked
once when the trigger arms, so a visitor the popup is not meant for gets
no listeners at all; absent means every device.
frequencyDays is how long to wait before the same visitor sees
it again, with 0 meaning once per session and absent meaning
30 days, the same window the Display settings write.
maxShows is the total number of times one visitor may ever see
it, and absent means no cap. The two are independent: set both and whichever
is stricter at that moment applies.
pages limits the popup to part of the site. It holds two lists
of paths, include and exclude, and a path may
end in * to cover everything under it:
/pricing is that page alone, /blog/* is any page
under /blog, and /blog itself is not one of them.
An absent or empty include means every page, so a rule carrying
only an exclude reads as everywhere except. A path matching
both lists is excluded. The mistake that order produces is a popup that stays
shut, never one that opens on a page you ruled out.
Paths are compared with the query string and the hash discarded, without
case, and without a trailing slash, so a visitor arriving at
/Pricing/?utm_source=x#plans is on /pricing.
The same repair runs on what you send, and a path with no leading slash
gets one. Each list keeps up to 20 paths of up to
200 characters, and drops the rest at save while
the form still saves. Nothing you send reaches the regex engine as syntax:
the path is split on * and every other character is escaped
before it is matched, so a pattern cannot become a regular expression by
accident or on purpose. The rule is compiled once when the trigger arms,
and a form without one never reads location at all. It is in
the Display settings as well.
trackingConsent says where the visitor's own consent banner
records its answer, for a site that runs one. Name the source with
cookie, storageKey or windowPath,
and add category when the banner records several permissions,
naming the one that covers analytics. We never ship a banner of our own,
so this is the only way we can know what a visitor was asked.
consentMode is the fourth source and the only one that needs
no name from you: set it to true and we read the answer Google
Consent Mode publishes on dataLayer, under
analytics_storage. Any site running Google Tag Manager or
gtag already has it, and a consent tool that speaks Consent Mode
publishes there whatever it calls its own cookie. A string names another
key instead, so ad_storage and the other advertising keys are
reachable without being the default. A default command scoped
to a region is skipped rather than read as a global answer,
and the last command carrying the key wins, so a visitor who changes their
mind is honoured. It is in the Display settings as well.
It governs one layer: the shown, converted and
declined events. An answer of no stops those three, and nothing
else. The modal still opens, the re-show cap still applies, and the form
still submits, because a visitor who declines tracking has not declined the
popup. Absent, or naming a value that cannot be read, leaves counting on:
most sites have no banner, and a popup that counts nothing with no error
is worse than one that counts. The signal is read once before the first listener
is attached, and then re-read about once a second while the popup is live,
so a visitor who withdraws through your banner is honoured in the page view
they are in rather than the next one. A withdrawal is terminal: the engine
drops its triggers and counts nothing further for that visitor, and it leaves
a modal already on screen open and whatever they have typed in it alone.
Nothing is polled on a form that names no source. It is in the Display settings
as well.
Trigger types
A trigger decides when the modal opens. The engine supports eight types,
the same eight the Display settings offer. Each takes an argument except
exit, bare intent, and all:
-
exit: fires on the signals that someone is on their way off the page. On a computer that is the cursor leaving through the top of the window; on a phone it is switching away from the tab or flicking back to the top. All of them are listed under what counts as leaving.Start with
exiton its own, because its signals are all on by default and between them they cover a visitor with a cursor and a visitor without one. -
delay:8000: fires after the given number of milliseconds on the page (here, 8 seconds).Start with 8 seconds on a short landing page and 20 to 30 seconds on a long article, because the timer needs to outlast the time it takes to read enough of the page to know what you are offering.
-
inactivity:30000: fires after the given number of milliseconds with no interaction (here, 30 seconds). A mouse move, a click, a tap, a key press, a scroll or a touch restarts the countdown, and a click counts even when no pointer produced it, so a button pressed from the keyboard or through assistive technology keeps the visitor counted as present. A scroll counts whether the page itself moves or only a panel inside it that scrolls on its own. Unlikedelay, which counts time on the page, this counts idle time since the last interaction.Start with 30 seconds, because a shorter idle window fires while the visitor is still reading the part of the page they stopped on, and a reader who has stopped to read is the one person you do not want to interrupt.
-
scroll:50: fires once the visitor has scrolled past the given percent of the page height (here, halfway down).Start with 50, because half the page is past the opening pitch on most layouts and still leaves room to act. Raise it on a long article so the form arrives after the argument; lower it on a short page where 50 percent is one screen.
-
click:#selector: fires when the visitor clicks any element matching the CSS selector. With an<a href>target the modal opens and the browser stays on the page; without JavaScript the link still navigates.Start with the id of the one button that already asks for the same thing, for example
click:#book-a-demo, because a broad selector such asclick:aopens the modal on every link on the page. -
visible:#selector: fires when the element matching the selector scrolls into view.Start with the section that makes the case for the form, for example
visible:#pricing, because by the time that section is on screen the visitor has read the argument for filling it in. -
intent:hot(or bareintent/intent:warm): fires when VeritaMetrics' cookieless engagement detection reads the session as warm or hot. The site must run VeritaMetrics with warm-lead capture on (data-warm-lead="true"), which dispatches averitametrics:intentbrowser event; this trigger listens for it and opens the form. No personal data is in the signal.Start with
intent:warm, because hot is a narrow tier and a form that waits for it can go a whole session without opening. Move tointent:hotonly once warm turns out to be too broad for what you are asking. -
all: a composite AND. Join conditions with&(for exampleintent:warm&exit); the form opens only once every listed trigger has fired. Use it to greet engaged visitors who are leaving, warm intent and exit, rather than every passer-by.Start with two conditions that measure different things, for example
intent:warm&exit, because a pair that measures the same thing twice (a delay and an idle window) cuts the audience without telling you anything new. In the form JSON the pair lives inand; the Display settings show which two conditions are in force.
Every trigger fires at most once per page load, click
included: when the modal opens the engine drops its listeners and timers.
For a button that reopens the form on every click, use
data-kiroku-open below, which is not capped that way.
What counts as leaving
exit watches five departure signals. Only the first needs a
cursor, which is why the trigger works on a phone. Any one of them opens
the form, and the arming guard below applies to all five.
- The cursor leaves through the top of the window. The desktop
gesture: the tab strip, the address bar and the other tabs are all up there.
A departure through the side or the bottom is ignored. The engine waits
a second before believing it, so a cursor that comes straight back cancels
it. Both the width of the top band and the length of that wait are settings
(
exitSensitivityPxandmouseLeaveDelayMs, above); they apply to this signal only. - The visitor switches away from the tab. Backgrounding the tab, switching to another tab, or locking the phone. No cursor involved, which is what makes this the main signal on a phone.
- The window loses focus. Switching to another application, or clicking into the address bar. Covers every platform.
- A fast scroll back to the top. The reach for the browser chrome a thumb makes. It has to be quick and it has to end near the top of the page: the same distance covered slowly is someone reading their way back up, and a single jump is a "back to top" button or a restored scroll position.
- The address bar shortcut, Cmd+L. Whatever gets typed there next is somewhere other than your page. This one is macOS only: Ctrl+L is the same shortcut on Windows and Linux and is deliberately not watched, because pages sometimes bind Ctrl chords for themselves and we would rather miss a signal than steal a keystroke. Losing it costs little, since losing focus to the address bar already fires on every platform through the signal above.
How soon a popup can open
Each trigger holds the modal until its own condition is met. On top of
that, the four triggers the engine infers from behaviour (exit, scroll, visible and intent)
wait for a minimum time on the page before anything they see counts, 15
seconds by default. It is a guard rather than a trigger: when it elapses
nothing opens, the trigger's own signal still has to arrive.
What the guard does to a signal that arrives inside it depends on whether the signal is a state or a gesture. A state is still true when the guard lifts and is read then, so a visitor already past the scroll mark opens the form as soon as it lifts. A gesture is over by then and is dropped: a cursor leaving the page or an intent event in the first 15 seconds does not open the form later, because replaying it would open the form at a moment the visitor did nothing. The same gesture made after the guard opens the form as usual.
The other three triggers are not held, because each one names its own
moment. delay and inactivity open at a time you
typed into the dashboard, so a floor would overrule it; click
opens because the visitor pressed the element, and guarding that would leave
your button dead for the first 15 seconds. all arms each half
through the same path, so a half is held or not on its own.
To hold a path back for longer than the guard, combine the trigger with
a delay: exit&delay:30000 opens on the exit that follows
30 seconds on the page. The two halves latch independently and in either
order, so a departure gesture at 20 seconds waits for the timer to complete
at 30. A gesture inside the first 15 seconds is the case above: the exit half is still under the guard, so it is dropped and the visitor has to
make the gesture again.
Combine several triggers
Pass a comma-separated list to open the modal on whichever condition
happens first. This example opens the form at the halfway scroll mark or
as soon as the visitor clicks #demo-cta, whichever comes
first:
<script src="https://www.kirokuforms.com/embed.js"
data-kiroku-form-id="YOUR_FORM_ID"
data-kiroku-trigger="scroll:50, click:#demo-cta"
async></script> Open a popup on demand
Besides the saved trigger, you can open the modal yourself from a button, from your own JavaScript, or from a custom event. Pick whichever couples least with your page code.
From a button: data-kiroku-open
Put data-kiroku-open="YOUR_FORM_ID" on any element. A click
opens that form in the modal. The handler is delegated from the document,
so elements added after the script loads still work.
<script src="https://www.kirokuforms.com/embed.js" async></script>
<button data-kiroku-open="YOUR_FORM_ID">Contact sales</button>
From your code: window.KirokuForms.open()
Call window.KirokuForms.open(formId) when your own logic decides
the moment is right. An unknown form id fails quietly and leaves the page
untouched.
window.KirokuForms.open("YOUR_FORM_ID");
From an event: kiroku:open
Dispatch a document-level kiroku:open CustomEvent with the form
id in its detail. Your trigger code then stays decoupled
from the embed:
document.dispatchEvent(
new CustomEvent("kiroku:open", { detail: { formId: "YOUR_FORM_ID" } })
); Override the trigger per placement
The form's saved trigger applies wherever you embed it. To use a
different trigger for one placement, set data-kiroku-trigger
on that script tag. It accepts the same values as the saved trigger:
exit, delay:8000,
inactivity:30000, scroll:50,
click:#selector, visible:#selector,
intent:warm, an & pair such as
intent:warm&exit, and comma-separated lists.
<script src="https://www.kirokuforms.com/embed.js"
data-kiroku-form-id="YOUR_FORM_ID"
data-kiroku-trigger="click:#pricing-contact"
async></script> Keep a no-JavaScript fallback
click trigger targets a link, keep the link's
href pointing at a real page (for example your
contact page). Visitors with JavaScript get the
modal; visitors without it follow the link, so both paths reach the
form.
What the engine does not do
Two behaviours are fixed, so you can integrate against them without checking whether a form's settings changed them.
A decline is durable
A passive close (the X, Escape, a click on the backdrop) starts the
frequencyDays window. An explicit decline is a stronger signal,
so it starts its own longer one: the form stays shut for that visitor for
90 days, and that holds even when frequencyDays is
0 for per-session re-showing. Set
settings.popup.decline.suppressDays to pick a different window,
or 0 to switch the longer window off and fall back to
frequencyDays.
The back button is left alone
We do not intercept the browser back button. The engine pushes no
history entry and listens for no popstate, so Back goes to
the previous page whether or not a popup is open or has been shown.
A history trap is the usual substitute for exit intent on a touch
device, where there is no pointer to leave the top of the viewport.
exit reads the tab switch, the window losing focus and the fast
scroll to the top instead, so it has real signals to work from there and
never has to hold anyone on the page to get one.
Next steps
- See what a popup does without any code, and the Display setting behind each one, on Popups and triggers.
- See how a popup, a branching question and a result make one form on Forms that ask one question at a time.
- See the popup open API alongside other embed patterns in our Code Examples.
- Set up event-driven integrations with the Webhooks Guide.
- Return to the Developer Hub.