Portals CORE+

Render content to a different location in the DOM while maintaining component ownership and reactivity.

Key Concept: Portals "teleport" content from its source location to a target element elsewhere in the DOM. The content remains bound to its source component's state and actions, even though it's rendered in a completely different part of the page.

Why Use Portals?

Sometimes the visual location of content needs to differ from its logical location in the component hierarchy. Common use cases include:

Modals & Dialogs

Render at <body> level to escape overflow: hidden containers and z-index stacking contexts.

Tooltips & Popovers

Position tooltips outside scrollable containers so they don't get clipped.

Dropdown Menus

Render menus outside their parent container to avoid overflow issues.

Basic Usage

Add data-portal to any element with a CSS selector pointing to the target location:

<!-- Portal target (content teleports here) -->
<div id="notification-area" class="mb-3"
     style="min-height: 50px; border: 2px dashed #ccc; padding: 10px; border-radius: 4px;">
    <small class="text-muted">Portal target area</small>
</div>

<!-- Component with portal -->
<div data-component="portal-demo">
    <button class="btn btn-primary" data-action="toggleNotification">
        Toggle Notification
    </button>
    <span class="ms-2" data-bind="isShowing ? 'Notification visible' : 'Notification hidden'"></span>

    <!-- This content teleports to #notification-area -->
    <div data-portal="#notification-area" data-show="isShowing">
        <div class="alert alert-info mb-0">
            <strong>Success!</strong> This notification is portaled above!
        </div>
    </div>
</div>
wildflower.component('portal-demo', {
    state: {
        isShowing: false
    },

    toggleNotification() {
        this.isShowing = !this.isShowing
    }
})
Live Preview

Notice how the notification appears in the "Portal target area" above the button, even though it's defined inside the component below the button.

Portal Targets

The data-portal attribute accepts any valid CSS selector:

<!-- By ID -->
<div data-portal="#modal-container">...</div>

<!-- By class -->
<div data-portal=".tooltip-layer">...</div>

<!-- To body -->
<div data-portal="body">...</div>

<!-- Complex selector -->
<div data-portal="#app .overlay-container">...</div>
Note: The target element must exist in the DOM when the portal is processed. If the target doesn't exist, the content remains in place and a warning is logged.

Portals with Conditional Rendering

Portals work with data-show and data-render:

With data-show

The content is teleported but visibility is toggled via display: none:

<div data-portal="#tooltip-layer" data-show="showTooltip">
    <div class="tooltip">
        <span data-bind="tooltipText"></span>
    </div>
</div>

With data-render

The content is only teleported when the condition is true, and removed from the target when false:

<div data-portal="#notification-area" data-render="hasNotification">
    <div class="notification">
        <span data-bind="notificationMessage"></span>
    </div>
</div>

Reactivity in Portals

Portaled content maintains full reactivity with its source component:

<!-- Portal target -->
<div id="counter-display" class="mb-3 p-3 bg-info text-white rounded">
    <small>Portaled content appears here:</small>
</div>

<!-- Component with portal -->
<div data-component="counter-portal-demo">
    <div class="card">
        <div class="card-body">
            <h6>Source Component</h6>
            <p>Count: <strong data-bind="count"></strong></p>
            <button class="btn btn-primary btn-sm" data-action="increment">+1 Here</button>
        </div>
    </div>

    <!-- This is portaled to #counter-display above -->
    <div data-portal="#counter-display">
        <div class="d-flex align-items-center gap-2">
            <span>Count: <strong data-bind="count"></strong></span>
            <button class="btn btn-light btn-sm" data-action="increment">+1 From Portal</button>
        </div>
    </div>
</div>
wildflower.component('counter-portal-demo', {
    state: { count: 0 },

    increment() {
        this.count++
        // Both displays update - in source AND in portal!
    }
})
Live Preview

Click either button - both counters update because they share the same component state!

Actions in Portals

Event handlers (data-action) in portaled content call methods on the source component:

<div data-component="modal-actions-demo">
    <button data-action="open">Open Modal</button>

    <div data-portal="body" data-show="isOpen">
        <div class="modal">
            <!-- These actions call methods on modal-actions-demo -->
            <button data-action="save">Save</button>
            <button data-action="close">Cancel</button>
        </div>
    </div>
</div>
wildflower.component('modal-actions-demo', {
    state: { isOpen: false },

    open() { this.isOpen = true },
    close() { this.isOpen = false },

    save() {
        console.log('Save clicked from portal!')
        this.isOpen = false
    }
})

Portals in Lists

Portals work inside data-list iterations. Each list item can have its own portal:

<div data-component="list-with-portals">
    <ul data-list="items">
        <template>
            <li>
                <span data-bind="name"></span>
                <button data-action="showDetails">Details</button>

                <!-- Each item can portal its own tooltip -->
                <div data-portal="#tooltip-layer" data-show="showTooltip">
                    <div class="tooltip" data-bind="description"></div>
                </div>
            </li>
        </template>
    </ul>
</div>

Cleanup

Portaled content is automatically cleaned up when:

  • The source component is destroyed
  • A data-render condition becomes false
  • The portal element is removed from the DOM

The framework tracks all portaled content and ensures proper cleanup to prevent memory leaks.

Best Practices

Do
  • Use portals for modals, tooltips, and dropdowns
  • Ensure portal targets exist before components render
  • Combine with data-show or data-render for conditional display
  • Keep portal content simple when possible
Don't
  • Use portals when regular positioning would work
  • Create deeply nested portal chains
  • Forget to handle keyboard accessibility (focus management)
  • Assume portal content will be in a specific DOM order

Example: Tooltip System

Here's a complete tooltip implementation using portals:

<!-- Tooltip layer at body level -->
<div id="tooltip-layer" style="position: fixed; top: 0; left: 0; pointer-events: none; z-index: 9999;"></div>

<!-- Component with tooltip -->
<div data-component="tooltip-demo">
    <button
        data-action="mouseenter:showTip mouseout:hideTip"
        class="btn btn-primary">
        Hover me
    </button>

    <div data-portal="#tooltip-layer" data-show="showTooltip">
        <div class="tooltip"
             data-bind-style="tooltipStyle">
            This is a tooltip!
        </div>
    </div>
</div>
wildflower.component('tooltip-demo', {
    state: {
        showTooltip: false,
        tooltipStyle: {}
    },

    showTip(event) {
        const rect = event.target.getBoundingClientRect()
        this.tooltipStyle = {
            position: 'absolute',
            left: rect.left + 'px',
            top: (rect.bottom + 5) + 'px'
        }
        this.showTooltip = true
    },

    hideTip() {
        this.showTooltip = false
    }
})

Simple Syntax

Portals use a single attribute, with no wrapper components and no JavaScript API calls:

<!-- Content renders inside #modal-container, not here -->
<div data-portal="#modal-container">
    <div class="modal">I appear elsewhere in the DOM</div>
</div>

The element stays in your component's HTML for easy reading, but renders at the target location. All bindings and reactivity work normally.