NebulaNebula
Component

Reusable Modal & Wizard Component

Nebula.modal() is a composable mixin — spread it into any Alpine.js component to get animated modals, multi-step wizards, keyboard handling, and focus trapping with zero boilerplate.

Architecture: Mixin, Not Standalone

Unlike traditional Magento modals that are self-contained widgets, Nebula.modal() is a plain JavaScript function that returns an object of Alpine.js reactive properties and methods. You compose it into your own component with the spread operator.

This means any component can become a modal — a product option wizard, a confirmation dialog, a file picker — without inheritance, event buses, or framework lock-in. Your component owns its data; the mixin only adds modal-specific state and behavior.

The pattern
Alpine.data('myComponent', function(config) {
    return {
        ...Nebula.modal({ size: 'lg', steps: ['Pick', 'Configure'] }),

        // Your component's own data and methods
        items: config.items || [],
        save() { /* ... */ }
    };
});

Before & After

Traditional approach

  • - 200+ lines of boilerplate per modal
  • - Manual DOM manipulation for open/close
  • - Custom z-index management per modal
  • - No wizard/step support built-in
  • - Escape key and backdrop click wired manually
  • - Transition animations hand-coded each time
  • - Scroll lock and focus trap forgotten

With Nebula.modal()

  • + One-line spread: ...Nebula.modal(config)
  • + Reactive modalOpen drives everything
  • + Consistent z-index, backdrop blur, animations
  • + Built-in multi-step wizard with guards
  • + Escape key and backdrop click-to-close automatic
  • + Fade + scale transitions via Alpine x-transition
  • + Auto focus-trap on first input after open

Configuration Reference

OptionTypeDefaultDescription
sizestring'lg'Modal width — sm, md, lg, xl, or full
stepsstring[][]Step labels for wizard mode. Empty = simple modal.
titlestring''Static modal title. Override dynamically via modalTitle.
onOpenFunction|nullnullCallback after modal opens. this = component.
onCloseFunction|nullnullCallback after modal closes. this = component.

Properties & Methods

Reactive Properties

modalOpenbooleanWhether the modal is visible.
modalStepnumberCurrent wizard step (zero-based).
modalStepsstring[]Array of step labels.
modalSizestringCurrent size key.
modalTitlestringCurrent title. Writable — override dynamically.

Methods

openModal()Opens modal, resets to step 0, fires onOpen callback, dispatches nebula-modal:open event.
closeModal()Closes modal, fires onClose callback, dispatches nebula-modal:close event.
nextModalStep(canProceed?)Advances to next step. Optional guard function must return true to proceed.
prevModalStep()Goes back one step. No-op on step 0.
goToModalStep(index)Jumps to a specific step by zero-based index.

Computed Getters

modalSizeClassstringTailwind max-width class (e.g. 'max-w-3xl').
isFirstModalStepbooleanTrue when modalStep === 0.
isLastModalStepbooleanTrue on last step or when no steps configured.
hasModalStepsbooleanTrue when steps array is non-empty.
currentModalStepLabelstringLabel of current step, or '' if no steps.
modalStepCountnumberTotal number of wizard steps.

HTML Template Pattern

The modal requires a specific HTML structure for transitions to work. The backdrop fades independently while the card scales in/out. The card uses flex-column with a max height so the content scrolls while header and footer stay pinned.

Modal HTML structure
<!-- Place inside your x-data root element -->
<div x-show="modalOpen" x-cloak
     class="fixed inset-0 z-[9999] flex items-center justify-center"
     @keydown.escape.window="closeModal()">

    <!-- Backdrop (fade transition) -->
    <div x-show="modalOpen"
         x-transition:enter="transition ease-out duration-200"
         x-transition:enter-start="opacity-0"
         x-transition:enter-end="opacity-100"
         x-transition:leave="transition ease-in duration-150"
         x-transition:leave-start="opacity-100"
         x-transition:leave-end="opacity-0"
         class="absolute inset-0 bg-black/50 backdrop-blur-sm"
         @click="closeModal()"></div>

    <!-- Modal card (scale transition) -->
    <div :class="modalSizeClass"
         class="relative w-full max-h-[90vh] flex flex-col rounded-2xl bg-white shadow-2xl"
         x-transition:enter="transition ease-out duration-300"
         x-transition:enter-start="opacity-0 scale-95"
         x-transition:enter-end="opacity-100 scale-100"
         x-transition:leave="transition ease-in duration-200"
         x-transition:leave-start="opacity-100 scale-100"
         x-transition:leave-end="opacity-0 scale-95"
         @click.stop>

        <!-- Sticky header -->
        <div class="sticky top-0 z-10 bg-white border-b border-gray-200
                    px-8 py-5 rounded-t-2xl shrink-0">
            <div class="flex items-center justify-between mb-4">
                <h2 class="text-lg font-bold text-gray-900"
                    x-text="modalTitle"></h2>
                <button @click="closeModal()" type="button">X</button>
            </div>
            <!-- Step indicators (wizard mode only) -->
        </div>

        <!-- Scrollable content -->
        <div class="px-8 py-6 overflow-y-auto flex-1">
            <div x-show="modalStep === 0">Step 1 content</div>
            <div x-show="modalStep === 1">Step 2 content</div>
        </div>

        <!-- Sticky footer -->
        <div class="sticky bottom-0 bg-white border-t border-gray-200
                    px-8 py-4 rounded-b-2xl flex items-center
                    justify-between shrink-0">
            <button @click="isFirstModalStep ? closeModal() : prevModalStep()"
                    x-text="isFirstModalStep ? 'Cancel' : 'Back'"></button>
            <button @click="isLastModalStep ? save() : nextModalStep()"
                    x-text="isLastModalStep ? 'Save' : 'Next'"></button>
        </div>
    </div>
