Modals
What are modals?
Modals are rule-driven overlays that appear on your website to engage users with targeted messages, offers, or calls-to-action. They’re rendered in Shadow DOM (with iframe fallback) and follow a simple flow:
trigger → filters → show modal
Benefits:
- Targeted messaging — Show relevant content based on user behavior, location, or properties
- A/B testing — Test different versions to optimize conversion
- Multi-language support — Automatically serve content in user’s preferred language
- Accessibility built-in — WCAG compliant with focus management and screen reader support
- Flexible triggers — Page views, scroll, timer, clicks, form submissions, and more
Modal types
- Popup: Centered overlay with backdrop. Use case: Promotions, newsletter signups, announcements. Benefit: High visibility, user attention
- Fullscreen: Covers entire viewport. Use case: Onboarding flows, important announcements. Benefit: Maximum impact, no distractions
- Sidebar: Slides in from side. Use case: Additional info, navigation, filters. Benefit: Non-intrusive, contextual
- Inline: Embedded in page content. Use case: Product recommendations, related content. Benefit: Seamless integration
Modal configuration
id(string, required): Unique identifier for the modal. Use descriptive names (e.g.,welcome-modal,exit-intent-promo) for easier debugging and analytics tracking.template(string, required): Template name fetched frommodal_url. Follow naming convention:<template>-<lang>.txt(e.g.,welcome-popup-en.txt). Templates support{{ }}macros for dynamic content.group(string, optional): Group name to prevent multiple modals from same group showing on one page. Use for related modals (e.g.,"entry"for welcome/onboarding flows) or competing campaigns.triggers(array, required): Defines when a modal is eligible to show (lifecycle, timing, user actions, element visibility). Triggers capture events, then the engine evaluates filters before rendering. See Events and triggers.filters(array, optional): Defines who should see the modal using a rule engine (macros + operators). Filters evaluate user/session/url/context to include/exclude audiences with precise conditions. See Filtering.options(object, optional): Modal behavior and appearance options. Controls animations, limits, theming, and accessibility features. Close behavior is not set here — see Close behavior options.
Modal options
animation,animation_in,animation_out,theme: Values available to the template as{{ }}macros. Entry/exit animation is read fromdata-animation_in/data-animation_outon the fetched template HTML.modal_delay(number): Minutes between shows.limit_type(string): Frequency scope. Set this to enforce a cap.sessionuses sessionStorage; any other value (including"local") uses localStorage.limit_count(number): Max shows per scope. Applied whenlimit_typeis set.
close_click, close_esc, close_cta, close_auto, and body_scroll are not options keys. Putting them in modals_config has no effect. Set them as data-* attributes on the modal template HTML — see Close behavior options.Basic modal structure
{
"id": "welcome-modal",
"template": "welcome-popup",
"group": "entry",
"triggers": [{ "type": "pageView" }],
"filters": [
{ "c": "{{data.segment}}", "o": "eq", "m": "prospect" }
],
"options": {
"animation": "fade-in",
"limit_type": "session",
"limit_count": 1
}
}
A/B testing
Modals support A/B testing to optimize conversion rates and user engagement. You can test different templates, animations, triggers, or complete modal configurations to find what works best for your audience.
Benefits:
- Conversion optimization — Test different CTAs, messaging, or designs
- User experience insights — Understand which variants perform better
- Data-driven decisions — Make informed choices based on real user behavior
- Consistent experience — Users see the same variant across sessions (when
persist: true)
A/B test configuration
id(string, required): Unique identifier for the A/B test. Use descriptive names (e.g.,newsletter-signup-ab,promo-banner-test) for easier analytics tracking and debugging.persist(boolean, default:false): Store chosen variant in localStorage for consistency across sessions. Enable for user experience continuity; disable for true randomization on each visit.variant(string, default:null): Force specific variant ("A","B", etc.) ornullfor auto-selection. Use for debugging specific variants or gradual rollouts. Leavenullfor production A/B tests.variations(array, required): Array of variant objects withname,weight, andmodal. Each variant defines a complete modal configuration. Weight determines selection probability (equal weights = 50/50 split).
When persist is true, the chosen variant is stored under m_<modalId>.ab.
{
"id": "promo-ab-test",
"ab": {
"id": "promo-ab-experiment",
"persist": true,
"variant": null,
"variations": [
{
"name": "A",
"weight": 1,
"modal": {
"template": "promo-variant-a",
"triggers": [{ "type": "pageView" }],
"options": { "theme": "light" }
}
},
{
"name": "B",
"weight": 1,
"modal": {
"template": "promo-variant-b",
"triggers": [{ "type": "pageView" }],
"options": { "theme": "dark" }
}
}
]
}
}
A/B test example
{
"id": "newsletter-signup",
"ab": {
"id": "newsletter-ab",
"persist": true,
"variations": [
{
"name": "Control",
"weight": 1,
"modal": {
"template": "newsletter-control",
"options": { "animation": "fade-in" }
}
},
{
"name": "Variant A",
"weight": 1,
"modal": {
"template": "newsletter-variant-a",
"options": { "animation": "slide-up" }
}
}
]
}
}
Multi-language modals
Language detection logic
The SDK determines which language to use for modal templates using this priority:
- User-set language —
Hood('setuserlanguage')(BW_INFO.ul) - Browser language — first supported base code from
navigator.languages(always at least'en') - If that code is not in
ml: skip whenslis true; otherwise'en'if'en'is inml, else the first entry inml
Language options
ml(array, default:[]): Allowed language codes (e.g.,["en", "es", "fr"]).sl(boolean, default:false): Skip the modal when the resolved language is not inml. Whenfalse, fall back to'en'if it is inml, otherwiseml[0].
sl: true — If the resolved language is not in ml, the modal is skipped.
sl: false — If the resolved language is not in ml, the SDK uses 'en' when that code is in ml, otherwise the first entry in ml.
Language configuration
{
"id": "welcome-multilang",
"template": "welcome-popup",
"ml": ["en", "es", "fr", "de"], // Allowed languages
"sl": true // Skip modal if resolved language is not in ml
}
Template naming convention
Templates are fetched from modal_url using this pattern:
<tag-prefix>/<template>-<lang>.txt
Examples:
NjY4bL60/welcome-popup-en.txtNjY4bL60/welcome-popup-es.txtNjY4bL60/welcome-popup-fr.txt
Language fallback behavior
Scenario 1: User language is supported
- User Language:
"es" mlList:["en", "es", "fr"]slSetting:false- Result: ✅ Shows Spanish template
Scenario 2: User language not supported, fallback enabled
- User Language:
"de" mlList:["en", "es", "fr"]slSetting:false- Result: ✅ Shows English template (first in
ml)
Scenario 3: User language not supported, skip enabled
- User Language:
"de" mlList:["en", "es", "fr"]slSetting:true- Result: ⏭️ Modal skipped (language not allowed)
Scenario 4: All languages allowed
- User Language:
"en" mlList:[]slSetting:false- Result: ✅ Shows English template (default)
Close behavior options
Close behavior is read from data-* attributes on the fetched modal template (the .txt file at modal_url), not from modals_config.modals[].options. The SDK never copies these keys from JSON config onto the template.
Put the attributes on the template root element:
<div
data-close_click="true"
data-close_esc="true"
data-close_cta=""
data-close_auto="0"
data-body_scroll="0"
>
<!-- modal content -->
</div>
Close behavior
data-close_click(default: enabled): Overlay click closes the modal. Set"false"to disable.data-close_esc(default: enabled): Escape key closes the modal. Set"false"to disable. Recommended for accessibility unless a forced action is required.data-close_cta(default: unset): When set to a truthy value (for example"true"), overlay click and Escape are ignored and only CTA actions close the modal. Use for gated actions (permission prompts, compliance steps). Combine with a visible secondary button for ethical UX.data-close_auto(default: disabled): Auto-dismiss after N milliseconds. Omit or0disables. Great for toast-like confirmations; avoid for forms or long reads.data-body_scroll(default: locked): Set"1"to allow the page behind the modal to scroll. Any other value locks body scroll.
Close behavior examples
<!-- Standard modal — closes on overlay click or Escape -->
<div data-close_click="true" data-close_esc="true">
...
</div>
<!-- CTA-only modal — overlay and Escape ignored -->
<div data-close_click="false" data-close_esc="false" data-close_cta="true">
...
</div>
<!-- Auto-closing modal — closes after 10 seconds -->
<div data-close_click="true" data-close_esc="true" data-close_auto="10000">
...
</div>
Frequency and limits
Limit types
session: Browser session scope. Resets when user closes browser/tablocal: localStorage scope. Persists across browser sessions
Limit configuration
{
"options": {
"limit_type": "session", // or "local"
"limit_count": 3 // Show max 3 times per scope
}
}
Grouping
Modals can be grouped to control display behavior and prevent conflicts. All modals and their triggers are registered simultaneously in the order they’re defined.
Group behavior:
- With group: Only the first modal in a group that meets trigger/filter conditions will be shown
- Without group: All modals without groups will be shown sequentially (one after another) as their triggers fire
Display order:
- Modals are displayed one at a time, not simultaneously
- When one modal closes, the next modal in the queue will be shown
- Order depends on when triggers fire and filters are satisfied
{
"id": "welcome-modal",
"group": "entry",
"options": { "limit_type": "session", "limit_count": 1 }
},
{
"id": "exit-intent-modal",
"group": "entry",
"options": { "limit_type": "session", "limit_count": 1 }
}
Only one modal from the “entry” group will show per page, regardless of triggers.
Accessibility
Built-in accessibility features
- Focus management — Focus is trapped inside the modal
- ARIA attributes —
role="dialog",aria-modal="true" - Screen reader support — Optional
aria-label,aria-labelledby,aria-describedby - Keyboard navigation — Escape key closes modal
- High contrast — Supports system theme preferences
Accessibility configuration
{
"options": {
"theme": "auto" // Respects user's system theme preference
}
}
Keyboard dismiss is controlled on the template with data-close_esc (enabled unless set to "false"). See Close behavior options.
CTA actions
Actions are wired through a data-actions attribute holding a JSON array of action names. An element without data-actions does nothing, no matter what its id is.
| Action | Effect |
|---|---|
pushShow | Requests the native push notification prompt |
modalHide | Closes the modal |
cta | Validates and submits the modal’s form data |
openPage | Navigates to another page of a multi-page modal |
openUrl | Opens a URL |
CTA implementation
Template HTML:
<div class="modal-content">
<h2>Stay updated!</h2>
<p>Get notified about new features and updates.</p>
<button data-actions='["pushShow"]' class="cta-button">
Enable notifications
</button>
<button data-actions='["modalHide"]' class="secondary-button">
No thanks
</button>
</div>
modalHide to close the modal from a dismiss button. There is no pushHide action in the modal system, so a button relying on it does nothing.Template HTML (CTA-only close — not an options key):
<div data-close_cta="true" data-close_click="false" data-close_esc="false">
...
</div>
Modal configuration:
{
"id": "push-prompt",
"template": "push-notification-prompt"
}
Custom behaviour
Built-in data-actions run in the SDK. For extra behaviour, attach a listener on an element in the modal template and keep data-actions for the SDK-side effects you still need.
<button data-actions='["modalHide"]' id="myButton" class="cta-button">
Continue
</button>
<script>
// Runs inside the modal template
document.getElementById('myButton').addEventListener('click', function () {
// Your own logic here; modalHide still closes the modal
});
</script>
To report such an interaction back to Hood, call Hood('trackevent', 'my_event') from that handler.
Examples
Basic welcome modal
{
"id": "welcome",
"template": "welcome-popup",
"triggers": [{ "type": "pageView" }],
"filters": [
{ "c": "{{data.segment}}", "o": "eq", "m": "prospect" }
],
"options": {
"animation": "fade-in",
"limit_type": "session",
"limit_count": 1
}
}
A/B tested promotional modal
{
"id": "promo-ab",
"ab": {
"id": "promo-experiment",
"persist": true,
"variations": [
{
"name": "A",
"weight": 1,
"modal": {
"template": "promo-variant-a",
"triggers": [{ "type": "scroll", "config": { "vertical": 50 } }],
"options": { "theme": "light", "animation": "slide-up" }
}
},
{
"name": "B",
"weight": 1,
"modal": {
"template": "promo-variant-b",
"triggers": [{ "type": "scroll", "config": { "vertical": 50 } }],
"options": { "theme": "dark", "animation": "zoom-in" }
}
}
]
}
}
Multi-language exit intent modal
{
"id": "exit-intent-multilang",
"template": "exit-intent-popup",
"triggers": [{ "type": "beforeunload" }],
"ml": ["en", "es", "fr", "de"],
"sl": false,
"options": {
"animation": "slide-up",
"limit_type": "session",
"limit_count": 1
}
}
Why Shadow DOM with iframe fallback?
We prioritize Shadow DOM for rendering because it provides:
- Isolation and safety: Modal CSS/JS are encapsulated and won’t leak into the page, nor will page styles break the modal.
- Predictable theming: Internal class names and animations won’t collide with site CSS; high-contrast/dark-mode can be applied reliably.
- Accessibility control: Focus trap, ARIA roles, keyboard handlers live in an isolated subtree without side effects.
- Performance: No additional browsing context; lower overhead than iframes in modern browsers.
We fall back to an iframe when isolation must be absolute or Shadow DOM is not viable:
- Hard CSS collisions: Site-wide resets or aggressive selectors still affect injected HTML; iframe guarantees complete sandboxing.
- CSP or script execution constraints: If inline scripts/styles or event handlers are restricted, iframe can succeed where Shadow DOM doesn’t.
- Browser/edge failures: If creating a shadow root or injecting styles fails (rare), iframe ensures the modal still renders consistently.
In short: Shadow DOM is the default for speed and clean integration; iframe is the safety net for maximum isolation when the host page environment is hostile.