Modals

Complete guide to modal configuration, targeting, A/B testing, and accessibility.
developer

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
  • 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
  • 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 from modal_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.
  • animation, animation_in, animation_out, theme: Values available to the template as {{ }} macros. Entry/exit animation is read from data-animation_in / data-animation_out on the fetched template HTML.
  • modal_delay (number): Minutes between shows.
  • limit_type (string): Frequency scope. Set this to enforce a cap. session uses sessionStorage; any other value (including "local") uses localStorage.
  • limit_count (number): Max shows per scope. Applied when limit_type is set.
Info
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.) or null for auto-selection. Use for debugging specific variants or gradual rollouts. Leave null for production A/B tests.
  • variations (array, required): Array of variant objects with name, weight, and modal. 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:

  1. User-set languageHood('setuserlanguage') (BW_INFO.ul)
  2. Browser language — first supported base code from navigator.languages (always at least 'en')
  3. If that code is not in ml: skip when sl is true; otherwise 'en' if 'en' is in ml, else the first entry in ml

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 in ml. When false, fall back to 'en' if it is in ml, otherwise ml[0].
Language filtering behavior

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.txt
  • NjY4bL60/welcome-popup-es.txt
  • NjY4bL60/welcome-popup-fr.txt

Language fallback behavior

Scenario 1: User language is supported

  • User Language: "es"
  • ml List: ["en", "es", "fr"]
  • sl Setting: false
  • Result: ✅ Shows Spanish template

Scenario 2: User language not supported, fallback enabled

  • User Language: "de"
  • ml List: ["en", "es", "fr"]
  • sl Setting: false
  • Result: ✅ Shows English template (first in ml)

Scenario 3: User language not supported, skip enabled

  • User Language: "de"
  • ml List: ["en", "es", "fr"]
  • sl Setting: true
  • Result: ⏭️ Modal skipped (language not allowed)

Scenario 4: All languages allowed

  • User Language: "en"
  • ml List: []
  • sl Setting: 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 or 0 disables. 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/tab
  • local: 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 attributesrole="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.

ActionEffect
pushShowRequests the native push notification prompt
modalHideCloses the modal
ctaValidates and submits the modal’s form data
openPageNavigates to another page of a multi-page modal
openUrlOpens 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>
`pushHide` is not an action
Use 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.