</div>
Key structural rules: The backdrop and card are siblings with independent transitions. The card uses flex flex-col + max-h-[90vh] so content scrolls while header/footer stay pinned. @click.stop on the card prevents backdrop click-to-close when clicking inside.

Creating a Custom Wizard

1

Define your Alpine component with the mixin

my-wizard.js
(function() {
    if (window._myWizardRegistered) return;
    window._myWizardRegistered = true;

    var register = function() {
        Alpine.data('myWizard', function(config) {
            return {
                ...Nebula.modal({
                    size: 'lg',
                    steps: ['Choose Type', 'Configure', 'Review'],
                    onClose: function () {
                        this.selectedType = '';
                        this.itemName = '';
                    }
                }),

                items: config.existing || [],
                selectedType: '',
                itemName: '',

                selectType(type) {
                    this.selectedType = type;
                    this.nextModalStep();
                },

                saveItem() {
                    this.items.push({
                        type: this.selectedType,
                        name: this.itemName
                    });
                    this.closeModal();
                }
            };
        });
    };

    if (window.Alpine) { register(); }
    else { document.addEventListener('alpine:init', register); }
})();
2

Build the template with the standard HTML pattern

Use x-show="modalStep === N" to show/hide each step's content. The footer buttons use the computed getters to adapt labels and behavior.

template.phtml
<div x-data="myWizard({existing: []})">
    <button @click="openModal()" type="button"
            class="rounded-lg bg-indigo-600 px-4 py-2 text-white">
        Add Item
    </button>

    <!-- Modal (use the full HTML pattern from above) -->
    <div x-show="modalOpen" x-cloak
         class="fixed inset-0 z-[9999] flex items-center justify-center"
         @keydown.escape.window="closeModal()">
        <!-- backdrop + card + header with steps + content + footer -->
    </div>
</div>
3

Use guarded transitions between steps

Pass a validation function to nextModalStep() to prevent advancing until conditions are met.

<!-- In the footer "Next" button -->
<button @click="nextModalStep(function() { return selectedType !== ''; })"
        :disabled="selectedType === ''"
        class="rounded-lg bg-indigo-600 px-5 py-2 text-white
               disabled:opacity-40 disabled:cursor-not-allowed">
    Next
</button>
4

Wire up the onClose callback for cleanup

The onClose callback runs after every close (Escape, backdrop click, or explicit closeModal()). Use it to reset wizard state so re-opening starts fresh.

...Nebula.modal({
    steps: ['Pick', 'Configure'],
    onClose: function () {
        this.selectedType = '';
        this.itemName = '';
        // modalStep is auto-reset to 0 on next openModal()
    }
})

Size Variants

sm
max-w-md
448px
Confirmations, alerts
md
max-w-xl
576px
Simple edit forms
lg
max-w-3xl
768px
Default — wizards, option editors
xl
max-w-5xl
1024px
Complex multi-column forms
full
max-w-7xl
1280px
Data-heavy tables, pickers

Simple Modal (No Steps)

Omit the steps config for confirmation dialogs and single-screen edit modals. When steps is empty, hasModalSteps is false and isLastModalStep is true.

Confirm delete example
Alpine.data('confirmDelete', function() {
    return {
        ...Nebula.modal({ size: 'sm', title: 'Confirm Delete' }),
        targetId: null,

        confirmDelete(id) {
            this.targetId = id;
            this.openModal();
        },

        doDelete() {
            // Perform delete with this.targetId ...
            this.closeModal();
        }
    };
});
Skip the step indicators in the header when hasModalSteps is false. Use <template x-if="hasModalSteps"> around the step indicator markup so it renders nothing for simple modals.

Custom Events

EventFired WhenBubbles
nebula-modal:openAfter openModal() completes (next tick)Yes
nebula-modal:closeAfter closeModal() completesYes
Listening for modal events
<!-- From a parent or sibling element -->
<div @nebula-modal:open.window="console.log('A modal opened')"
     @nebula-modal:close.window="console.log('A modal closed')">
</div>